使用 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
框架也会出现在开发者眼前。