Cruyun's Blog


Talk is cheap, show you my code


MVVM双向数据绑定(二):VueJS数据劫持

数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是Vue。
基于数据劫持的双向绑定离不开Proxy与Object.defineProperty等方法对对象/对象属性的”劫持”,我们要实现一个完整的双向绑定需要以下几个要点。

  1. 利用 Proxy 或 Object.defineProperty 生成的 Observer 针对对象/对象的属性进行”劫持”,在属性发生变化后通知订阅者
  2. 解析器 Compile 解析模板中的 Directive (指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染。
  3. Watcher 属于 Observer 和 Compile 桥梁,它将接收到的 Observer 产生的数据变化,并根据 Compile 提供的指令进行视图渲染,使得数据变化促使视图变化。

本文主要总结了VueJS利用Object.defineProperty()Proxy,结合发布者-订阅者模式实现双向数据绑定的基本原理。

Object.defineProperty()

Vue 内部使用了 Object.defineProperty()来实现双向绑定,当你把一个普通的 JavaScript 对象传给 Vue 实例的 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter,监听到 set 和 get 的事件。

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

Object.defineProperty(obj, prop, descriptor)

该函数接受三个参数:

  • obj:要在其上定义属性的对象。
  • prop:要定义或修改的属性的名称。
  • descriptor:将被定义或修改的属性描述符

详细的文档可以参阅MDN

Observer对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Observer {
constructor(data) {
this._data = data;
this.walk(this._data);
}
walk(data) {
Object.keys(data).forEach((key) => {
this.defineRective(data, key, data[key])
})
};
defineRective(vm, key, value) {
// 将这个属性的依赖表达式存储在闭包中。
var self = this;
if (value && typeof value === "object") {
this.walk(value);
}
Object.defineProperty(vm, key, {
get: function() {
return value;
},
set: function(newVal) {
if (value != newVal) {
if (newVal && typeof newVal === "object") {
self.walk(newVal);
}
value = newVal;
// 通知所有的 viewModel 更新
dep.notify();
}
}
})
}
}
module.exports = Observer;

为每个属性添加了 getter 和 setter ,当属性是一个对象,那么就递归添加。

一旦获取属性值或者为属性赋值就会触发 get 或 set ,当触发了 set ,即 model 变化,就可以发布消息,dep.notify();通知所有 viewModel 更新。

Dep对象就是一个闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Dep {
constructor() {
// 依赖列表
this.dependences = [];
}
// 添加依赖
addDep(watcher) {
if (watcher) {
this.dependences.push(watcher);
}
}
depend() {
// Dep.target是一个实例化的全局 watcher 对象
if (Dep.target) {
// 传入闭包中的 dep 对象
Dep.target.addDep(this)
}
}
// 通知所有依赖更新
notify() {
this.dependences.forEach((watcher) => {
watcher.update();
})
}
}
Dep.target = null
function update(value) {
document.querySelector('div').innerText = value
}
module.exports = Dep;

这里的每个依赖就是一个 Watcher 。每一个 Watcher 都会有一个唯一的 id 号,它拥有一个表达式和一个回调函数 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var uid = 0;
class Watcher {
constructor(viewModel, exp, callback) {
// viewModel 就是 obj
this.viewModel = viewModel;
this.id = uid++;
this.exp = exp;
this.callback = callback;
this.oldValue = "";
this.update();
}
get() {
// 将 Dep.target 指向自己
Dep.target = this;
var res = this.compute(this.viewModel, this.exp);
Dep.target = null;
return res;
}
update() {
var newValue = this.get();
if (this.oldValue === newValue) {
return;
}
// callback 里进行Dom 的更新操作
this.callback(newValue, this.oldValue);
this.oldValue = newValue;
}
compute(viewModel, exp) {
var res = replaceWith(viewModel, exp);
return res;
}
}
module.exports = Watcher;

由于当前表达式需要在 当前的 model 下面执行,所以 采用 replaceWith 函数来代替 with。

通过get添加依赖, 修改Object.defineProperty为以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Object.defineProperty(vm, key, {
get: function() {
var watcher = Dep.target;
if (watcher && !dep.dependences[watcher.id]) {
dep.addDep(watcher);
}
return value;
},
set: function(newVal) {
if (value != newVal) {
if (newVal && typeof newVal === "object") {
self.walk(newVal);
}
value = newVal;
// 执行 watcher 的 update 方法
dep.notify();
}
}
})

以上实现了一个简易的双向绑定,核心思路就是手动触发一次属性的 getter 来实现发布订阅的添加。

Proxy

Object.defineProperty存在两个缺陷:

  1. 无法监听数组变化。

然而Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下八种方法,vm.items[indexOfItem] = newValue这种是无法检测的。

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

其实作者在这里用了一些奇技淫巧,把无法监听数组的情况hack掉了。由于只针对了八种方法进行了hack,所以其他数组的属性也是检测不到的。

  1. 只能对对象的属性进行数据劫持,所以需要深度遍历整个对象。

所以利用 Proxy,就可以很好地避免上述两个缺陷。

1
2
3
4
5
6
7
8
9
10
11
12
13
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};

可以看到,Proxy直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty。

Proxy的其他优势

Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。

Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy的劣势就是兼容性问题,而且无法用polyfill磨平,因此Vue的作者才声明需要等到下个大版本(3.0)才能用Proxy重写


参考及相关阅读: