响应式原理:Object.defineProperty 与 Observer
🎯 引言
「修改 data 里的数据,页面自动更新」是 Vue2 给我们最直接的印象。这篇我们就打开这个黑盒,看看它是怎么做到的。学完之后,你能够:
- 说清楚
Object.defineProperty是如何拦截数据的读写的。 - 理解
defineReactive、Observer和数组方法拦截的分工。 - 手写一个迷你响应式系统,在数据被读取和修改时打印日志。
- 知道 Vue2 响应式的两个经典局限,以及用
Vue.set解决的方法。
本篇源码基于 Vue 2.7.16(GitHub vuejs/vue 仓库),主要涉及 src/core/observer/index.js 和 src/core/observer/array.js。示例代码可以直接用 Node.js v22 运行。
🧱 什么是响应式
响应式就是:数据一变,用到这个数据的地方自动跟着变。
生活化一点说,它像我们订的报纸。你向报社登记「我要订这份报」(依赖收集),报社每天出新报纸(数据变化)就主动送到你家(触发更新)。你不需要每天跑去报社问「今天出新报了吗」。
在 Vue2 里,「报社」就是我们写在 data 里的数据。Vue 会悄悄改造这些数据:读它的时候登记一次「谁在用」,改它的时候通知所有使用者更新。这一「登记」一「通知」,靠的就是 Object.defineProperty。
如果你学过 Vue2 课程,可以回想一下:我们在 data 里声明的字段,模板里改了输入框、页面上立刻变,背后就是这套机制在工作。
🧱 Object.defineProperty 基础回顾
Object.defineProperty 可以给对象的某个属性定义 getter 和 setter。当别人读取这个属性时会走 getter,修改时会走 setter,我们就有机会在中间插入自己的逻辑。
先写一个能跑的小例子感受一下:
const user = {};
let name = 'noxussj';
Object.defineProperty(user, 'name', {
get() {
console.log('读取了 name');
return name;
},
set(newVal) {
console.log('修改了 name:', newVal);
name = newVal;
},
});
user.name; // 打印:读取了 name
user.name = '小明'; // 打印:修改了 name: 小明
用 Node.js v22 运行 node demo.js,就能看到读取和修改都被我们拦截下来了。
name 里,user.name 只是一个「入口」。getter/setter 通过闭包记住了这个变量,所以读写都能拿到正确的值。能拦截读写,就有了实现响应式的基础:在 getter 里登记使用者(依赖收集),在 setter 里通知更新。
🧱 defineReactive:把一个属性变成响应式
Vue2 把「给单个属性加 getter/setter」封装成了 defineReactive 函数。下面是教学简化版,去掉了 Flow 类型注解和边界分支,保留核心逻辑:
// 简化自 src/core/observer/index.js 的 defineReactive
function defineReactive(obj, key, val) {
// 嵌套对象也要递归变成响应式
observe(val);
Object.defineProperty(obj, key, {
get() {
// 依赖收集:登记「谁在用这个属性」
// 细节下一篇讲,这里先占位:dep.depend()
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
// 新赋的值如果是对象,也要变成响应式
observe(newVal);
// 触发更新:通知视图重新渲染,下一篇细讲
// dep.notify()
console.log(`触发更新:${key} 变成了`, newVal);
},
});
}
注意三个要点:
- 递归观测:调
observe(val),保证user.info.name这种深层属性也是响应式的。 - get 里收集依赖:谁在渲染时读了这个属性,就把它记下来。具体怎么记,下一篇「依赖收集与 Watcher」会展开。
- set 里触发更新:值变了就通知视图。同时对新值再调一次
observe,防止新赋的对象「漏网」。
🧱 Observer 类:遍历整个对象
defineReactive 一次只能处理一个 key,而 data 往往是一整个对象。Observer 类负责遍历对象的每个 key,逐个调用 defineReactive:
// 简化自 src/core/observer/index.js 的 Observer 类
class Observer {
constructor(data) {
// 给数据打上 __ob__ 标记,表示「已观测」,避免重复处理
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false, // 不可枚举,遍历时不会出现
});
if (Array.isArray(data)) {
// 数组走特殊处理,下一节讲
protoAugment(data, arrayMethods);
this.observeArray(data);
} else {
// 对象:遍历每个 key,逐个响应式化
this.walk(data);
}
}
walk(data) {
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key]);
});
}
observeArray(items) {
items.forEach((item) => observe(item));
}
}
function observe(data) {
if (typeof data !== 'object' || data === null) return;
// 已经观测过(有 __ob__)就直接复用,不再重复处理
if (data.__ob__) return data.__ob__;
return new Observer(data);
}
这里的 __ob__ 是一个重要的细节:
- 标记作用:看到数据身上有
__ob__,就知道它已经被观测过,直接跳过,避免重复包一层 getter/setter。 enumerable: false:让__ob__不出现在Object.keys、for...in的结果里,也不会参与后续的递归观测,防止干扰业务数据和造成死循环。
把对象交给 observe 之后,它身上的每一层属性都被装上了 getter/setter。读取时收集依赖,修改时触发更新,对象的响应式就完成了。
🧱 数组的特殊处理:重写七个方法
对象可以靠 defineReactive 逐 key 拦截,但数组不行。如果给一个有几千个元素的数组逐下标加 getter/setter,性能开销太大,而且通过下标赋值是数组的高频操作,拦截收益很低。
Vue2 换了个思路:数组的内容变化,几乎都是通过 push、splice 这些方法触发的,重写这些方法就够了。 具体做法是创建一个以数组原型为原型的新对象,在新对象上重写七个会改变原数组的方法,再把这个新对象设为响应式数组的原型:
// 简化自 src/core/observer/array.js
const arrayProto = Array.prototype;
// 以数组原型为原型,创建一个新对象
const arrayMethods = Object.create(arrayProto);
// 七个会改变原数组的方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach((method) => {
const original = arrayProto[method];
arrayMethods[method] = function (...args) {
// 1. 先调用原方法,保证数组行为不变
const result = original.apply(this, args);
// 2. 对 push / unshift / splice 新插入的元素做观测
let inserted;
if (method === 'push' || method === 'unshift') {
inserted = args;
} else if (method === 'splice') {
inserted = args.slice(2); // splice 第三个参数起是新增元素
}
if (inserted) {
inserted.forEach((item) => observe(item));
}
// 3. 通知视图更新(同样先占位,下一篇细讲)
// ob.dep.notify()
console.log('数组变化了,触发更新');
return result;
};
});
// 把响应式数组的原型指向 arrayMethods
function protoAugment(data, proto) {
Object.setPrototypeOf(data, proto);
}
用一个比喻帮助理解:普通数组的原型链是 数组 -> Array.prototype。改造后变成 数组 -> arrayMethods -> Array.prototype。调用 list.push() 时,先命中 arrayMethods 上重写的版本,执行完拦截逻辑后,再转发给 Array.prototype 上的原版方法。
这样设计带来两个能力:
- 拦截变更:调
list.push(4)能触发更新,因为push被我们重写过。 - 新增元素也是响应式的:
push进去一个对象,会被observe处理,之后改它的属性照样能触发更新。
没有改动数组行为本身,list.length、for 循环这些照常工作,只是原型链上多了一层「哨兵」。
⚠️ 两个经典局限
defineProperty 这套方案有一个硬伤:它只能拦截对象上「已经存在」的 key。这导致了两个经典局限,实际开发中很容易踩到。
局限一:对象新增属性不触发响应
data() {
return {
user: { name: 'noxussj' },
};
},
mounted() {
// user 上没有 age 这个 key,新增它不会触发更新
this.user.age = 18;
},
原因是 walk 只在初始化时遍历了已有的 key,age 是后来才出现的,身上没有 getter/setter,Vue 感知不到。
解决办法是用 Vue.set(组件内是 this.$set),它会手动把新属性变成响应式并触发更新:
this.$set(this.user, 'age', 18);
局限二:数组通过下标赋值或改 length 不触发响应
// 这两种写法都不会触发更新
this.list[0] = '新值';
this.list.length = 0;
上一节说过,数组的下标没有被逐个拦截(性能考虑),所以直接按下标赋值绕开了所有拦截逻辑,length 也不是被重写的七个方法之一。
解决办法同样用 $set,或者用被重写过的 splice:
this.$set(this.list, 0, '新值');
// 或者
this.list.splice(0, 1, '新值');
// 清空数组
this.list.splice(0);
this.$set 或 splice。Vue3 改用 Proxy 重写响应式后,这两个问题就不存在了。🧾 小节总结
- 响应式的核心:读取时收集依赖,修改时触发更新,读写都靠
Object.defineProperty拦截。 defineReactive把单个属性变成响应式,并对嵌套对象递归observe。Observer遍历对象的每个 key 调defineReactive,并用不可枚举的__ob__标记已观测的数据。- 数组不拦截下标,而是重写
push等七个方法:先调原方法,再观测新增元素,最后触发更新。 - 两个经典局限:对象新增属性、数组下标赋值或改
length都不触发更新,用$set或splice解决。
❓ 知识问答
Q1:为什么 Vue2 不拦截数组的下标?
A:主要是性能考虑。数组可能有成百上千个元素,逐个下标加 getter/setter 开销大,而下标赋值又不是高频操作。重写七个变更方法,就能覆盖绝大多数数组修改场景,性价比更高。
Q2:__ob__ 为什么要设置成不可枚举?
A:如果可枚举,Object.keys、for...in 都会多出一个 __ob__,污染业务数据;更严重的是递归观测时会再次碰到它,可能造成死循环。设成 enumerable: false 后它就「隐身」了,只作为标记存在。
Q3:push 进去的新对象,改它的属性能触发更新吗?
A:能。重写的 push 里会对新插入的元素调 observe,新对象同样被装上了 getter/setter,所以后续修改它的属性照常触发更新。
Q4:Vue3 是怎么解决这两个局限的?
A:Vue3 用 Proxy 替代 Object.defineProperty。Proxy 拦截的是整个对象而不是单个 key,新增属性、数组下标赋值、改 length 都能被拦截到,这两个局限自然消失。
🧪 小练习
不查资料,补全下面两个函数,实现一个迷你响应式系统:数据被读取时打印「读取了 xxx」,被修改时打印「xxx 修改为:新值」。用 Node.js v22 保存为 .js 文件直接运行验证。
function defineReactive(obj, key, val) {
// 请在这里编写代码
}
function observe(obj) {
// 请在这里编写代码(提示:遍历 obj 的每个 key)
}
const data = { name: 'noxussj', age: 18 };
observe(data);
// 期望输出:
// 读取了 name
// name 修改为: 小明
// 读取了 age
console.log(data.name);
data.name = '小明';
console.log(data.age);
完成后给自己加个挑战:如果 data 里再嵌套一层对象(如 info: { city: '杭州' }),你的实现能让 data.info.city 也打印日志吗?需要改动哪里?
🎉 恭喜你已经掌握 Vue2 响应式的核心原理啦!下一篇我们接着讲依赖收集与 Watcher,看看「谁在用这个数据」到底是怎么被登记下来的,以及更新是如何精确到具体节点的。
