基本实现
约 2395 字大约 8 分钟
2026-03-08
Vue 的响应式,本质是在 "副作用函数" 与 "该函数执行期间读取的响应式数据" 之间建立依赖关系,当这些被读取的数据发生变化时,Vue 会自动重新执行对应的副作用函数
这其中最核心的关键就是 如何让函数知晓了它依赖了哪些数据,因此这些数据必须要被拦截,并与函数建立一种关联。Vue 会先把数据做成响应式对象,在函数运行期间,记录它访问了哪些属性,最后当属性变化时,反向找到相关函数并重新执行
| 阶段 | Vue 在做什么 | 目的 |
|---|---|---|
| 1. 标记数据 | 把普通数据变成响应式数据 | 让读取和修改都可被感知 |
| 2. 执行函数 | 运行副作用函数 | 收集它实际访问了哪些数据 |
| 3. 建立关系 | 把函数与它读取过的数据关联起来 | 形成依赖关系 |
| 4. 触发更新 | 数据发生变化时,找到相关函数重新执行 | 完成自动更新 |
换个角度看,就是这条链路:函数执行 -> 读取数据 -> 建立依赖 -> 数据变化 -> 重新执行函数
为什么不是所有函数都自动参与响应式
Vue 并不会强制所有函数都和数据建立关系,而是只处理那些需要响应更新的函数。需要依赖数据的函数,就建立关联;不需要依赖数据的函数,就不建立关联。这样做的好处是:
- 避免无意义的追踪
- 只在真正需要更新的地方触发重新执行
- 把控制权交给开发者,而不是把所有逻辑都塞进响应式系统
实现流程
基本实现
三个最基本角色
原始数据对象
obj、一个副作用函数effect、一个依赖容器buckets解释
其中
effect就是 "数据变化后需要重新执行的函数",buckets则用来存放这些函数const obj = { name: 'John', age: 20, }; const buckets = new Set(); function effect() { app.innerHTML = proxy.name; }用
Proxy进行拦截想要建立起数据和副作用函数之间的关系,必须要拦截住数据的读取和修改这两个动作。只有这样在数据变化时,才能够找到相关函数并重新执行
通过
Proxy创建代理对象,就可以在数据发生读取和修改时,触发get和set,从而实现拦截const proxy = new Proxy(obj, { get(target, key, receiver) {}, set(target, key, value, receiver) {}, });依赖收集
当
effect执行到proxy.name时,会触发get。这时就把effect放进buckets,就表示这个函数依赖了当前读取的数据,然后通过Reflect返回目标对象的属性值get: (target, key, receiver) => { buckets.add(effect); return Reflect.get(target, key, receiver); }完成首次渲染和首次收集
需要显示执行一次副作用函数来触发
get,从而完成首次渲染和首次收集effect();触发更新
当执行
proxy.name = 'Jane'时,会触发set。set回先把新值写回原对象,然后遍历buckets,把里面收集到的函数重新执行一遍set: (target, key, value, receiver) => { const result = Reflect.set(target, key, value, receiver); buckets.forEach((fn) => fn()); return result; }整条链路
阶段 触发点 发生的事 首次执行 effect()触发副作用函数,开始读取响应式数据 依赖收集 get把 effect收进buckets修改数据 proxy.name = 'Jane'触发 set派发更新 buckets.forEach(fn => fn())把收集到的副作用函数重新执行 更新视图 app.innerHTML = proxy.nameDOM 显示最新值
Proxy 与 Object.defineProperty 的差异
Vue3 放弃 defineProperty 并不是因为它少了监听新增属性或删除属性的几个能力,而是因为当响应式系统继续往下做时,defineProperty 已经无法承载 Vue 3 想要的那套依赖模型
Vue 3 的响应式系统,不仅仅是要知道 "某个属性被读了" 或 "某个属性被改了",而是要更精细化地回答下面这些问题:
详情
- 是读取了某个具体属性,还是在判断这个属性是否存在
- 是修改已有属性,还是新增了一个属性
- 是删除属性导致结构变化,还是只是值变化
- 是在遍历对象的 key,还是在遍历数组、
Map、Set - 是在读取
length、size这类派生结构信息,还是在读取普通值
只有把这些操作区分开,track 和 trigger 才能做到真正精确。因此源码里才会区分 GET、HAS、ITERATE,以及 SET、ADD、DELETE、CLEAR 这些触发类型,
重要
本质上响应式追踪的从来不只是属性值,而是整个数据结构上的访问语义
defineProperty 的问题
核心问题:拦截粒度太低。Object.defineProperty 的工作方式是:对对象上某一个已经存在的属性,单独定义 getter / setter,然后遍历对象的每个属性,逐个改写成 getter / setter
Object.defineProperty(obj, 'foo', {
get() {},
set(value) {},
});这意味着它的能力边界天然就是 "某个 key 的读取和写入"。但 Vue 真正要处理的依赖,远不止这一层
只能拦截属性值,不能自然表达 "结构变化"
下面几种访问:
obj.foo; 'foo' in obj; Object.keys(obj); for (const key in obj) {} delete obj.foo;这几种操作依赖的根本不是同一种信息
解释
obj.foo依赖的是foo这个具体属性的值'foo' in obj依赖的是foo这个属性是否存在Object.keys(obj)和for...in依赖的是整个对象的 key 集合delete obj.foo影响的是对象结构,而不只是某个属性值
而
defineProperty只能在foo这个属性本身的getter / setter上做文章,它没有办法站在对象这一层统一拦截in、for...in、Object.keys、delete这些操作这会直接导致一个问题:Vue 2 的依赖模型天然偏向 "值级别依赖",却很难完整表达 "结构级别依赖"。这不是实现技巧的问题,而是底层拦截机制决定的
所以 Vue 2 才会出现很多额外约束:
- 新增属性需要
Vue.set - 删除属性需要
Vue.delete - 遍历相关更新并不是靠统一语义完成的,而是靠额外补丁函数去完成
初始化成本
根据上面的内容,要让一个对象变成响应式对象,Vue 2 必须在一开始就递归遍历它的所有 key,把每个属性都改写成
getter / setter于这种数据,只要它进入响应式系统,就需要被层层 walk,一直走到最深处
const data = { user: { profile: { address: { city: 'Beijing', }, }, }, };当对象很大时,初始化阶段就要支付整棵对象树的遍历成本,后续如果给某个属性重新赋值为一个新对象,还要继续递归观测这个新对象
Proxy 的做法
先代理根对象,等真正访问到某个嵌套对象时,再按需把它包装成响应式对象。这种 "惰性代理" 对 Vue 3 很关键,因为它把成本从 "初始化时整体支付" 变成了 "访问到哪里,处理到哪里"
数组问题
数组是 Vue 2 响应式里最别扭的一块,因为对于数组而言,下面这些操作都很常见:
arr[0] = 1; arr.length = 0; arr.push(1); arr.splice(0, 1);但
defineProperty无法自然拦截:解释
- 通过索引设置数组项
- 直接修改
length
所以 Vue 2 只能换一种思路:重写数组变异方法
这其实说明了一件事:
defineProperty已经不足以给数组提供一套统一的响应式语义,只能靠方法劫持去打补丁,而补丁方案的问题是:详情
- 只能覆盖特定变异方法
- 语义被拆散了,一部分依赖在 getter / setter 上,一部分依赖在数组方法重写上
- 实现需要大量特殊分支
这也是为什么 Vue 3 在数组上会舒服得多,因为
Proxy可以在对象层统一拦截索引读写、length访问、属性存在性判断等行为,数组终于不需要作为一个特例系统存在无法支持集合类型
Vue 3 的响应式不只覆盖普通对象和数组,还覆盖了:
Map、Set、WeakMap、WeakSet这些集合类型重要
对这些集合而言,响应式系统真正关心的是他们的:
get、set、add、delete、clear、size,以及迭代器遍历。这些操作根本不是 "对象属性 getter / setter" 的范畴。因此defineProperty在这里几乎没有施展空间但
Proxy不同。Vue 3 可以在代理层拦截这些方法访问,再通过 collection handlers 对Map/Set的操作做统一包装,这才让集合类型第一次真正进入 Vue 的响应式系统
