导航
导航
文章目录󰁋
  1. 一、响应式的整条链路
  2. 二、getter 和 setter 分别在干什么
  3. 三、数组为什么监听不到
  4. 四、三种修改场景该怎么写
    1. 4.1 修改已有属性,直接改就行
    2. 4.2 新增属性,用 $set 或变异方法
    3. 4.3 整体替换
  5. 五、$forceUpdate 能救场,但它不是响应式
  6. 六、为什么 Vue 3 换成了 Proxy
  7. 七、动手写一个最小的双向绑定
  8. 总结
  9. 参考
NEW
🚀

前端系统进阶指南

系统化学习前端知识

关注公众号

公众号:前端进阶之旅

Vue响应式原理从defineProperty到Proxy

写 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 中的访问器属性,也就是 getset 方法。data 中声明的属性都被添加了访问器属性,当读取 data 中的数据时自动调用 get 方法,当修改 data 中的数据时自动调用 set 方法。检测到数据变化后会通知观察者 WatcherWatcher 自动触发当前组件重新 render(子组件不会重新渲染),生成新的虚拟 DOM 树。Vue 框架会遍历并对比新旧两棵虚拟 DOM 树中每个节点的差别并记录下来,最后把所有记录的不同点局部修改到真实 DOM 树上。

Vue 响应式从数据变更到 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 换了个思路,不拦截下标,改成拦截方法。

Vue 2 中 arrayMethods 对数组变异方法的重写实现

可以看到,arrayMethods 首先继承了 Array.prototype,然后对数组中所有能改变数组自身的方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的三个方法 pushunshiftsplice 做了判断,获取到插入的值,然后把新添加的值变成响应式对象,最后调用 ob.dep.notify() 手动触发依赖通知。这就很好地解释了为什么 vm.items.splice(newLength) 能被检测到变化。

被重写的一共是七个方法:

方法 会改变原数组 需要额外 observe 新元素
push
unshift
splice
pop
shift
sort
reverse

反过来看,filtermapconcatslice 这些方法不在列表里,因为它们返回新数组,不改变原数组。用它们的时候把结果整体赋值回去就能触发响应,走的是对象属性 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 却没用。

六、为什么 Vue 3 换成了 Proxy

Object.defineProperty 虽然能实现双向绑定,但缺点很明确,它只能对对象的已有属性做数据劫持,所以初始化时要深度遍历整个对象,不管层级有多深。只要数组中嵌套有对象就能监听到对象的数据变化,但监听不到数组本身的变化。Proxy 没有这个问题,可以代理整个对象的操作,所以 Vue 3 用 Proxy 代替了 defineProperty

Proxy 补上的洞主要有这几个:

  • 直接改数组下标 arr[0] = x 能被拦截
  • 修改 arr.length 能被拦截
  • 新增属性 obj.newKey = x 能被拦截,不再需要 $set
  • 删除属性 delete obj.key 能被拦截,不再需要 $delete
  • 代理是惰性的,只有真正访问到某一层才会递归代理下去,初始化开销显著降低
// Vue 3 的最简响应式模型
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key); // 依赖收集
const res = Reflect.get(obj, key, receiver);
// 惰性递归,访问到才代理
return typeof res === 'object' && res !== null ? reactive(res) : res;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const result = Reflect.set(obj, key, value, receiver);
if (oldValue !== value) trigger(obj, key); // 派发更新
return result;
}
});
}

但 Proxy 也不是没有代价。它代理的是对象,没法代理原始值,所以 Vue 3 才额外引入了 ref 用一个对象把原始值包一层。另外 Proxy 没有办法被 polyfill,这是 Vue 3 直接放弃 IE11 的根本原因。

MapSetWeakMap 这些集合类型也需要单独写一套 handler,因为它们的操作走的是方法调用而不是属性读写,普通的 get/set 拦截不到。

七、动手写一个最小的双向绑定

把前面的东西串起来,一个最小可用的双向绑定其实就是 Observer 加 Dep 加 Watcher 加 Compiler 四个部分。

数据双向绑定的整体实现结构示意

Observer 负责递归遍历数据对象,给每个属性装上 getter/setter。Dep 是每个属性各自持有的订阅者列表。Watcher 代表一个具体的更新任务,它在被创建时把自己挂到 Dep.target 上,然后主动读一次数据来完成依赖收集。Compiler 负责扫描模板,遇到指令和插值就创建对应的 Watcher。

class Watcher {
constructor(vm, key, updateFn) {
this.vm = vm;
this.key = key;
this.updateFn = updateFn;

// 关键的两行:挂上去,读一次,触发 getter 完成收集,再摘下来
Dep.target = this;
this.vm[this.key];
Dep.target = null;
}

update() {
this.updateFn.call(this.vm, this.vm[this.key]);
}
}

这十几行代码就是整个依赖收集机制的核心。理解了「挂上全局变量、主动读一次、摘掉」这三步,Vue 2 源码里 mountComponent 那块逻辑就不难看懂了。

至于后半段的 diff 和 patch,属于另一个话题,可以看 虚拟 DOM 原理分析

总结

Vue 2 的响应式建立在 Object.defineProperty 之上,getter 负责在渲染时收集依赖,setter 负责在赋值时派发更新。所有的行为边界都是从这个机制推导出来的,不需要单独记。

数组的问题不是 defineProperty 做不到,是 Vue 2 出于性能考虑主动放弃了对下标的逐个劫持,改成重写 pushpopshiftunshiftsplicesortreverse 这七个变异方法。绕开这七个方法去改数组,更新就不会触发。

三种修改场景的规则也就清楚了。改对象已有属性直接赋值;新增属性和改数组下标用 $set;改一大片就整体替换,让它走属性本身的 setter。$forceUpdate 只是把当前这一帧渲染出来,属性依然没有响应式,看到它出现基本可以判定某处漏了 $set

Vue 3 换成 Proxy 之后,新增属性、删除属性、数组下标、length 修改全部能被拦截,初始化也从深度遍历变成了惰性递归。代价是原始值需要 ref 包一层,以及彻底告别 IE11。

参考

支持一下
扫一扫,支持poetries
  • 微信扫一扫
  • 支付宝扫一扫