写 Vue 2 的项目,大概率你撞过这么一次:this.list[0] = newItem 执行完了,Vue DevTools 里数据确实变了,页面纹丝不动。换成 this.$set(this.list, 0, newItem) 立刻就好。当时可能就记住了「数组要用 $set」这个结论,但没想明白为什么。
这背后是 Vue 2 响应式实现方式带来的一整套边界条件。搞清楚 Object.defineProperty 在这条链路上具体做了什么,那些「什么时候能直接改、什么时候必须用 $set」的规则就不需要死记了,能自己推出来。
这篇从数据变化到视图更新的完整链路讲起,把依赖收集、数组拦截、新增属性、整体替换这几个场景逐个拆开,最后看看 Vue 3 换成 Proxy 之后补上了哪些洞。文章偏原理侧,更偏实践总结的版本可以看 Vue 响应式原理总结。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 从数据修改到 DOM 更新,中间到底经过了几道工序
Object.defineProperty的 getter 和 setter 各自负责什么- 依赖收集里的 Dep 和 Watcher 是怎么配对的
- 数组为什么监听不到,以及「defineProperty 做不到」这个说法哪里不准确
- 数组变异方法的原型拦截是怎么实现的
- 修改已有属性、新增属性、整体替换三种场景的正确写法
$forceUpdate能让页面更新,但它和响应式不是一回事- Vue 3 换成 Proxy 之后哪些限制消失了,又引入了什么新约束
# 一、响应式的整条链路
先把全貌摆出来。
Vue 的响应式原理核心是通过 ES5 用于保护对象的 Object.defineProperty 中的访问器属性,也就是 get 和 set 方法。data 中声明的属性都被添加了访问器属性,当读取 data 中的数据时自动调用 get 方法,当修改 data 中的数据时自动调用 set 方法。检测到数据变化后会通知观察者 Watcher,Watcher 自动触发当前组件重新 render(子组件不会重新渲染),生成新的虚拟 DOM 树。Vue 框架会遍历并对比新旧两棵虚拟 DOM 树中每个节点的差别并记录下来,最后把所有记录的不同点局部修改到真实 DOM 树上。

这条链路可以拆成四道工序,每一道出问题的表现都不一样:
- 劫持:初始化时递归遍历
data,给每个属性装上 getter/setter - 依赖收集:渲染过程中读取属性,getter 把当前正在渲染的 Watcher 记下来
- 派发更新:属性被赋值,setter 通知所有记下来的 Watcher
- 打补丁:Watcher 触发重新 render,diff 新旧虚拟 DOM,把差异打到真实 DOM 上
虚拟 DOM(Virtual DOM)是用 JS 对象模拟的,保存当前视图内所有 DOM 节点对象的基本描述属性和节点间关系的树结构。用 JS 对象描述每个节点及其父子关系,形成虚拟 DOM 对象树。
大部分「数据变了页面不动」的问题,卡的都是前两道工序。要么属性压根没被劫持,要么劫持了但依赖没收集上。
# 二、getter 和 setter 分别在干什么
很多人对 defineProperty 的理解停留在「拦截读写」,但读和写这两件事的分工是完全不对等的。
function defineReactive(obj, key, val) {
const dep = new Dep(); // 每个属性一个依赖收集器
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 谁在读我,就把谁记下来
if (Dep.target) {
dep.depend();
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
// 我变了,通知所有记下来的人
dep.notify();
}
});
}
getter 干的是收集的活。Dep.target 是一个全局变量,指向当前正在执行的 Watcher。组件渲染的时候,Vue 先把这个组件的渲染 Watcher 挂到 Dep.target 上,再执行 render 函数。render 过程中读到哪个属性,哪个属性的 dep 就把这个 Watcher 收进去。
setter 干的是通知的活。属性被赋值时,遍历自己的 dep 里存的所有 Watcher,挨个通知它们该更新了。
所以有个推论很关键:没有在模板里被读到的属性,它的 dep 是空的,改了也不会触发任何更新。
这解释了一个常见的困惑。你在 data 里声明了一个属性但模板里没用,改它页面当然没反应,这不是 bug,是依赖收集根本没发生。关于 Object.defineProperty 本身的更多用法,可以看 Object.defineProperty 详解。
# 三、数组为什么监听不到
因为只要在 data 中声明的基本数据类型的数据基本不存在数据不响应的问题,所以重点是数组和对象在 Vue 中的数据响应问题。Vue 可以检测对象属性的修改,但无法监听数组的所有变动以及对象的新增和删除,只能使用数组变异方法及 $set 方法。
这里要澄清一个流传很广的说法。很多文章会写「Object.defineProperty 监听不到数组」,这句话其实不太准确。
数组的下标本身也是对象的属性,技术上完全可以对 arr[0]、arr[1] 逐个 defineProperty。Vue 2 没这么做,是主动权衡的结果。数组的长度可能是几万甚至几十万,初始化时逐个装 getter/setter 的开销扛不住,而且 push 之后新增的下标还得重新处理一遍。尤雨溪在多个场合都解释过这是性能取舍,不是能力缺失。
所以 Vue 2 换了个思路,不拦截下标,改成拦截方法。

可以看到,arrayMethods 首先继承了 Array.prototype,然后对数组中所有能改变数组自身的方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的三个方法 push、unshift、splice 做了判断,获取到插入的值,然后把新添加的值变成响应式对象,最后调用 ob.dep.notify() 手动触发依赖通知。这就很好地解释了为什么 vm.items.splice(newLength) 能被检测到变化。
被重写的一共是七个方法:
| 方法 | 会改变原数组 | 需要额外 observe 新元素 |
|---|---|---|
push |
是 | 是 |
unshift |
是 | 是 |
splice |
是 | 是 |
pop |
是 | 否 |
shift |
是 | 否 |
sort |
是 | 否 |
reverse |
是 | 否 |
反过来看,filter、map、concat、slice 这些方法不在列表里,因为它们返回新数组,不改变原数组。用它们的时候把结果整体赋值回去就能触发响应,走的是对象属性 setter 那条路。
至于两个经典的失效场景,现在就能自己解释了:
this.list[0] = x直接改下标,没走任何被重写的方法,dep.notify()不会被调用this.list.length = 0改长度同理,length属性上没有 setter
# 四、三种修改场景该怎么写
# 4.1 修改已有属性,直接改就行
当想要修改对象已有的属性而非新增属性时,一个已经在 data 中声明过的响应式数据可以直接操作改变,数据改变会经过上图的步骤触发视图更新,直接 obj.xxx = xxx 即可。
数组除外,但后台传过来的 JSON 数组,数组中嵌套的对象也可以直接修改。因为 Object.defineProperty 的限制导致无法监听数组的变动,但 Vue 始终会深度遍历 data 中的数据,给数组中嵌套的对象添加上 get 和 set 方法,完成对对象的监听。所以数组中嵌套对象的情况是可以直接修改数组中的对象,并且保持响应式。
这一条特别实用。this.list[0].name = 'new' 是能触发更新的,因为 list[0] 这个对象本身被 observe 过,它的 name 属性有 setter。但 this.list[0] = {...} 不行,因为这一步操作的是下标。
一字之差,行为完全不同。
# 4.2 新增属性,用 $set 或变异方法
即使是一个后台传过来的 JSON 数组,也可以使用 this.$set 向数组中的某个对象添加一个响应式的属性,例如 this.$set(arr[0], 'xxx', xxx)。或者使用数组变异方法例如 splice。
$set 内部做的事情其实就三步。判断目标是数组还是对象,是数组就调重写过的 splice 塞进去;是对象就先检查这个 key 是不是已经存在,存在就直接赋值,不存在才走 defineReactive 给它补上 getter/setter,最后手动 ob.dep.notify() 通知一次。
// 给对象新增响应式属性
this.$set(this.form, 'phone', '13800000000');
// 给数组指定位置替换元素
this.$set(this.list, 0, newItem);
// 等价写法
this.list.splice(0, 1, newItem);
这里有个坑要注意,$set 不能作用在 Vue 实例本身或者根级 data 对象上。也就是说 this.$set(this, 'newProp', 1) 会在开发环境报警告。根级属性必须在 data 里预先声明,哪怕先给个 null 占位。
# 4.3 整体替换
向响应式的数组和对象替换为新的数据时,可以直接赋值。因为 data 中声明的数据已经添加了访问器属性 setter,当重新赋值一个新的堆内存地址时,该数组或对象也会被循环遍历添加访问器属性,所以依然是有响应式的。

这是处理列表数据最省心的写法。与其纠结怎么往数组里精确插入,不如构造一个新数组整体赋值:
// 与其这样
this.list[index] = newItem;
// 不如这样,走的是 list 属性的 setter,一定触发更新
this.list = this.list.map((item, i) => (i === index ? newItem : item));
整体替换的代价是这个数组会被重新完整地 observe 一遍,数据量很大时有性能开销。列表上万条的场景要斟酌,几十上百条完全不用考虑。
# 五、$forceUpdate 能救场,但它不是响应式
Vue 无法监听对象的新增和删除。直接通过 obj.xxx = xxx 新增一个原本没有的属性,同时修改当前组件的另一个响应式数据,会重新触发当前组件 render,可以让非响应式数据也保持更新状态,但它并不是响应式的。
具体来说,给一个已经在 data 中声明过的对象 obj 新增一个原本没有的属性,同时修改组件中其他某个响应式数据,该 obj 也会同步更新到最新的数据。另一种情况是,当你向一个对象或数组中同时增加一个响应式和一个非响应式数据,非响应式数据也会同步更新到页面上。
只要触发当前组件重新 render,就可以让数据显示为最新状态,例如 this.$forceUpdate()。

我一开始也觉得这算是个可用的技巧,后来发现它坑很深。
$forceUpdate 做的只是「这一次把最新值渲染出来」,属性上依然没有 setter。下一次这个属性再变,页面不会有任何反应,除非你再调一次。更麻烦的是它只强制当前组件重新渲染,不影响子组件,所以你会遇到父组件更新了、子组件还是老数据这种极难排查的现象。
线上代码里出现 $forceUpdate,九成是某处该用 $set 却没用。