本文介绍前端MVVM框架中双向数据绑定的三种实现方式:脏检查、观察机制和封装属性访问器。重点解析avalon.js如何利用ECMAScript5.1的Object.defineProperty方法,通过定义get和set访问器来实现数据监听,并指出配置属性时的常见错误及注意事项。
缘起
双向数据绑定的实现方式概述
近期在研究一些流行的轻量级MVVM框架(如avalon.js、vue.js等,区别于AngularJS、EmberJS等重量级框架)的实现机制。现代主流MVVM框架通常将数据双向绑定(two-ways data binding)作为核心功能卖点(Ember.js似乎不支持此特性),且各框架的实现方式各异。例如,AngularJS内部采用脏检查机制,而avalon.js的本质则是设置属性访问器。
此处不深入探讨各框架双向绑定的具体实现细节,仅简述前端实现双向数据绑定的几种常用方法,并重点讲解avalon.js在技术选型上的思路。
双向数据绑定的常规实现方式
首先明确前端双向数据绑定的定义。简而言之,即框架的控制器层(泛指连接View行为与Model层的中间件)与UI展示层(View层)之间建立双向数据通道。当其中任意一层数据发生变化时,另一层会立即(或感知上立即)自动做出相应更新。
目前前端实现这种双向绑定关系(即控制器层与展示层的关联)主要有三种方式:
1. 脏检查
2. 观察机制
3. 封装属性访问器
脏检查机制
脏检查
以AngularJS(特指1.x.x版本,非2.x.x)为例,其双向绑定技术基于脏检查。原理是AngularJS内部维护一个监控序列,将所有需监控的属性纳入其中。当发生特定事件(非定时触发,而是由特殊事件触发)时,AngularJS调用$digest方法。该方法遍历所有watcher,对比监控属性在调用前后的值是否变化,若变化则执行对应的handler。网上有许多剖析AngularJS双向绑定原理的文章,如相关技术解析等。
该方式的缺点显而易见:遍历轮询watcher非常消耗性能,尤其在单页应用监控属性数量达到一定规模时。
观察机制
观察机制
此前曾转载翻译过一篇名为《Object.observe()带来的数据绑定变革》的文章,介绍了使用ECMAScript7中的Object.observe方法对对象或其属性进行监控。一旦属性发生变化,即执行相应的handler。
这是目前监控属性数据变更最理想的方法,由语言(浏览器)原生支持,无需额外封装。唯一的遗憾是目前浏览器支持广度不足,有待全面推广。
封装属性访问器
封装属性访问器
PHP中有魔术方法(如__get()和__set())的概念。JavaScript中也有类似概念,称为访问器。参考以下示例代码:
var data = {
name: "erik",
getName: function() {
return this.name;
},
setName: function(name) {
this.name = name;
}
};
从上述代码可见,data对象中的getName()和setName()方法可视为data.name属性的访问器(或存取器)。
严格来说,不应允许直接访问data.name属性,所有读写操作必须通过data.getName()和data.setName()方法。若属性禁止直接读写,仅能通过访问器操作,则可在重写访问器时执行额外逻辑,如属性值变更监控。这就是利用属性访问器实现数据双向绑定的原理。
该方法亦有弊端,最突出的是每添加一个属性监控,都必须为该属性添加对应的访问器方法,否则无法捕获属性变更。
Object.defineProperty方法详解
Object.defineProperty 方法
国产MVVM框架avalon.js实现数据双向绑定的原理正是属性访问器,但其实现并非如上述示例代码般原始。它使用了ECMAScript5.1(ECMA-262)中定义的标准属性Object.defineProperty方法。针对国内环境,部分不支持Object.defineProperty的低级浏览器采用VBScript进行了完美兼容,不像其他MVVM框架逐渐放弃对低端浏览器的支持。
先查看MDN上对Object.defineProperty方法的定义:
The Object.defineProperty() method defines a new property directly on an object, or modifies an existing property on an object, and returns the object.
含义明确,Object.defineProperty方法提供了一种直接定义或修改对象属性的方式。其方法原型如下:
Object.defineProperty(obj, prop, descriptor)
其中,
obj:待修改的对象 prop:待修改的属性名称 descriptor:待修改属性的相关描述 descriptor要求传入一个对象,其默认值如下:
/**
* @{param} descriptor
*/
{
configurable: false,
enumerable: false,
writable: false,
value: null,
set: undefined,
get: undefined
}
configurable:属性是否可配置。可配置含义包括:是否可以删除属性(delete),是否可以修改属性的writable、enumerable、configurable属性。
enumerable:属性是否可枚举。可枚举含义包括:是否可以通过for...in遍历到,是否可以通过Object.keys()方法获取属性名称。
writable:属性是否可重写。可重写含义包括:是否可以对属性进行重新赋值。
value:属性的默认值。
set:属性的重写器(暂且如此称呼)。一旦属性被重新赋值,此方法被自动调用。
get:属性的读取器(暂且如此称呼)。一旦属性被访问读取,此方法被自动调用。
下面来看一段示例代码:
var o = {};
Object.defineProperty(o, 'name', {
value: 'erik'
});
console.log(Object.getOwnPropertyDescriptor(o, 'name')); // Object {value: "erik", writable: false, enumerable: false, configurable: false}
Object.defineProperty(o, 'age', {
value: 26,
configurable: true,
writable: true
});
console.log(o.age); // 26
o.age = 18;
console.log(o.age); // 18. 因为age属性是可重写的
console.log(Object.keys(o)); // []. name和age属性都不是可枚举的
Object.defineProperty(o, 'sex', {
value: 'male',
writable: false
});
o.sex = 'female'; // 这里的赋值其实是不起作用的
console.log(o.sex); // 'male';
delete o.sex; // false, 属性删除的动作也是无效的
经过上述示例,正常情况下Object.definePropert()的使用较为简单。
但有一点需额外注意:Object.defineProperty()方法设置属性时,属性不能同时声明访问器属性(set和get)和writable或value属性。即,若某属性设置了writable或value属性,则不能声明get和set;反之亦然。
因为Object.defineProperty()在声明属性时,不允许同一属性出现两种以上存取访问控制。
示例代码:
var o = {},
myName = 'erik';
Object.defineProperty(o, 'name', {
value: myName,
set: function(name) {
myName = name;
},
get: function() {
return myName;
}
});
上述代码看似无问题,但真正执行时会报错,报错信息如下:
TypeError: Invalid property. A property cannot both have accessors and be writable or have a value, #<Object>
因为这里的name属性同时声明了value特性和set及get特性,这两者提供了两种对name属性的读写控制。若不声明value特性,而是声明writable特性,结果同样会报错。