双向绑定是现代前端框架的基石,无论是Vue中的v-model指令,还是Angular中的[(ngModel)]语法,背后都依赖一套完整的数据响应机制。简单来说,双向绑定就是:数据变了,视图跟着变;视图变了,数据也跟着变。很多开发者用了多年框架,却始终不清楚这套机制是怎么运转的。本文就用原生JavaScript手写一个简化版的MVVM,把双向绑定的底层原理彻底讲透。

双向绑定的核心思路:数据劫持加发布订阅
要实现双向绑定,需要解决两个方向的问题。第一个方向是数据驱动视图:当JavaScript对象中的某个属性发生变化时,能够自动更新页面上对应的DOM节点。第二个方向是视图驱动数据:当用户在输入框中修改内容时,能够把最新的值同步回数据对象。
第一个方向的实现依赖两个关键技术。第一是数据劫持,也就是拦截对象属性的读取和修改操作。在ES5时代,我们使用Object.defineProperty把对象的普通属性改造成访问器属性,一旦有人给属性赋值,就会触发我们预先定义的setter函数。第二是发布订阅模式,它充当数据和视图之间的通信桥梁。每个被劫持的属性都维护一个订阅者列表,当属性变化时,遍历这个列表,通知所有订阅者去更新视图。
第二个方向反而简单得多,只需要监听输入框的input事件,在事件回调里把用户输入的值写回数据对象即可。写回的动作又会触发setter,进而更新其他绑定了同一数据的DOM节点,整个闭环就这样形成了。
手写核心代码:从Observer到Watcher
先看数据劫持部分。Observer的任务是遍历数据对象的所有属性,用Object.defineProperty逐一改写。注意这里要用一个局部变量保存原值,如果直接在getter里返回this.xxx,会造成无限递归调用。完整代码如下:
class MVVM {
constructor(options) {
this.$data = options.data;
// 第一步:劫持数据
new Observer(this.$data);
// 第二步:编译模板
new Compile(options.el, this);
}
}
class Observer {
constructor(data) {
this.observe(data);
}
observe(data) {
if (data && typeof data === 'object') {
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
});
}
}
defineReactive(obj, key, value) {
// 每个属性对应一个依赖收集器
let dep = new Dep();
// 递归处理嵌套对象
this.observe(value);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖:把当前正在计算的Watcher添加进列表
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newValue) {
if (newValue === value) return;
value = newValue;
// 新值如果是对象也要劫持
this.observe(newValue);
// 通知所有订阅者更新
dep.notify();
}
});
}
}
Dep是发布订阅的中心,代码很简洁。Watcher则是订阅者,每个Watcher实例对应视图中一个绑定点。它的关键设计是在构造函数中主动把自己挂载到Dep.target上,然后触发一次getter完成依赖收集:
class Dep {
constructor() {
this.subs = [];
}
addSub(watcher) {
this.subs.push(watcher);
}
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
class Watcher {
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 求值时触发getter,完成依赖收集
this.value = this.get();
}
get() {
Dep.target = this;
let value = this.getVal(this.vm, this.expr);
Dep.target = null;
return value;
}
getVal(vm, expr) {
// 支持取值路径,如 user.name
return expr.split('.').reduce((data, key) => data[key], vm.$data);
}
update() {
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if (newValue !== oldValue) {
this.value = newValue;
this.cb(newValue);
}
}
}
最后是模板编译部分。Compile负责扫描DOM节点,找到带v-model属性的元素和双花括号插值,分别为它们创建Watcher。对于v-model元素,还要额外绑定input事件,实现视图到数据的回流:
class Compile {
constructor(el, vm) {
this.el = document.querySelector(el);
this.vm = vm;
this.fragment = this.nodeToFragment(this.el);
this.compile(this.fragment);
this.el.appendChild(this.fragment);
}
nodeToFragment(el) {
// 移到内存碎片中操作,减少重排
let fragment = document.createDocumentFragment();
let child;
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment;
}
compile(node) {
Array.from(node.childNodes).forEach(child => {
if (child.nodeType === 3) {
// 文本节点:处理插值表达式
this.compileText(child);
} else if (child.nodeType === 1) {
this.compileElement(child);
}
if (child.childNodes && child.childNodes.length) {
this.compile(child);
}
});
}
compileText(node) {
let reg = /\{\{(.+?)\}\}/;
if (reg.test(node.textContent)) {
let expr = RegExp.$1.trim();
new Watcher(this.vm, expr, newValue => {
node.textContent = newValue;
});
node.textContent = this.getVal(expr);
}
}
compileElement(node) {
Array.from(node.attributes).forEach(attr => {
if (attr.name === 'v-model') {
let expr = attr.value;
// 数据驱动视图
new Watcher(this.vm, expr, newValue => {
node.value = newValue;
});
node.value = this.getVal(expr);
// 视图驱动数据
node.addEventListener('input', e => {
expr.split('.').reduce((data, key, index, arr) => {
if (index === arr.length - 1) {
data[key] = e.target.value;
} else {
return data[key];
}
}, this.vm.$data);
});
}
});
}
getVal(expr) {
return expr.split('.').reduce((data, key) => data[key], this.vm.$data);
}
}
在页面上定义一个输入框和一段文本,都用同一个数据字段绑定,就可以看到效果:修改输入框内容,文本和$data中的值同步变化;在控制台直接给数据赋值,视图也会立刻刷新。这正是Vue双向绑定的最小可运行模型。
三种主流方案对比:为什么Vue3放弃了defineProperty
除了数据劫持,业界还有其他实现双向绑定的思路,各有取舍。AngularJS早期采用的脏检查方案,会在特定时机(事件触发、定时器等)遍历所有被监听的数据,逐一对比新旧值。它的优点是不需要侵入数据对象,缺点是数据量大时性能开销明显,一次摘要循环可能执行多次比对。
Object.defineProperty方案是Vue2的选择,优点是兼容性好,能支持IE9及以上浏览器。但它有三个硬伤:一是无法监听属性的新增和删除,所以Vue2需要$set和$delete这类补充API;二是无法直接监听数组下标的变化,Vue2只能重写七个数组变异方法来曲线救国;三是需要在初始化时递归遍历整个数据对象,对深层嵌套的大对象初始化成本较高。
Vue3改用ES6的Proxy,从根本上解决了这些问题。Proxy监听的是整个对象而不是单个属性,新增删除属性、数组下标赋值都能捕获,而且是惰性劫持,只有真正访问到嵌套对象时才会递归代理。来看一个简单的Proxy示例:
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver);
// 依赖收集逻辑
track(target, key);
// 嵌套对象递归处理(惰性)
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
// 派发更新逻辑
trigger(target, key);
}
return result;
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key);
trigger(target, key);
return result;
}
});
}
三种方案的对比可以归纳为下表:
| 方案 | 代表框架 | 优点 | 缺点 |
|---|---|---|---|
| 脏检查 | AngularJS 1.x | 不侵入数据,兼容性极好 | 大量数据时性能差,时机不直观 |
| defineProperty | Vue 2 | 兼容IE9,实现简单 | 无法监听增删属性和数组下标 |
| Proxy | Vue 3 | 全量拦截,惰性代理,性能更好 | 不支持IE,无法被polyfill |
理解了这些原理之后,再回头看框架文档中的响应式陷阱说明,比如Vue2中直接给数组某一项赋值不生效、Vue3中解构props会失去响应性等问题,就能明白它们不是框架的Bug,而是实现方案本身的特性边界。掌握了手写双向绑定的能力,面对框架中的异常行为时,也就有了独立分析和排查的底气。