外观
Vue 响应式系统深潜:一文搞懂从依赖收集到 effect 调度的源码级实现
每个 Vue 开发者都背过「数据变了,视图就变」这句话,但真正把响应式当回事,往往是在排查诡异 bug 的深夜:props 一解构就丢了响应式,watch 的回调死活不触发,明明改了数组页面却纹丝不动。这些问题用「背口诀」的方式绕过去一次,还会换一种姿势绕回来第二次。根治的办法只有一个:把响应式系统的实现逻辑真正过一遍。
这篇文章以 Vue 3 稳定版本(3.4 / 3.5 系)的实现为线索,把响应式从 Proxy 拦截、依赖收集到 effect 调度的完整链路拆开讲清,并附一个百行内、可直接运行的最小实现。读完之后你会发现,那些看似玄学的问题全都有确定性的成因。
从 Proxy 到 track:依赖收集的完整链路
Vue 3 用 Proxy 替换了 Vue 2 的 Object.defineProperty,核心收益是天然拦截属性增删、数组索引赋值这类 Vue 2 时代需要 hack 的操作。reactive(obj) 返回的代理对象在 get 阶段做两件事:返回取值结果(嵌套对象做懒代理,访问到才包一层),以及调用 track 把「当前正在运行的 effect」记录到这个属性的依赖集合里。set 阶段则调用 trigger,把收集到的依赖全部通知一遍。
把这条链路压缩到极致,就是下面这个百行级的最小实现,保存为单文件用 Node 直接可跑,完整覆盖 track、trigger、effect 与 computed 四个核心角色:
js
// mini-reactivity.js:Vue 3 响应式最小实现,node mini-reactivity.js 直接运行
let activeEffect = null // 当前正在执行的副作用(effect)
const targetMap = new WeakMap() // 原始对象 → 属性 → 依赖集合 的两层映射
// 依赖收集:把 activeEffect 记入「对象.属性」对应的 dep 集合
function track(target, key) {
if (!activeEffect) return // 没有 effect 在跑,说明不是响应式上下文,跳过
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect) // 记下谁在用这个属性
}
// 派发更新:属性变化时通知所有依赖它的 effect 重新执行
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
if (!dep) return
for (const effect of dep) effect.schedule?.() ?? effect.run()
}
// 响应式代理:get 时收集依赖,set 时派发更新
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
const val = Reflect.get(target, key, receiver)
// 嵌套对象懒代理:访问到才包一层,避免初始化时递归全量代理
return typeof val === 'object' && val !== null ? reactive(val) : val
},
set(target, key, value, receiver) {
const old = Reflect.get(target, key, receiver)
const ok = Reflect.set(target, key, value, receiver)
if (old !== value) trigger(target, key) // 值确实变了才通知
return ok
}
})
}
// 副作用:包装一个函数,执行期间被读取的响应式属性都会收集到它
function effect(fn) {
const e = {
run() {
activeEffect = e
try { fn() } finally { activeEffect = null } // 执行完清空,防止误收集
},
schedule() { queueJob(e) } // 更新走调度器,见下节
}
e.run()
return e
}
// 批量调度队列:同一轮微任务内去重,避免一次赋值触发 N 次重渲染
const queue = new Set()
let flushing = false
function queueJob(job) {
queue.add(job)
if (flushing) return
flushing = true
Promise.resolve().then(() => {
for (const job of queue) job.run()
queue.clear()
flushing = false
})
}
// —— 验证:与 Vue 的使用体感一致 ——
const state = reactive({ count: 0 })
effect(() => console.log('视图更新:count =', state.count))
state.count = 1 // 输出一次更新
state.count = 2 // 输出一次更新
state.count = 2 // 值未变化,不触发两个容易忽略的细节值得强调:一是 track 必须发生在「effect 执行期间」,靠全局变量 activeEffect 标记当前上下文,这也是嵌套 effect 需要栈管理的原因(Vue 内部用 effectStack 处理);二是懒代理策略让初始化成本从「一次性递归全量代理」降为「按访问路径逐步代理」,这是大对象首屏性能的关键设计。
effect 调度:为什么一次赋值只触发一次更新
很多初学者担心:一次事件回调里改了十个响应式属性,视图岂不是要重渲染十次?答案是不会,奥秘就在调度器。组件的渲染函数本身就是一个 effect,trigger 通知它时并不会立即重跑,而是把更新任务丢进一个用 Set 实现的去重队列,再借 Promise 的微任务机制在「同步代码全部执行完」之后统一批量刷新。
这个设计带来三层保障:Set 按 effect 身份天然去重,十次赋值汇成一次刷新;微任务时机保证了同一个 tick 内的多次修改原子生效;Vue 内部还会按 effect 创建顺序排序,保证父组件先于子组件更新,避免子组件用了过期 props 又重渲染一遍的浪费。上面最小实现里的 queueJob 就是这条链路的骨架,Vue 源码里的 scheduler 复杂得多,但思想一脉相承。
另一个值得了解的演进是 Vue 3.5 对响应式系统的重构:依赖的存储结构从单纯的 Set 换成了「版本计数 + 双向链表」,effect 与 dep 互相持有引用,配合属性级别的版本号做快速脏检查。对大数组高频更新、大量 computed 依赖同一个源数据的场景,无用遍历被大幅削减,这也是 3.5 之后大型列表页流畅度提升的底层原因。你不需要改任何业务代码,升级即受益——这正是把原理搞懂的价值:知道框架在替你做什么,才能在性能问题时判断瓶颈在框架层还是用法层。
computed 的脏检查:缓存不是免费的午餐
computed 的本质是「带脏标记的懒 effect」。依赖变化时它并不立即重算,只是把 dirty 标记翻回 true;等下一个读取方访问 .value 时才真正求值并缓存。这解释了两个反直觉现象:没有任何地方读取的 computed 永远不会计算;而读取方越多,computed 的缓存收益越大——它是依赖链上的一个「收敛点」,把 N 个下游对同一份派生数据的重复计算压成一次。
因此做状态设计时有一条朴素准则:被多处使用的派生数据优先收敛成 computed,而不是在各个地方重复写同样的过滤、排序逻辑。前者只算一次,后者每个副本都要独立跟踪依赖、独立重算。当 computed 依赖了非响应式数据(比如普通全局变量、函数参数),失效通知就断了——这也是「computed 不更新」类问题的通用病根:它的依赖收集依赖读取时机,读取没发生,依赖就不存在。
避坑总结
- 解构 reactive 对象必丢响应式。解构取出的是当刻的普通值,与代理断开连接;需要拆开传时用
toRefs/toRef保持连接。注意 Vue 3.5 起「props 解构」已支持响应式转换,但这是编译器对 props 的特例,普通 reactive 对象解构仍然会丢。 - 数组越界索引赋值可能不更新。
arr[100] = x超出当前 length 时,模板若没读取过该索引就没有依赖存在,自然不触发;批量填充大列表建议整体替换引用或用 splice 类方法,让 length 与索引的 ADD/SET 通知成对发生。 - watch 与 watchEffect 的执行时机要分清。默认
flush: 'pre'在组件更新前执行,回调里拿到的 DOM 还是旧的;需要读取更新后 DOM 时显式声明flush: 'post'。'sync'模式每次赋值同步触发,高频场景极易造成性能灾难,除非有明确理由否则不要用。 - 绕过代理的修改不会触发更新。
toRaw拿到的原始对象、被markRaw标记的对象、以及第三方库内部缓存的旧引用,改它们都不走 Proxy 拦截;把响应式对象存进非响应式容器(如 Map、class 字段)时要警惕引用脱管。 - 循环依赖会让 effect 无限自触发。effect A 写 B 读的属性、effect B 又写 A 读的属性时,会形成互相唤醒的死循环(Vue 会告警 Maximum recursive updates exceeded)。解法是把「读写分离」:派生逻辑收敛进 computed,让数据流保持单向。
写在最后
响应式系统的源码并不神秘:Proxy 拦截读写、WeakMap 存依赖、Set 去重、微任务批量调度、脏标记缓存派生值,五个组件拼出了 Vue 的核心魔法。把这条链路内化之后,大多数响应式疑难问题都能在读代码的第一分钟定位到「依赖断在哪一环」,而不是在页面里反复横跳地加日志。原理每多懂一分,排障就少绕一里路。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。