使用 Proxy 实现简单的 MVVM 模型
绑定实现的历史
绑定的基础是 propertyChange 事件。如何得知 viewModel 成员值的改变一直是开发 MVVM 框架的首要问题。主流框架的处理有一下三大类:
- 另外开发一套 API。典型框架:Backbone.js
Backbone 有自己的 模型类 和 集合类。这样做虽然框架开发简单运行效率也高,但开发者不得不使用这套 API 操作 viewModel,导致上手复杂、代码繁琐。
- 脏检查机制。典型框架:angularjs
特点是直接使用 JS 原生操作对象的语法操作 viewModel,开发者上手简单、代码简单。但脏检查机制随之带来的就是性能问题。这点在我另外的一篇博文 《Angular 1 深度解析:脏数据检查与 angular 性能优化》 有详细讲解这里不另加赘述。
- 替换属性。典型框架:vuejs
vuejs 把开发者定义的 viewModel 对象(即 data 函数返回的对象)中所有的(除某些前缀开头的)成员替换为属性。这样既可以使用 JS 原生操作对象的语法,又是主动触发propertyChange事件,效率也高。但这种方法也有一些限制,后文会分析。
Object.observe
Object.observe 是谷歌对于简化双向绑定机制的尝试,在 Chrome 49 中引入。然而由于性能等问题,并没有被其他各大浏览器及 ES 标准所接受。挣扎了一段时间后谷歌 Chrome 团队宣布收回 Object.observe 的提议,并在 Chrome 50 中完全删除了 Object.observe 实现。
Proxy
Proxy(代理)是 ES2015 加入的新特性,用于对某些基本操作定义自定义行为,类似于其他语言中的面向切面编程。它的其中一个作用就是用于(部分)替代 Object.observe 以实现双向绑定。
例如有一个对象
let viewModel = {};
可以构造对应的代理类实现对 viewModel 的属性赋值操作的监听:
viewModel = new Proxy(viewModel, {
set(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = value;
console.log(`${prop} 属性被改为 ${value}`);
}
return true;
}
});
这时所有对 viewModel 的属性赋值的操作都不会直接生效,而是将这个操作转发给 Proxy 中注册的 set 方法,其中的参数 obj 是原始对象(注意不能直接用 a,否则还会触发代理函数,造成无限递归),prop 是被赋值的属性名,value 是待赋的值。
如果有:
viewModel.test = 1;
这时就会输出 test 属性被改为 1。
用 Proxy 实现简单的单向绑定。
有了 Proxy 就可以得知 viewModel 中属性的变更了,还需要更新页面上绑定此属性的元素。
简单起见,我们用 this 表示 viewModel 本身,使用 this.XXX 就表示依赖 XXX 属性。有 DOM 如下:
<div my-bind="'str1 + str2 = ' + (this.str1 + this.str2)"></div>
<div my-bind="'num1 - num2 = ' + (this.num1 - this.num2)"></div>
首先要获得所有使用了单向绑定的元素:
const bindingElements = [...document.querySelectorAll('[my-bind]')];
获取绑定表达式:
bindingElements.forEach(el => {
const expression = el.getAttribute('my-bind');
});
由于获得的表达式是个字符串,需要构造一个函数去执行它,得到表达式的结果:
const expression = el.getAttribute('my-bind');
const result = new Function('"use strict";\nreturn ' + expression).call(viewModel);
代码中会动态创建一个函数,内容就是将字符串解析执行后将其结果返回(类似 eval,但更安全)。将结果放到页面上就可以了:
el.textContent = result;
与上文的 viewModel 结合起来:
const bindingElements = [...document.querySelectorAll('[my-bind]')];
window.viewModel = new Proxy({}, { // 设置全局变量方便调试
set(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = value;
bindingElements.forEach(el => {
const expression = el.getAttribute('my-bind');
const result = new Function('"use strict";\nreturn ' + expression)
.call(obj);
el.textContent = result;
});
}
return true;
}
});
如果实际放在浏览器中运行的话,改变 viewModel 中属性的值就会触发页面的更新。
示例中写了循环会更新所有绑定元素,比较好的方式是只更新对当前变更属性有依赖的元素。这时就要分析绑定表达式的属性依赖。
简单起见可以使用正则表达式解析属性依赖:
let match;
while (match = /this(?:\.(\w+))+/g.exec(expression)) {
match[1] // 属性依赖
}
添加事件绑定
事件绑定即绑定原生事件,在事件触发时执行绑定表达式,表达式调用 viewModel 中的某个回调函数。
以 click 事件为例。依然是获取所有绑定了 click 事件的元素,并执行表达式(表达式的值被丢弃)。与单项绑定不同的是:执行表达式需要传入事件的 event 参数。
[...document.querySelectorAll('[my-click]')].forEach(el => {
const expression = el.getAttribute('my-click');
const fn = new Function('$event', '"use strict";\n' + expression);
el.addEventListener('click', event => {
fn.call(viewModel, event);
});
});
Function 对象的构造函数,前 n-1 个参数是生成的函数对象的参数名,最后一个是函数体。代码中构造了包含一个 $event 参数的函数,函数体就是直接执行绑定表达式。
双向绑定
双向绑定就是单项绑定和事件绑定的结合体。绑定元素的 input 事件来修改 viewModel 的属性,然后再单项绑定元素的 value 属性修改元素的值。
这里是一个较为完整的示例:http://sandbox.runjs.cn/show/...。完整的代码放在我的 GitHub 仓库
使用 Proxy 实现双向绑定的优缺点
相较于 vuejs 的属性替换,Proxy 实现的绑定至少有如下三个优点:
- 无需预先定义待绑定的属性。
vuejs 要做属性(getter, setter 方法)替换,首先需要知道有哪些属性需要替换,这样导致必须预先定义需要替换的属性,也就是 vuejs 中的 data 方法。vuejs 中 data 方法必须定义完整所有绑定属性,否则对应绑定不能正常工作。
Vue 不能检测到对象属性的添加或删除:Property or method "XXX" is not defined on the instance but referenced during render. Make sure to declare reactive data properties in the data option.
而 Proxy 不需要,因为它监听的是整个对象。
- 对数组相性良好。
虽说数组里的方法可以替换(push、pop等),但是数组下标却不能替换为属性,以致必须搞出一个 set 方法用于对数组下标赋值。
- 更容易调试的 viewModel 对象。
由于 vuejs 把对象中的所有成员全部替换成了属性,如果想直接用 Chrome 的原生调试工具查看属性值,你不得不挨个去点属性后面的 (...):因为获取属性的值其实是执行了属性的 get 方法,执行一个方法可能会产生副作用,Chrome 把这个决定权留给开发者。 Proxy 对象不需要。Proxy 的 set 方法只是一层包装,Proxy 对象自身维护原始对象的值,自然也可以直接拿出原始值给开发者看。查看一个 Proxy 对象,只需要展开其内置属性 [[Target]] 即可看到原始对象的所有成员的值。你甚至还可以看到包装原始对象的哪些 get、set 函数——如果你感兴趣的话。
虽说使用 Proxy 实现双向绑定的优点很明显,但是缺点也很明显:Proxy 是 ES2015 的特性,它无法被编译为 ES5,也无法 Polyfill。IE 自然全军覆没;其他各大浏览器实现的时间也较晚:Chrome 49、Safari 10。浏览器兼容性极大的限制了 Proxy 的使用。但是我相信,随着时间的推移,基于 Proxy 的前端 MVVM 框架也会出现在开发者眼前。
浙公网安备 33010602011771号