衆所周知 Vue 是藉助 ES5 的 Object.defineProperty
方法設置 getter、setter 達到數據驅動界面,固然其中還有模板編譯等等其餘過程。javascript
而小程序官方的 api 是在 Page
中調用 this.setData
方法來改變數據,從而改變界面。html
那麼假如咱們將二者結合一下,將 this.setData
封裝起來,豈不是能夠像開發 Vue 應用同樣地使用 this.foo = 'hello'
來開發小程序了?前端
第一步咱們先定一個小目標:掙他一個億!!!java
對於簡單非嵌套屬性(非對象,數組),直接對其賦值就能改變界面。git
<!-- index.wxml --> <view>msg: {{ msg }}</view> <button bindtap="tapMsg">change msg</button>
// index.js TuaPage({ data () { return { msg: 'hello world', } }, methods: { tapMsg () { this.msg = this.reverseStr(this.msg) }, reverseStr (str) { return str.split('').reverse().join('') }, }, })
這一步很簡單啦,直接對於 data 中的每一個屬性都綁定下 getter、setter,在 setter 中調用下 this.setData
就好啦。github
/** * 將 source 上的屬性代理到 target 上 * @param {Object} source 被代理對象 * @param {Object} target 被代理目標 */ 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 實例 */ 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) }, }) } /** * 觀察對象 * @param {any} obj 待觀察對象 * @return {any} 已被觀察的對象 */ const observe = (obj) => { const observedObj = Object.create(null) Object.keys(obj).forEach((key) => { // 過濾 __wxWebviewId__ 等內部屬性 if (/^__.*__$/.test(key)) return defineReactive( observedObj, key, obj[key] ) }) return observedObj } const $data = observe(vm.data) vm.$data = $data proxyData($data, vm) } /** * 適配 Vue 風格代碼,使其支持在小程序中運行(告別不方便的 setData) * @param {Object} args Page 參數 */ 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) }, }) }
那麼若是數據是嵌套的對象咋辦咧?
其實也很簡單,我們遞歸觀察一下就好。web
<!-- index.wxml --> <view>a.b: {{ a.b }}</view> <button bindtap="tapAB">change a.b</button>
// 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('') }, }, })
observe
-> observeDeep
:在 observeDeep
中判斷是對象就遞歸觀察下去。編程
// ... /** * 遞歸觀察對象 * @param {any} obj 待觀察對象 * @return {any} 已被觀察的對象 */ 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 } // 簡單屬性直接返回 return obj } // ...
你們都知道,Vue 劫持了一些數組方法。我們也來依葫蘆畫瓢地實現一下~小程序
/** * 劫持數組的方法 * @param {Array} arr 原始數組 * @return {Array} observedArray 被劫持方法後的數組 */ 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 }
其實,Vue 還作了個優化,若是當前環境有
__proto__
屬性,那麼就把以上方法直接加到數組的原型鏈上,而不是對每一個數組數據的方法進行修改。
computed
功能平常還蠻經常使用的,經過已有的 data
元數據,派生出一些方便的新數據。segmentfault
要實現的話,由於 computed
中的數據都定義成函數,因此其實直接將其設置爲 getter
就行啦。
/** * 將 computed 中定義的新屬性掛到 vm 上 * @param {Page|Component} vm Page 或 Component 實例 * @param {Object} computed 計算屬性對象 */ 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 中數據變化時能夠一塊兒被 setData proxyData($computed, vm.$data) // 初始化 vm.setData($computed) }
接下來又是一個炒雞好用的 watch
功能,即監聽 data
或 computed
中的數據,在其變化的時候調用回調函數,並傳入 newVal
和 oldVal
。
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) // 實現 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) // 實現 watch computed 屬性 const watchFn = watch[key] if (typeof watchFn === 'function' && newVal !== oldVal) { watchFn.call(vm, newVal, oldVal) } // 重置 oldVal oldVal = newVal return newVal }, set () {}, }) }) // ... }
看似不錯,實則否則。
我們如今碰到了一個問題:如何監聽相似 'a.b' 這樣的嵌套數據?
這個問題的緣由在於咱們在遞歸遍歷數據的時候沒有記錄下路徑。
解決這個問題並不難,其實咱們只要在遞歸觀察的每一步中傳遞 key
便可,注意對於數組中的嵌套元素傳遞的是 [${index}]
。
而且一旦咱們知道了數據的路徑,還能夠進一步提升 setData
的性能。
由於咱們能夠精細地調用 vm.setData({ [prefix]: newVal })
修改其中的部分數據,而不是將整個 $data
都 setData
。
const defineReactive = (obj, key, val, path) => { Object.defineProperty(obj, key, { // ... set (newVal) { // ... vm.setData({ // 由於不知道依賴因此更新整個 computed ...vm.$computed, // 直接修改目標數據 [path]: newVal, }) // 經過路徑來找 watch 目標 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({ // 由於不知道依賴因此更新整個 computed ...vm.$computed, // 直接修改目標數據 [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 實例 * @param {Object} computed 計算屬性對象 * @param {Object} watch 偵聽器對象 */ const bindComputed = (vm, computed, watch) => { // ... proxyData($computed, vm) // 掛在 vm 上,在 data 變化時從新 setData vm.$computed = $computed // 初始化 vm.setData($computed) }
目前的代碼還有個問題:每次對於 data
某個數據的修改都會觸發 setData
,那麼假如反覆地修改同一個數據,就會頻繁地觸發 setData
。而且每一次修改數據都會觸發 watch
的監聽...
總結一下就是這三種常見的 setData 操做錯誤:
- 頻繁的去 setData
- 每次 setData 都傳遞大量新數據
- 後臺態頁面進行 setData
計將安出?
答案就是緩存一下,異步執行 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({ // 由於不知道依賴因此更新整個 computed ...vm.$computed, ...newState, }) if (typeof watchFn === 'function') { watchFn.call(vm, newState[prefix], oldVal) } newState = null }) }
在 Vue 中由於兼容性問題,優先選擇使用 Promise.then
,其次是 MutationObserve
,最後纔是 setTimeout
。
由於 Promise.then
和 MutationObserve
屬於 microtask
,而 setTimeout
屬於 task
。
根據 HTML Standard,在每一個 task
運行完之後,UI
都會重渲染,那麼在 microtask
中就完成數據更新,當前 task
結束就能夠獲得最新的 UI
了。反之若是新建一個 task
來作數據更新,那麼渲染就會進行兩次。(固然,瀏覽器實現有很多不一致的地方)
有興趣的話推薦看下這篇文章:Tasks, microtasks, queues and schedules
以前的代碼爲了方便地獲取 vm 和 watch,在 bindData
函數中又定義了三個函數,整個代碼耦合度過高了,函數依賴很不明確。
// 代碼耦合度過高 const bindData = (vm, watch) => { const defineReactive = () => {} const observeArray = () => {} const observeDeep = () => {} // ... }
這樣在下一步編寫單元測試的時候很麻煩。
爲了寫測試讓我們來重構一把,利用學習過的函數式編程中的高階函數把依賴注入。
// 高階函數,傳遞 vm 和 watch 而後獲得 asyncSetData const getAsyncSetData = (vm, watch) => ({ ... }) => { ... } // 從 bindData 中移出來 // 原來放在裏面就是爲了獲取 vm,而後調用 vm.setData // 以及經過 watch 獲取監聽函數 const defineReactive = ({ // ... asyncSetData, // 不傳 vm 改爲傳遞 asyncSetData }) => { ... } // 同理 const observeArray = ({ // ... asyncSetData, // 同理 }) => { ... } // 一樣外移,由於依賴已注入了 asyncSetData const getObserveDeep = (asyncSetData) => { ... } // 函數外移後代碼邏輯更加清晰精簡 const bindData = (vm, observeDeep) => { const $data = observeDeep(vm.data) vm.$data = $data proxyData($data, vm) }
高階函數是否是很膩害!代碼瞬間就在沒事的時候,在想的時候,到一個地方,不相同的地方,到這個地方,來了吧!能夠瞧一瞧,不同的地方,不相同的地方,改變了不少不少
那麼接下來你必定會偷偷地問本身,這麼膩害的技術要去哪裏學呢?
其實以上代碼還有一個目前解決不了的問題:咱們不知道 computed
裏定義的函數的依賴是什麼。因此在 data
數據更新的時候咱們只好所有再算一遍。
也就是說當 data
中的某個數據更新的時候,咱們並不知道它會影響哪一個 computed
中的屬性,特別的還有 computed
依賴於 computed
的狀況。
計將安出?
且聽下回分解~溜了溜了,嘿嘿嘿...
以上 to be continued...