数据绑定这个词几乎天天挂在嘴边,但真正动手实现过的人不多。所谓绑定,本质上就是建立数据和视图之间的自动同步机制:数据变了,视图跟着变;视图上用户输入了,数据也跟着变。听起来像魔法,其实拆开之后就是两个独立的问题——怎么知道数据变了(侦测变化),以及数据变了之后告诉谁(依赖收集与派发更新)。本文围绕这两条主线,用原生JavaScript把双向绑定的核心逻辑完整实现一遍。

一、数据侦测:Object.defineProperty的getter与setter
要感知一个对象属性的变化,最经典的思路是用Object.defineProperty把属性改造成访问器属性。改造之后,读取属性会触发getter,写入属性会触发setter,我们就在setter里埋点,任何赋值动作都逃不过去。
先看一个最小化的示例,只对一个属性做劫持:
// 定义一个普通的观察函数
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('属性被读取了:' + key);
return val;
},
set(newVal) {
if (newVal === val) return;
console.log('属性被修改了:' + key + ',旧值:' + val + ',新值:' + newVal);
val = newVal;
}
});
}
var data = { message: 'hello' };
defineReactive(data, 'message', data.message);
data.message; // 触发getter
data.message = 'hi'; // 触发setter这段代码就是Vue 2响应式系统的雏形。getter里除了返回值,更重要的职责是“收集依赖”——把当前正在使用这个属性的观察者记下来;setter里除了更新值,还要“派发更新”——通知所有记下来的观察者去执行各自的更新逻辑。
不过Object.defineProperty有几个绕不开的缺陷:一是无法监测对象属性的新增和删除,所以Vue 2提供了$set和$delete来补位;二是数组的变化通过索引赋值和修改length它也感知不到,Vue 2采取的办法是重写数组原型上的七个变异方法(push、pop、shift、unshift、splice、sort、reverse);三是初始化时需要递归遍历整个对象,数据量大时性能开销不小。
二、手写一个完整的双向绑定:Observer、Watcher与Dep
只劫持数据还不够,得有人订阅、有人通知。经典的三件套分工很明确:Observer负责把数据全部变成响应式的;Dep是一个依赖收集容器,每个属性对应一个Dep实例;Watcher是订阅者,数据一变它就执行回调。而视图层需要一个编译器,扫描DOM里的指令,为每个绑定的属性创建Watcher,同时在getter里完成订阅。
下面是精简后的完整实现,可以直观看到整个链路:
// 1. Dep:依赖收集器
class Dep {
constructor() {
this.subs = []; // 存放所有Watcher
}
addSub(w) { this.subs.push(w); }
notify() { this.subs.forEach(w => w.update()); }
}
Dep.target = null; // 当前正在收集的Watcher
// 2. Observer:递归劫持所有属性
function observe(obj) {
if (!obj || typeof obj !== 'object') return;
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}
function defineReactive(obj, key, val) {
const dep = new Dep();
observe(val); // 递归处理嵌套对象
Object.defineProperty(obj, key, {
get() {
if (Dep.target) dep.addSub(Dep.target); // 收集依赖
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal);
dep.notify(); // 派发更新
}
});
}
// 3. Watcher:订阅者
class Watcher {
constructor(obj, key, cb) {
this.obj = obj;
this.key = key;
this.cb = cb;
this.value = this.get();
}
get() {
Dep.target = this; // 把自己挂到全局
const value = this.obj[this.key]; // 触发getter完成订阅
Dep.target = null;
return value;
}
update() {
const oldVal = this.value;
const newVal = this.obj[this.key];
if (newVal !== oldVal) {
this.value = newVal;
this.cb(newVal, oldVal);
}
}
}
// 4. 使用示例:数据变了,视图回调自动执行
const data = { text: '初始内容' };
observe(data);
new Watcher(data, 'text', (newVal) => {
console.log('视图更新为:' + newVal);
});
data.text = '我变了'; // 控制台输出:视图更新为:我变了这段代码的关键技巧在于Dep.target这个全局变量。Watcher初始化时先把自己挂上去,再去读取一次目标属性,读取动作会触发getter,getter发现Dep.target存在,就把这个Watcher存进Dep。这一步俗称“依赖收集”,本质上就是发布订阅模式在getter里的落地。
把这个Watcher的回调改成操作DOM,比如document.querySelector('#app').textContent = newVal,再把输入框的input事件监听里写入数据,双向绑定就成型了:用户输入触发事件更新数据,数据的setter触发notify更新视图,形成闭环。
三、Proxy方案:Vue 3为什么换了思路
ES6的Proxy可以代理整个对象而不是某个属性,新增删除属性、数组索引赋值都能拦截到,天然解决了Object.defineProperty的短板,所以Vue 3全面转向了Proxy。
const data = { list: ['a', 'b'] };
const proxy = new Proxy(data, {
get(target, key, receiver) {
console.log('读取:' + String(key));
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log('写入:' + String(key) + ' = ' + value);
const result = Reflect.set(target, key, value, receiver);
// 这里可以触发更新
return result;
},
deleteProperty(target, key) {
console.log('删除:' + String(key));
return Reflect.deleteProperty(target, key);
}
});
proxy.list[0] = 'x'; // 索引赋值也能拦截
proxy.newProp = 123; // 新增属性也能拦截
delete proxy.newProp; // 删除属性同样能拦截Proxy的优势还包括惰性代理:访问到嵌套对象时才递归代理,初始化成本比Vue 2低很多。但它也不是完美无缺,比如Proxy代理不了内部this指向原始对象的场景,一个典型例子是WeakMap与Vue响应式配合时的边界情况;另外Proxy在部分老旧浏览器不可用且无法被polyfill,这是框架选型时需要权衡的点。
四、双向绑定的另一半:视图到数据的同步
上面解决的都是数据到视图这一半,反过来视图到数据其实简单得多,靠的就是事件监听。框架的做法是编译模板时发现某个指令(比如Vue里的v-model),就会给对应的输入元素绑定input事件,事件触发时把新值写回数据,数据的setter再触发视图更新,两个方向就串起来了。
// 极简版v-model的实现思路
function modelBind(inputEl, data, key) {
// 数据到视图
new Watcher(data, key, (newVal) => {
inputEl.value = newVal;
});
inputEl.value = data[key];
// 视图到数据
inputEl.addEventListener('input', function () {
data[key] = inputEl.value;
});
}值得一提的是,v-model在不同元素上是语法糖:在原生input上相当于绑定value属性加input事件;在组件上相当于props传值加监听一个update事件。理解了这一点,就能明白为什么自定义组件上使用v-model时需要声明对应的props和emit,而不是以为框架替你做了什么神秘操作。
总结一下,双向数据绑定并不神秘:数据到视图靠劫持加发布订阅,视图到数据靠事件监听。搞懂了defineReactive里getter收集依赖、setter派发更新这套机制,再去读Vue、MobX甚至任何响应式库的源码,都会顺畅很多。建议自己把上面的代码敲一遍跑起来,改动几处观察输出,比看十篇原理文章印象都深。