終極蛇皮上帝視角之微信小程序之告別 setData

眾所周知 Vue 是借助 ES5 的 Object.defineProperty 方法設(shè)置 getter、setter 達(dá)到數(shù)據(jù)驅(qū)動(dòng)界面,當(dāng)然其中還有模板編譯等等其他過(guò)程。

而小程序官方的 api 是在 Page 中調(diào)用 this.setData 方法來(lái)改變數(shù)據(jù),從而改變界面。

那么假如我們將兩者結(jié)合一下,將 this.setData 封裝起來(lái),豈不是可以像開發(fā) Vue 應(yīng)用一樣地使用 this.foo = 'hello' 來(lái)開發(fā)小程序了?

  • 更進(jìn)一步地,可以實(shí)現(xiàn) h5 和小程序 js 部分代碼的同構(gòu)

  • 更進(jìn)一步地,增加模板編譯和解析就可以連 wxml/html 部分也同構(gòu)

  • 更進(jìn)一步地,兼容 RN/Weex/快應(yīng)用

  • 更進(jìn)一步地,世界大同,天下為公,前端工程師全部失業(yè)...23333

E(@0V~RIV(`94ZETJC`UN$2.png 終極蛇皮上帝視角之微信小程序之告別 setData的圖2

0.源碼地址

1.綁定簡(jiǎn)單屬性

第一步我們先定一個(gè)小目標(biāo):掙他一個(gè)億!!!

對(duì)于簡(jiǎn)單非嵌套屬性(非對(duì)象,數(shù)組),直接對(duì)其賦值就能改變界面。

<!-- index.wxml --> <view>msg: {{ msg }}</view> <button bindtap="tapMsg">change msg</button> 復(fù)制代碼
// index.js TuaPage({     data () {         return {             msg: 'hello world',         }     },     methods: {         tapMsg () {             this.msg = this.reverseStr(this.msg)         },         reverseStr (str) {             return str.split('').reverse().join('')         },     }, }) 復(fù)制代碼

這一步很簡(jiǎn)單啦,直接對(duì)于 data 中的每個(gè)屬性都綁定下 getter、setter,在 setter 中調(diào)用下 this.setData 就好啦。

/**  * 將 source 上的屬性代理到 target 上  * @param {Object} source 被代理對(duì)象  * @param {Object} target 被代理目標(biāo)  */ const proxyData = (source, target) => {     Object.keys(source).forEach((key) => {         Object.defineProperty(             target,             key,             Object.getOwnPropertyDescriptor(source, key)         )     }) } /**  * 遍歷觀察 vm.data 中的所有屬性,并將其直接掛到 vm 上  * @param {Page|Component} vm Page 或 Component 實(shí)例  */ const bindData = (vm) => {     const defineReactive = (obj, key, val) => {         Object.defineProperty(obj, key, {             enumerable: true,             configurable: true,             get () { return val },             set (newVal) {                 if (newVal === val) return                 val = newVal                 vm.setData($data)             },         })     }     /**      * 觀察對(duì)象      * @param {any} obj 待觀察對(duì)象      * @return {any} 已被觀察的對(duì)象      */     const observe = (obj) => {         const observedObj = Object.create(null)         Object.keys(obj).forEach((key) => {             // 過(guò)濾 __wxWebviewId__ 等內(nèi)部屬性             if (/^__.*__$/.test(key)) return             defineReactive(                 observedObj,                 key,                 obj[key]             )         })         return observedObj     }     const $data = observe(vm.data)     vm.$data = $data     proxyData($data, vm) } /**  * 適配 Vue 風(fēng)格代碼,使其支持在小程序中運(yùn)行(告別不方便的 setData)  * @param {Object} args Page 參數(shù)  */ export const TuaPage = (args = {}) => {     const {         data: rawData = {},         methods = {},         ...rest     } = args     const data = typeof rawData === 'function'         ? rawData()         : rawData     Page({         ...rest,         ...methods,         data,         onLoad (...options) {             bindData(this)             rest.onLoad && rest.onLoad.apply(this, options)         },     }) } 復(fù)制代碼

2.綁定嵌套對(duì)象

那么如果數(shù)據(jù)是嵌套的對(duì)象咋辦咧?

其實(shí)也很簡(jiǎn)單,咱們遞歸觀察一下就好。

<!-- index.wxml --> <view>a.b: {{ a.b }}</view> <button bindtap="tapAB">change a.b</button> 復(fù)制代碼
// index.js TuaPage({     data () {         return {             a: { b: 'this is b' },         }     },     methods: {         tapAB () {             this.a.b = this.reverseStr(this.a.b)         },         reverseStr (str) {             return str.split('').reverse().join('')         },     }, }) 復(fù)制代碼

observe -> observeDeep:在 observeDeep 中判斷是對(duì)象就遞歸觀察下去。

// ... /**  * 遞歸觀察對(duì)象  * @param {any} obj 待觀察對(duì)象  * @return {any} 已被觀察的對(duì)象  */ const observeDeep = (obj) => {     if (typeof obj === 'object') {         const observedObj = Object.create(null)         Object.keys(obj).forEach((key) => {             if (/^__.*__$/.test(key)) return             defineReactive(                 observedObj,                 key,                 // -> 注意在這里遞歸                 observeDeep(obj[key]),             )         })         return observedObj     }     // 簡(jiǎn)單屬性直接返回     return obj } // ... 復(fù)制代碼

3.劫持?jǐn)?shù)組方法

大家都知道,Vue 劫持了一些數(shù)組方法。咱們也來(lái)依葫蘆畫瓢地實(shí)現(xiàn)一下~

/**  * 劫持?jǐn)?shù)組的方法  * @param {Array} arr 原始數(shù)組  * @return {Array} observedArray 被劫持方法后的數(shù)組  */ const observeArray = (arr) => {     const observedArray = arr.map(observeDeep)     ;[         'pop',         'push',         'sort',         'shift',         'splice',         'unshift',         'reverse',     ].forEach((method) => {         const original = observedArray[method]         observedArray[method] = function (...args) {             const result = original.apply(this, args)             vm.setData($data)             return result         }     })     return observedArray } 復(fù)制代碼

其實(shí),Vue 還做了個(gè)優(yōu)化,如果當(dāng)前環(huán)境有 __proto__ 屬性,那么就把以上方法直接加到數(shù)組的原型鏈上,而不是對(duì)每個(gè)數(shù)組數(shù)據(jù)的方法進(jìn)行修改。

4.實(shí)現(xiàn) computed 功能

computed 功能日常還蠻常用的,通過(guò)已有的 data 元數(shù)據(jù),派生出一些方便的新數(shù)據(jù)。

要實(shí)現(xiàn)的話,因?yàn)?computed 中的數(shù)據(jù)都定義成函數(shù),所以其實(shí)直接將其設(shè)置為 getter 就行啦。

/**  * 將 computed 中定義的新屬性掛到 vm 上  * @param {Page|Component} vm Page 或 Component 實(shí)例  * @param {Object} computed 計(jì)算屬性對(duì)象  */ const bindComputed = (vm, computed) => {     const $computed = Object.create(null)     Object.keys(computed).forEach((key) => {         Object.defineProperty($computed, key, {             enumerable: true,             configurable: true,             get: computed[key].bind(vm),             set () {},         })     })     proxyData($computed, vm)     // 掛到 $data 上,這樣在 data 中數(shù)據(jù)變化時(shí)可以一起被 setData     proxyData($computed, vm.$data)     // 初始化     vm.setData($computed) } 復(fù)制代碼

5.實(shí)現(xiàn) watch 功能

接下來(lái)又是一個(gè)炒雞好用的 watch 功能,即監(jiān)聽 datacomputed 中的數(shù)據(jù),在其變化的時(shí)候調(diào)用回調(diào)函數(shù),并傳入 newValoldVal

const defineReactive = (obj, key, val) => {     Object.defineProperty(obj, key, {         enumerable: true,         configurable: true,         get () { return val },         set (newVal) {             if (newVal === val) return             // 這里保存 oldVal             const oldVal = val             val = newVal             vm.setData($data)             // 實(shí)現(xiàn) watch data 屬性             const watchFn = watch[key]             if (typeof watchFn === 'function') {                 watchFn.call(vm, newVal, oldVal)             }         },     }) } const bindComputed = (vm, computed, watch) => {     const $computed = Object.create(null)     Object.keys(computed).forEach((key) => {         // 這里保存 oldVal         let oldVal = computed[key].call(vm)         Object.defineProperty($computed, key, {             enumerable: true,             configurable: true,             get () {                 const newVal = computed[key].call(vm)                 // 實(shí)現(xiàn) watch computed 屬性                 const watchFn = watch[key]                 if (typeof watchFn === 'function' && newVal !== oldVal) {                     watchFn.call(vm, newVal, oldVal)                 }                 // 重置 oldVal                 oldVal = newVal                 return newVal             },             set () {},         })     })     // ... } 復(fù)制代碼

看似不錯(cuò),實(shí)則不然。

咱們現(xiàn)在碰到了一個(gè)問(wèn)題:如何監(jiān)聽類似 'a.b' 這樣的嵌套數(shù)據(jù)?

這個(gè)問(wèn)題的原因在于我們?cè)谶f歸遍歷數(shù)據(jù)的時(shí)候沒(méi)有記錄下路徑。

6.記錄路徑

解決這個(gè)問(wèn)題并不難,其實(shí)我們只要在遞歸觀察的每一步中傳遞 key 即可,注意對(duì)于數(shù)組中的嵌套元素傳遞的是 [${index}]

并且一旦我們知道了數(shù)據(jù)的路徑,還可以進(jìn)一步提高 setData 的性能。

因?yàn)槲覀兛梢跃?xì)地調(diào)用 vm.setData({ [prefix]: newVal }) 修改其中的部分?jǐn)?shù)據(jù),而不是將整個(gè) $datasetData

const defineReactive = (obj, key, val, path) => {     Object.defineProperty(obj, key, {         // ...         set (newVal) {             // ...             vm.setData({                 // 因?yàn)椴恢酪蕾囁愿抡麄€(gè) computed                 ...vm.$computed,                 // 直接修改目標(biāo)數(shù)據(jù)                 [path]: newVal,             })             // 通過(guò)路徑來(lái)找 watch 目標(biāo)             const watchFn = watch[path]             if (typeof watchFn === 'function') {                 watchFn.call(vm, newVal, oldVal)             }         },     }) } const observeArray = (arr, path) => {     const observedArray = arr.map(         // 注意這里的路徑拼接         (item, idx) => observeDeep(item, `${path}[${idx}]`)     )     ;[         'pop',         'push',         'sort',         'shift',         'splice',         'unshift',         'reverse',     ].forEach((method) => {         const original = observedArray[method]         observedArray[method] = function (...args) {             const result = original.apply(this, args)             vm.setData({                 // 因?yàn)椴恢酪蕾囁愿抡麄€(gè) computed                 ...vm.$computed,                 // 直接修改目標(biāo)數(shù)據(jù)                 [path]: observedArray,             })             return result         }     })     return observedArray } const observeDeep = (obj, prefix = '') => {     if (Array.isArray(obj)) {         return observeArray(obj, prefix)     }     if (typeof obj === 'object') {         const observedObj = Object.create(null)         Object.keys(obj).forEach((key) => {             if (/^__.*__$/.test(key)) return             const path = prefix === ''                 ? key                 : `${prefix}.${key}`             defineReactive(                 observedObj,                 key,                 observeDeep(obj[key], path),                 path,             )         })         return observedObj     }     return obj } /**  * 將 computed 中定義的新屬性掛到 vm 上  * @param {Page|Component} vm Page 或 Component 實(shí)例  * @param {Object} computed 計(jì)算屬性對(duì)象  * @param {Object} watch 偵聽器對(duì)象  */ const bindComputed = (vm, computed, watch) => {     // ...     proxyData($computed, vm)     // 掛在 vm 上,在 data 變化時(shí)重新 setData     vm.$computed = $computed     // 初始化     vm.setData($computed) } 復(fù)制代碼

7.異步 setData

目前的代碼還有個(gè)問(wèn)題:每次對(duì)于 data 某個(gè)數(shù)據(jù)的修改都會(huì)觸發(fā) setData,那么假如反復(fù)地修改同一個(gè)數(shù)據(jù),就會(huì)頻繁地觸發(fā) setData。并且每一次修改數(shù)據(jù)都會(huì)觸發(fā) watch 的監(jiān)聽...

而這恰恰是使用小程序 setData api 的大忌:

總結(jié)一下就是這三種常見的 setData 操作錯(cuò)誤:

  1. 頻繁的去 setData

  2. 每次 setData 都傳遞大量新數(shù)據(jù)

  3. 后臺(tái)態(tài)頁(yè)面進(jìn)行 setData

計(jì)將安出?

終極蛇皮上帝視角之微信小程序之告別 setData的圖3

答案就是緩存一下,異步執(zhí)行 setData~

let newState = null /**  * 異步 setData 提高性能  */ const asyncSetData = ({     vm,     newData,     watchFn,     prefix,     oldVal, }) => {     newState = {         ...newState,         ...newData,     }     // TODO: Promise -> MutationObserve -> setTimeout     Promise.resolve().then(() => {         if (!newState) return         vm.setData({             // 因?yàn)椴恢酪蕾囁愿抡麄€(gè) computed             ...vm.$computed,             ...newState,         })         if (typeof watchFn === 'function') {             watchFn.call(vm, newState[prefix], oldVal)         }         newState = null     }) } 復(fù)制代碼

在 Vue 中因?yàn)榧嫒菪詥?wèn)題,優(yōu)先選擇使用 Promise.then,其次是 MutationObserve,最后才是 setTimeout

因?yàn)?Promise.thenMutationObserve 屬于 microtask,而 setTimeout 屬于 task

為啥要用 microtask

根據(jù) HTML Standard,在每個(gè) task 運(yùn)行完以后,UI 都會(huì)重渲染,那么在 microtask 中就完成數(shù)據(jù)更新,當(dāng)前 task 結(jié)束就可以得到最新的 UI 了。反之如果新建一個(gè) task 來(lái)做數(shù)據(jù)更新,那么渲染就會(huì)進(jìn)行兩次。(當(dāng)然,瀏覽器實(shí)現(xiàn)有不少不一致的地方)

有興趣的話推薦看下這篇文章:Tasks, microtasks, queues and schedules

8.代碼重構(gòu)

之前的代碼為了方便地獲取 vm 和 watch,在 bindData 函數(shù)中又定義了三個(gè)函數(shù),整個(gè)代碼耦合度太高了,函數(shù)依賴很不明確。

// 代碼耦合度太高 const bindData = (vm, watch) => {     const defineReactive = () => {}     const observeArray = () => {}     const observeDeep = () => {}     // ... } 復(fù)制代碼

這樣在下一步編寫單元測(cè)試的時(shí)候很麻煩。

為了寫測(cè)試讓咱們來(lái)重構(gòu)一把,利用學(xué)習(xí)過(guò)的函數(shù)式編程中的高階函數(shù)把依賴注入。

// 高階函數(shù),傳遞 vm 和 watch 然后得到 asyncSetData const getAsyncSetData = (vm, watch) => ({ ... }) => { ... } // 從 bindData 中移出來(lái) // 原來(lái)放在里面就是為了獲取 vm,然后調(diào)用 vm.setData // 以及通過(guò) watch 獲取監(jiān)聽函數(shù) const defineReactive = ({     // ...     asyncSetData, // 不傳 vm 改成傳遞 asyncSetData }) => { ... } // 同理 const observeArray = ({     // ...     asyncSetData, // 同理 }) => { ... } // 同樣外移,因?yàn)橐蕾囈炎⑷肓?nbsp;asyncSetData const getObserveDeep = (asyncSetData) => { ... } // 函數(shù)外移后代碼邏輯更加清晰精簡(jiǎn) const bindData = (vm, observeDeep) => {     const $data = observeDeep(vm.data)     vm.$data = $data     proxyData($data, vm) } 復(fù)制代碼

高階函數(shù)是不是很膩害!代碼瞬間就在沒(méi)事的時(shí)候,在想的時(shí)候,到一個(gè)地方,不相同的地方,到這個(gè)地方,來(lái)了吧!可以瞧一瞧,不一樣的地方,不相同的地方,改變了很多很多

終極蛇皮上帝視角之微信小程序之告別 setData的圖4

那么接下來(lái)你一定會(huì)偷偷地問(wèn)自己,這么膩害的技術(shù)要去哪里學(xué)呢?

終極蛇皮上帝視角之微信小程序之告別 setData的圖5

9.依賴收集

其實(shí)以上代碼還有一個(gè)目前解決不了的問(wèn)題:我們不知道 computed 里定義的函數(shù)的依賴是什么。所以在 data 數(shù)據(jù)更新的時(shí)候我們只好全部再算一遍。

也就是說(shuō)當(dāng) data 中的某個(gè)數(shù)據(jù)更新的時(shí)候,我們并不知道它會(huì)影響哪個(gè) computed 中的屬性,特別的還有 computed 依賴于 computed 的情況。


作者:佯真愚
來(lái)源:掘金

登錄后免費(fèi)查看全文
立即登錄
App下載
技術(shù)鄰APP
工程師必備
  • 項(xiàng)目客服
  • 培訓(xùn)客服
  • 平臺(tái)客服

TOP