眾所周知 Vue 是藉助 ES5 的 Object.defineProperty
方法設定 getter、setter 達到資料驅動介面,當然其中還有模板編譯等等其他過程。
而小程式官方的 api 是在 Page
中呼叫 this.setData
方法來改變資料,從而改變介面。
那麼假如我們將兩者結合一下,將 this.setData
封裝起來,豈不是可以像開發 Vue 應用一樣地使用 this.foo = `hello`
來開發小程式了?
- 更進一步地,可以實現 h5 和小程式 js 部分程式碼的同構
- 更進一步地,增加模板編譯和解析就可以連 wxml/html 部分也同構
- 更進一步地,相容 RN/Weex/快應用
- 更進一步地,世界大同,天下為公,前端工程師全部失業…23333
0.原始碼地址
1.繫結簡單屬性
第一步我們先定一個小目標:掙他一個億!!!
對於簡單非巢狀屬性(非物件,陣列),直接對其賦值就能改變介面。
<!-- 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
就好啦。
/**
* 將 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)
},
})
}
複製程式碼
2.繫結巢狀物件
那麼如果資料是巢狀的物件咋辦咧?
其實也很簡單,我們們遞迴觀察一下就好。
<!-- 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
}
// ...
複製程式碼
3.劫持陣列方法
大家都知道,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__
屬性,那麼就把以上方法直接加到陣列的原型鏈上,而不是對每個陣列資料的方法進行修改。
4.實現 computed 功能
computed
功能日常還蠻常用的,通過已有的 data
後設資料,派生出一些方便的新資料。
要實現的話,因為 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)
}
複製程式碼
5.實現 watch 功能
接下來又是一個炒雞好用的 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` 這樣的巢狀資料?
這個問題的原因在於我們在遞迴遍歷資料的時候沒有記錄下路徑。
6.記錄路徑
解決這個問題並不難,其實我們只要在遞迴觀察的每一步中傳遞 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)
}
複製程式碼
7.非同步 setData
目前的程式碼還有個問題:每次對於 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
8.程式碼重構
之前的程式碼為了方便地獲取 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)
}
複製程式碼
高階函式是不是很膩害!程式碼瞬間就在沒事的時候,在想的時候,到一個地方,不相同的地方,到這個地方,來了吧!可以瞧一瞧,不一樣的地方,不相同的地方,改變了很多很多
那麼接下來你一定會偷偷地問自己,這麼膩害的技術要去哪裡學呢?
- slide
- JavaScript 函數語言程式設計(一)
- JavaScript 函數語言程式設計(二)
- JavaScript 函數語言程式設計(三)
- JavaScript 函數語言程式設計(四)正在醞釀…
9.依賴收集
其實以上程式碼還有一個目前解決不了的問題:我們不知道 computed
裡定義的函式的依賴是什麼。所以在 data
資料更新的時候我們只好全部再算一遍。
也就是說當 data
中的某個資料更新的時候,我們並不知道它會影響哪個 computed
中的屬性,特別的還有 computed
依賴於 computed
的情況。
計將安出?
且聽下回分解~溜了溜了,嘿嘿嘿…
以上 to be continued…