双向数据绑定Proxy
和Object.defineProperty
对比
数据劫持的优势所在。
对比其他双向绑定的实现方法,数据劫持的优势所在:
- 无需显示调用: 例如 Vue 运用数据劫持+发布订阅,直接可以通知变化并驱动视图,上面的例子也是比较简单的实现
data.name = '渣渣辉'
后直接触发变更,而比如 Angular 的脏检测则需要显示调用markForCheck
(可以用 zone.js 避免显示调用,不展开),react 需要显示调用setState
。
- 可精确得知变化数据:还是上面的小例子,我们劫持了属性的 setter,当属性值改变,我们可以精确获知变化的内容
newVal
,因此在这部分不需要额外的 diff 操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候需要大量 diff 来找出变化值,这是额外性能损耗。
基于数据劫持双向绑定的实现思路
数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是 Vue。
基于数据劫持的双向绑定离不开Proxy
与Object.defineProperty
等方法对对象/对象属性的”劫持”,我们要实现一个完整的双向绑定需要以下几个要点。
- 利用
Proxy
或Object.defineProperty
生成的 Observer 针对对象/对象的属性进行”劫持”,在属性发生变化后通知订阅者
- 解析器 Compile 解析模板中的
Directive
(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染
- Watcher 属于 Observer 和 Compile 桥梁,它将接收到的 Observer 产生的数据变化,并根据 Compile 提供的指令进行视图渲染,使得数据变化促使视图变化
![img]()
- 在
new Vue()
后, Vue 会调用_init
函数进行初始化,也就是 init 过程,在 这个过程 Data 通过 Observer 转换成了 getter/setter 的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行getter
函数,而在当被赋值的时候会执行 setter
函数。
- 当 render function 执行的时候,因为会读取所需对象的值,所以会触发
getter
函数从而将 Watcher 添加到依赖中进行依赖收集。
- 在修改对象的值的时候,会触发对应的
setter
, setter
通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update
来更新视图。
基于 Object.defineProperty 双向绑定的特点
Vue 通过设定对象属性的 setter/getter 方法来监听数据的变化,通过 getter 进行依赖收集,而每个 setter 方法就是一个观察者,在数据变更的时候通知订阅者更新视图。
在 getter 中收集依赖,在 setter 中触发依赖。
当外界通过 Watcher 读取数据时,便会触发 getter 从而将 Watcher 添加到依赖中,哪个 Watcher 触发了 getter,就把哪个 Watcher 收集到 Dep 中。当数据发生变化时,会循环依赖列表,把所有的 Watcher 都通知一遍。
极简版的双向绑定
Object.defineProperty
的作用就是劫持一个对象的属性,通常我们对属性的getter
和setter
方法进行劫持,在对象的属性发生变化时进行特定的操作。
我们就对对象obj
的text
属性进行劫持,在获取此属性的值时打印'get val'
,在更改属性值的时候对 DOM 进行操作,这就是一个极简的双向绑定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const obj = {}; Object.defineProperty(obj, "text", { get: function() { console.log("get val"); }, set: function(newVal) { console.log("set val:" + newVal); document.getElementById("input").value = newVal; document.getElementById("span").innerHTML = newVal; } });
const input = document.getElementById("input"); input.addEventListener("keyup", function(e) { obj.text = e.target.value; });
|
升级改造
我们很快会发现,这个所谓的双向绑定貌似并没有什么乱用。。。
原因如下:
- 我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象每个属性进行监听。
- 违反开放封闭原则,我们如果了解开放封闭原则的话,上述代码是明显违反此原则,我们每次修改都需要进入方法内部,这是需要坚决杜绝的。
- 代码耦合严重,我们的数据、方法和 DOM 都是耦合在一起的,就是传说中的面条代码。
那么如何解决上述问题?
Vue 的操作就是加入了发布订阅模式,结合Object.defineProperty
的劫持能力,实现了可用性很高的双向绑定。
首先,我们以发布订阅的角度看我们第一部分写的那一坨代码,会发现它的监听、发布和订阅都是写在一起的,我们首先要做的就是解耦。
我们先实现一个订阅发布中心,即消息管理员(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
| let uid = 0;
class Dep { constructor() { this.id = uid++; this.subs = []; } depend() { Dep.target.addDep(this); } addSub(sub) { this.subs.push(sub); } notify() { this.subs.forEach(sub => sub.update()); } }
Dep.target = null;
|
现在我们需要实现监听者(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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| class Observer { constructor(value) { this.value = value; this.walk(value); } walk(value) { Object.keys(value).forEach(key => this.convert(key, value[key])); } convert(key, val) { defineReactive(this.value, key, val); } }
function defineReactive(obj, key, val) { const dep = new Dep(); let chlidOb = observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: () => { if (Dep.target) { dep.depend(); } return val; }, set: newVal => { if (val === newVal) return; val = newVal; chlidOb = observe(newVal); dep.notify(); } }); }
function observe(value) { if (!value || typeof value !== "object") { return; } return new Observer(value); }
|
那么接下来就简单了,我们需要实现一个订阅者(Watcher)。
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 35 36 37
| class Watcher { constructor(vm, expOrFn, cb) { this.depIds = {}; this.vm = vm; this.cb = cb; this.expOrFn = expOrFn; this.val = this.get(); } update() { this.run(); } addDep(dep) { if (!this.depIds.hasOwnProperty(dep.id)) { dep.addSub(this); this.depIds[dep.id] = dep; } } run() { const val = this.get(); console.log(val); if (val !== this.val) { this.val = val; this.cb.call(this.vm, val); } } get() { Dep.target = this; const val = this.vm._data[this.expOrFn]; Dep.target = null; return val; } }
|
那么我们最后完成 Vue,将上述方法挂载在 Vue 上。
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
| class Vue { constructor(options = {}) { this.$options = options; let data = (this._data = this.$options.data); Object.keys(data).forEach(key => this._proxy(key)); observe(data); } $watch(expOrFn, cb) { new Watcher(this, expOrFn, cb); } _proxy(key) { Object.defineProperty(this, key, { configurable: true, enumerable: true, get: () => this._data[key], set: val => { this._data[key] = val; }, }); } } ]
|
至此,一个简单的双向绑定算是被我们实现了。
Object.defineProperty 的缺陷
Object.defineProperty
的第一个缺陷,无法监听数组变化。Vue 的文档提到了 Vue 是可以检测到数组变化的,但是只有以下八种方法,vm.items[indexOfItem] = newValue
这种是无法检测的。push()
、pop()
、shift()
、unshift()
、splice()
、sort()
、reverse()
Object.defineProperty
的第二个缺陷,只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是更好的选择。
Object.keys(value).forEach(key => **this**.convert(key, value[key]));
无法检测到对象属性的添加或删除(如data.location.a=1
)。
这是因为 Vue 通过Object.defineProperty
来将对象的 key 转换成getter/setter
的形式来追踪变化,但getter/setter
只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。如果是删除属性,我们可以用vm.$delete
实现,那如果是新增属性,该怎么办呢?
1)可以使用 Vue.set(location, a, 1)
方法向嵌套对象添加响应式属性;
2)也可以给这个对象重新赋值,比如data.location = {...data.location,a:1}
Proxy 实现的双向绑定的特点
Proxy 在 ES2015 规范中被正式发布,它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为,Proxy 是Object.defineProperty
的全方位加强版
Proxy 可以直接监听对象而非属性
我们还是以上文中用Object.defineProperty
实现的极简版双向绑定为例,用 Proxy 进行改写。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const input = document.getElementById("input"); const p = document.getElementById("p"); const obj = {};
const newObj = new Proxy(obj, { get: function(target, key, receiver) { console.log(`getting ${key}!`); return Reflect.get(target, key, receiver); }, set: function(target, key, value, receiver) { console.log(target, key, value, receiver); if (key === "text") { input.value = value; p.innerHTML = value; } return Reflect.set(target, key, value, receiver); } });
input.addEventListener("keyup", function(e) { newObj.text = e.target.value; });
|
我们可以看到,Proxy 直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty
。
Proxy 可以直接监听数组的变化
当我们对数组进行操作(push、shift、splice 等)时,会触发对应的方法名称和length的变化,我们可以借此进行操作,以上文中Object.defineProperty
无法生效的列表渲染为例。
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| const list = document.getElementById("list"); const btn = document.getElementById("btn");
const Render = { init: function(arr) { const fragment = document.createDocumentFragment(); for (let i = 0; i < arr.length; i++) { const li = document.createElement("li"); li.textContent = arr[i]; fragment.appendChild(li); } list.appendChild(fragment); }, change: function(val) { const li = document.createElement("li"); li.textContent = val; list.appendChild(li); } };
const arr = [1, 2, 3, 4];
const newArr = new Proxy(arr, { get: function(target, key, receiver) { console.log(key); return Reflect.get(target, key, receiver); }, set: function(target, key, value, receiver) { console.log(target, key, value, receiver); if (key !== "length") { Render.change(value); } return Reflect.set(target, key, value, receiver); } });
window.onload = function() { Render.init(arr); };
btn.addEventListener("click", function() { newArr.push(6); });
|
很显然,Proxy 不需要那么多 hack(即使 hack 也无法完美实现监听)就可以无压力监听数组的变化,我们都知道,标准永远优先于 hack。
Proxy 的其他优势
Proxy 有多达 13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是Object.defineProperty
不具备的。
Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty
只能遍历对象属性直接修改。
Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy 的劣势就是兼容性问题,而且无法用 polyfill 磨平,因此 Vue 的作者才声明需要等到下个大版本(3.0)才能用 Proxy 重写。
基础 proxy 的双向数据绑定的实现
发布订阅中心(Dep)
Dep
保存订阅者,并在 Observer 发生变化时通知保存在 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 31
|
export default class Dep { constructor() { this.subs = new Map(); } addSub(key, sub) { const currentSub = this.subs.get(key); if (currentSub) { currentSub.add(sub); } else { this.subs.set(key, new Set([sub])); } } notify(key) { if (this.subs.get(key)) { this.subs.get(key).forEach(sub => { sub.update(); }); } } }
|
监听者的实现(Observer)
我们在订阅器 Dep
中实现了一个notify
方法来通知相应的订阅这们,然而notify
方法到底什么时候被触发呢?
当然是当状态发生变化时,即 MVVM 中的 Modal 变化时触发通知,然而Dep
显然无法得知 Modal 是否发生了变化,因此我们需要创建一个监听者Observer
来监听 Modal, 当 Modal 发生变化的时候我们就执行通知操作。
与Object.defineProperty
监听属性不同, Proxy 可以监听(实际是代理)整个对象,因此就不需要遍历对象的属性依次监听了,但是如果对象的属性依然是个对象,那么 Proxy 也无法监听,所以我们实现了一个observify
进行递归监听即可。
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 35 36 37 38 39 40 41 42 43
|
const Observer = obj => { const dep = new Dep(); return new Proxy(obj, { get: function(target, key, receiver) { if (Dep.target) { dep.addSub(key, Dep.target); } return Reflect.get(target, key, receiver); }, set: function(target, key, value, receiver) { if (Reflect.get(receiver, key) === value) { return; } const res = Reflect.set(target, key, observify(value), receiver); dep.notify(key); return res; } }); };
export default function observify(obj) { if (!isObject(obj)) { return obj; }
Object.keys(obj).forEach(key => { obj[key] = observify(obj[key]); });
return Observer(obj); }
|
订阅者的实现(watcher)
我们目前已经解决了两个问题,一个是如何得知 Modal 发生了改变(利用监听者 Observer 监听 Modal 对象),一个是如何收集订阅者并通知其变化(利用订阅器收集订阅者,并用 notify 通知订阅者)。
我们目前还差一个订阅者(Watcher)
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
| export default class Watcher { constructor(vm, exp, cb) { this.vm = vm; this.exp = exp; this.cb = cb; this.value = this.get(); } get() { const exp = this.exp; let value; Dep.target = this; if (typeof exp === "function") { value = exp.call(this.vm); } else if (typeof exp === "string") { value = this.vm[exp]; } Dep.target = null; return value; } update() { pushQueue(this); } run() { const val = this.get(); this.cb.call(this.vm, val, this.value); this.value = val; } }
|
批量更新的实现
我们在上一节中实现了订阅者( Watcher),但是其中的update
方法是将订阅者放入了一个待更新的队列中,而不是直接触发,原因如下:
![img]()
因此这个队列需要做的是异步且去重,因此我们用 Set
作为数据结构储存 Watcher 来去重,同时用Promise
模拟异步更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| let queue = new Set();
function nextTick(cb) { Promise.resolve().then(cb); }
function flushQueue(args) { queue.forEach(watcher => { watcher.run(); }); queue = new Set(); }
export default function pushQueue(watcher) { queue.add(watcher); nextTick(flushQueue); }
|