「Vue 的双向绑定是怎么实现的」这个问题,多数人的答案停在「用 Object.defineProperty 劫持了 getter 和 setter」。这句话没错,但它只回答了一半。数据变了之后,Vue 怎么知道该更新页面上哪个节点?模板里的指令是什么时候被解析成更新函数的?谁负责把这两头接起来?这篇不去逐行抠代码,而是把整套架构拆成四个模块讲清楚各自的职责和边界,顺带把 Vue 之外的另外两种流派(Backbone 的发布订阅、Angular 的脏值检查)拿来做对照,看看数据劫持到底赢在哪。
想看逐行手写的完整实现,可以配合 Vue 响应式原理模拟 手写一个迷你版 Vue 那篇一起看;只关心 Object.defineProperty 这个 API 本身的描述符细节,看 Object.defineProperty 详解。这三篇的分工是:那篇讲 API,另一篇讲代码怎么敲,这篇讲架构为什么这么分。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 发布订阅、脏值检查、数据劫持三种流派各自的做法和代价
- 为什么数据劫持能做到
vm.property = value这种自然的写法 Observer怎么把普通对象变成能广播变化的对象Dep这个消息订阅器为什么必须建在闭包里Compile为什么要先把 DOM 搬进DocumentFragment再解析Watcher作为桥梁,是靠什么完成依赖收集的MVVM入口的属性代理在解决什么问题- 这套实现和真实 Vue 之间还差什么,Vue 3 换成 Proxy 补上了哪些短板
先看要做出来的效果:
<div id="mvvm-app">
<input type="text" v-model="word">
<p>{{word}}</p>
<button v-on:click="sayHi">change model</button>
</div>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script src="compile.js"></script>
<script src="mvvm.js"></script>
<script>
var vm = new MVVM({
el: '#mvvm-app',
data: {
word: 'Hello World!'
},
methods: {
sayHi: function() {
this.word = 'Hi, everybody!';
}
}
});
</script>
输入框里敲字,下面的段落跟着变;点按钮改数据,输入框和段落一起变:

# 一、几种实现双向绑定的做法
目前几种主流的 MVC(VM) 框架都实现了单向数据绑定,而所谓双向数据绑定,无非就是在单向绑定的基础上给可输入元素(input、textarea 等)添加了 change(input)事件,来动态修改 model 和 view,并没有多高深。所以无需太过介怀实现的是单向还是双向绑定。
三种主流做法是这样的:
- 发布者订阅者模式(
backbone.js) - 脏值检查(
angular.js) - 数据劫持(
vue.js)
# 1.1 发布者订阅者模式
一般通过 sub、pub 的方式实现数据和视图的绑定监听,更新数据的做法通常是 vm.set('property', value),这里有篇文章讲得比较详细。
这种方式的问题在于用起来不自然。改一个数据得调方法,取一个数据得调 get,模型对象和普通 JavaScript 对象长得不一样,写着写着就忘了哪个是模型哪个是普通对象。我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式。
要说清楚的是,发布订阅这个模式本身并没有被淘汰。Vue 内部的 Dep 和 Watcher 用的就是这套思路,只是它不再暴露给使用者,而是藏进了 getter/setter 里面。被淘汰的是「让用户手动调 set」这种交互方式,不是模式本身。
# 1.2 脏值检查
angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图。最简单的方式是通过 setInterval() 定时轮询检测数据变动,当然 Google 不会这么做,Angular 只在指定的事件触发时进入脏值检测,大致有这几类:
- DOM 事件,譬如用户输入文本、点击按钮等(
ng-click) - XHR 响应事件(
$http) - 浏览器 Location 变更事件(
$location) - Timer 事件(
$timeout、$interval) - 执行
$digest()或$apply()
脏值检查的思路和数据劫持是反着来的。数据劫持是「数据变的那一刻我就知道」,脏值检查是「我不知道什么时候变,但在几个关键时机把所有值都跟上一次的快照比一遍」。
代价在于比对本身有成本。每次 $digest 要遍历所有 watcher,而且一轮比对里如果有 watcher 修改了别的数据,还得再来一轮,直到没有变化为止(Angular 里最多循环十次,超了就报错)。绑定项一多,一次交互跑几千次比对是常事。
好处也很明显:它不关心数据是怎么变的。用 arr[0] = x 改数组、用 delete obj.a 删属性、直接整个对象换掉,脏值检查一视同仁。而这几种情况恰恰是 Object.defineProperty 方案的软肋。
# 1.3 数据劫持
vue.js 采用的是数据劫持结合发布者订阅者模式,通过 Object.defineProperty() 来劫持各个属性的 setter、getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
它的好处是精确。哪个属性变了就通知订阅了那个属性的人,不需要全量比对。代价是得在初始化时把整个 data 对象递归遍历一遍,把每个属性都改造成访问器属性,对象层级深、字段多的时候,这个初始化开销是实打实的。
先说结论,这三种做法解决的是同一个问题的不同侧面:怎么在数据变化和视图更新之间建立联系。发布订阅让用户手动报告,脏值检查靠定时比对,数据劫持靠改写属性描述符。Vue 选了第三种,代价换来的是最自然的书写体验。
# 二、实现思路
已经了解到 Vue 是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过 Object.defineProperty() 来实现对属性的劫持,达到监听数据变动的目的。不熟悉 defineProperty 的话,MDN 上这一页 要先过一遍。
要实现 MVVM 的双向绑定,必须要实现以下几点:
- 实现一个数据监听器
Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者 - 实现一个指令解析器
Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数 - 实现一个
Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图 mvvm入口函数,整合以上三者
上述流程如图所示:

这个四模块的划分值得多想一层。为什么不把监听和更新写在一起?因为它们变化的原因不一样。数据的形态千变万化(对象、数组、嵌套),指令的种类也在不断增加(v-text、v-html、v-model、v-on),如果耦在一起,加一个指令要改数据层的代码,加一种数据类型要改指令层的代码。拆成两半,中间用 Watcher 这个只做转发的桥梁连起来,两边就能各自演进。
# 2.1 实现 Observer
我们知道可以利用 Object.defineProperty() 来监听属性变动。那么将需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter 和 getter。这样的话,给这个对象的某个值赋值,就会触发 setter,也就能监听到数据变化了:
var data = {name: 'kindeng'};
observe(data);
data.name = 'dmq'; // 监听到值变化了 kindeng --> dmq
function observe(data) {
if (!data || typeof data !== 'object') {
return;
}
// 取出所有属性遍历
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key]);
});
};
function defineReactive(data, key, val) {
observe(val); // 监听子属性
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再 define
get: function() {
return val;
},
set: function(newVal) {
console.log('监听到值变化了 ', val, ' --> ', newVal);
val = newVal;
}
});
}
完整代码在 https://github.com/poetries/mvvm/blob/master/observer.js
这段代码里 val 这个参数是关键。它是 defineReactive 的形参,被 get 和 set 两个闭包共同引用,成了这个属性真正的值仓库。原来的属性已经被访问器属性覆盖掉了,如果 getter 里写 return data[key],那就是再次触发自己的 getter,死循环爆栈。这是新手手写响应式时最常见的一个错误。
configurable: false 表示定义完之后不能再改这个属性的描述符,也不能删除它。真实的 Vue 源码里没有这么写,因为要留出后续处理的余地。自己练手的话知道有这回事就行。
有了监听能力,接下来是怎么通知订阅者。很简单,维护一个数组用来收集订阅者,数据变动时触发 notify,再调用订阅者的 update 方法:
// ... 省略
function defineReactive(data, key, val) {
var dep = new Dep();
observe(val); // 监听子属性
Object.defineProperty(data, key, {
// ... 省略
set: function(newVal) {
if (val === newVal) return;
val = newVal;
dep.notify(); // 通知所有订阅者
}
});
}
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
};
if (val === newVal) return; 这一行别漏。赋一个和原来一样的值时直接返回,不触发通知,能省掉大量无意义的重渲染。
那么问题来了,谁是订阅者,怎么往订阅器里添加订阅者?
按前面的思路整理,订阅者应该是 Watcher。而 var dep = new Dep(); 是在 defineReactive 方法内部定义的,想通过 dep 添加订阅者,就必须在闭包内操作。所以可以在 getter 里面动手脚: