众所周知 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...