只要涉及到 MVVM 框架就不得不提到双向绑定,毕竟它是 Vue 的三要素之一。
- 响应式:例如如何监听数据变化,其中的实现方法就是双向绑定
- 模板引擎:如何解析模板
- 渲染:Vue 如何将监听到的数据变化和解析后的 HTML 进行渲染
目前业界分为两个大的流派,一个是以 React 为首的单向数据绑定,另一个是以 Vue、Angular 为主的双向数据绑定。其实三大框架都既可以双向绑定也可以单向绑定,比如 React 可以手动绑定 onChange
和 value 实现双向绑定,也可以调用一些双向绑定库。Vue 也加入了 props 这种单向流的 API,不过都并非其主要卖点。单向或双向的优劣也不在此文讨论范围内。
可以实现双向绑定的方法有很多:KnockoutJS
基于观察者模式的双向绑定,Ember 基于数据模型的双向绑定,Angular 基于脏检查的双向绑定。本篇将着重讲述 Vue 基于数据劫持的双向绑定。
常见的基于数据劫持的双向绑定有两种实现,一个是目前 Vue 在用的 Object.defineProperty
,另一个是 ES2015 中新增的 Proxy,而 Vue 将在 3.0 版本后用 Proxy 代替 Object.defineProperty
。严格来讲 Proxy 应该被称为『代理』而非『劫持』,不过由于作用有很多相似之处,在下文中就不再做区分,统一叫『劫持』。
下图说明了双向绑定的基本体系。

(一)数据劫持
利用 Object.defineProperty
劫持对象的访问器,在属性值发生变化时可以获取变化,然后根据变化进行后续响应。在 vue3.0 中通过 Proxy 代理对象进行类似的操作。
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
| const data = { name: '', }
function say(name) { if(name === '古天乐') { console.log('给大家推荐一款超好玩的游戏'); } else if (name === '渣渣辉') { console.log('戏我演过很多,可游戏我只玩贪玩蓝月'); } else { console.log('来做我的兄弟'); } }
Object.keys(data).forEach(function(key) { Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function() { console.log('get'); }, set: function(newVal) { console.log(`大家好,我系${newVal}`); say(newVal); } }) })
data.name = '渣渣辉';
|
对比其他双向绑定的实现方法,数据劫持的优势在于:
- 无需显式调用:例如 Vue 运用数据劫持 + 发布订阅,可以直接通知变化并驱动视图,上面的例子也是比较简单的实现
data.name = '渣渣辉'
后直接触发变更,而比如 Angular 的脏检测则需要显式调用 markForCheck
(可以用 zone.js 避免显式调用,不展开),react 需要显式调用 setState
。 - 可精确得知变化数据:我们劫持了属性的 setter,当属性值改变,可以精确获知变化的内容
newVal
,因此在这部分不需要额外的 diff 操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这时需要大量 diff 来找出变化值,这是额外性能损耗。
(二)订阅发布
Observer「响应式」
Vue 中采用 Observer 类来管理上述响应式化 Object.defineProperty
的过程。示例代码如下,其中 this.data
为 Vue 中定义的需要进行响应式绑定的 data 属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Observer { constructor() { observe(this.data); } }
export function observe (data) { const keys = Object.keys(data); for (let i = 0, len = keys.length; i < len; i++) { Object.defineProperty(obj, keys[i], { }); } }
|
Dep「依赖管理」
通过 Observer 类将 data 中的数据进行响应式绑定后,虽然可以监听到数据的变化,那要怎么通知视图更新数据呢?Dep 就是专门用于收集【究竟要通知到哪里的】。
如下所示,虽然 data 中有 text 和 message 属性,但只有 message 会被渲染到页面上,因此这里仅对 message 进行收集。此时 message 的 Dep 就收集到了一个依赖,这个依赖就是用来管理 data 中 message 变化的。
1 2 3
| <div> <p> {{message}} </p> </div>
|
1 2 3 4
| data: { text: 'hello world', message: 'hello vue', }
|
当使用 watch 属性监听 message 时,需要将 message 数据的变化通知给 watch 这个钩子,让它去执行回调函数。这时 message 的 Dep 就收集到了两个依赖,第二个依赖就是用来管理 watch 中 message 变化的。
1 2 3 4 5
| watch: { message: function (val, oldVal) { console.log('new: %s, old: %s', val, oldVal) }, }
|
当开发者定义依赖 message 变化的计算属性时,当 message 发生变化,我们也要通知到 computed,让它去执行回调函数。这时 message 的 Dep 就收集到了三个依赖,这个依赖是用来管理 computed 中 message 变化的。
1 2 3 4 5
| computed: { messageT() { return this.message + '!'; } }
|

我们通过Object.defineProperty
的 getter/setter 知道 data 中的某个属性被使用了,因此可以在其中进行改造以收集依赖。
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
| class Observer { constructor() { observe(this.data); } }
export function observe (data) { const keys = Object.keys(data); for (let i = 0; i < keys.length; i++) { Object.defineProperty(obj, keys[i], { enumerable: true, configurable: true, get: () => { console.log('我被读了,我要不要做点什么好?'); Dep.depend(); return val; }, set: newVal => { if (val === newVal) { return; } val = newVal; Dep.notify(); console.log("数据被改变了,我要把新的值渲染到页面上去!"); } }); } }
|
那所谓的依赖究竟是什么呢?上图中已经暴露了答案,就是 Watcher。
Watcher「订阅者」
当 message 变化,就会通知 Watchers,它们会执行各自需要做的变化。Watcher 能知晓自己负责 data、watch 还是 computed 中的属性,它有统一的更新入口,只要你通知它,就会执行对应的更新方法。因此可以推测出 Watcher 必须要有的 2 个方法:一个是收到通知进行更新,另一个是被收集到 Dep 中。
1 2 3 4 5 6 7 8
| class Watcher { addDep() { }, update() { }, }
|
回顾一下,Vue 响应式原理的核心就是 Observer、Dep、Watcher。Observer 中进行响应式的绑定,在数据被读时触发 get 方法,执行 Dep 来收集依赖,或者说收集 Watcher。在数据被改时触发 set 方法,通过对应依赖 (Watcher) 去执行更新。比如 watch 和 computed 就执行开发者自定义的回调方法。
(三)双向绑定实现
数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是 Vue。基于数据劫持的双向绑定依靠 Proxy
或 Object.defineProperty
方法劫持对象或其属性,要实现一个完整的双向绑定需要注意以下几个要点。
- 利用
Proxy
或 Object.defineProperty
生成的 Observer 针对对象或对象的属性进行劫持,在属性发生变化后通知订阅者; - 解析器 Compile 解析模板中的
Directive
(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染; - Watcher 属于 Observer 和 Compile 桥梁,它将接收到的 Observer 产生的数据变化,并根据 Compile 提供的指令进行视图渲染,使得数据变化促使视图变化

极简版
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,即消息或依赖管理员,来负责储存订阅者 Watcher 和消息的分发。不论是订阅者还是发布者都依赖于 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 childOb = 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; childOb = 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
| 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(); 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 上。
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
| 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
缺陷
改进版的双向绑定依然存在漏洞,比如将属性值改为数组。
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
| let demo = new Vue({ data: { list: [1], }, });
const list = document.getElementById('list'); const btn = document.getElementById('btn');
btn.addEventListener('click', function() { demo.list.push(1); });
const render = 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); };
demo.$watch('list', list => render(list));
setTimeout( function() { alert(demo.list); }, 5000, );
|
Object.defineProperty
的第一个缺陷,无法监听数组变化。然而 Vue 可以检测到数组变化,但只有以下七种方法,像 vm.items[indexOfItem] = newValue
这种是无法检测的。
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
其实作者在这里用了一些奇技淫巧,把无法监听数组的情况 hack 掉了。以下是方法示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; const arrayAugmentations = []; aryMethods.forEach((method)=> { let original = Array.prototype[method]; arrayAugmentations[method] = function () { console.log('我被改变啦!'); return original.apply(this, arguments); }; }); let list = ['a', 'b', 'c'];
list.__proto__ = arrayAugmentations; list.push('d');
let list2 = ['a', 'b', 'c']; list2.push('d');
|
由于只针对了八种方法进行了 hack,所以其他数组的属性也是检测不到的,其中的坑很多。我们应该注意到在上文中的实现里,多次用遍历方法遍历对象的属性,这就引出了 Object.defineProperty
的第二个缺陷,只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是更好的选择。
1
| Object.keys(value).forEach(key => this.convert(key, value[key]));
|
Proxy 优点
Proxy 是 Object.defineProperty
的全方位加强版,在 ES2015 规范中被正式发布,它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
① Proxy 可以直接监听对象而非属性
以上文中用 Object.defineProperty
实现的极简版双向绑定为例,用 Proxy 进行改写。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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
| 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 就能轻松监听数组的变化。
③ Proxy 的其他优势
Proxy 有多达 13 种拦截方法,比如 apply
、ownKeys
、deleteProperty
、has
等,这些 Object.defineProperty
并不具备;
Proxy 返回的是一个新对象,因此可以只操作新对象达到目的,而 Object.defineProperty
只能遍历对象属性直接修改;
Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
当然,Proxy 的劣势就是兼容性问题,且无法用 polyfill
磨平,因此 Vue 作者才声明需要等到下个大版本 (3.0) 才能用 Proxy 重写。