那些说熟练掌握Vue 3的能真的讲明白响应式吗?
Vue 3 响应式到底是什么水平:从“只会说 Proxy”到能把依赖图讲明白
写 Vue 3 写久了会发现一个很现实的现象:同样叫“懂响应式”,不同人脑子里的东西完全不是同一个层次。有人停留在“Vue3 用 Proxy 了”;有人能把依赖收集、触发更新、effect 栈、cleanup 讲清楚;再往上,能把那套数据结构为什么要用 WeakMap → Map → Set 解释得很顺,还能指出哪些地方是性能/边界的坑。
这篇按不同水平,给一套“应该能回答到的点”,再用一段接近源码的实现骨架把关键路径串起来。
0)先定一句话:Vue 3 响应式做的事
把“读取”和“写入”变成可观测的事件:
- 读取(get)的时候:记录“谁(effect)在读哪个对象的哪个 key”
- 写入(set/delete/add)的时候:找到“依赖这个 key 的 effect 集合”,把它们重新跑一遍(或交给调度器)
这两句话是主线,后面所有细节都在为它服务。
1)初级:别只说 Proxy,起码把“为什么 Proxy + 它解决了什么”讲完整
能说清楚的点
- Vue 3 用 Proxy 拦截对象的 get/set,从而实现依赖收集和触发更新。
- 对比 Vue 2:
- Vue 2 用
Object.defineProperty劫持属性,新增/删除属性有天然限制(需要Vue.set之类的补丁),数组索引也麻烦。 - Vue 3 的 Proxy 能拦截更多操作(
in、ownKeys、delete 等),新增/删除属性也能被捕获。
- Vue 2 用
别说得太虚
“Proxy 更强大所以 Vue3 更快”这种容易被追问。更稳的说法是:
- Proxy 让拦截的覆盖面更完整,减少补丁式 API;
- 真正的性能来自按需追踪和更精细的触发策略(后面中高级部分)。
2)中级:讲得出“依赖收集/触发更新”是一条链,知道 activeEffect 是啥
到这个层次,至少能把下面这条链串起来:
reactive(obj)返回一个 Proxy- 组件渲染 / computed / watch 本质都会创建 effect(可以理解为“带依赖的函数”)
- effect 执行时把自己挂成
activeEffect - Proxy
get触发track(target, key) - Proxy
set触发trigger(target, key) - trigger 找到依赖集合,把 effect 重新执行(或 scheduler 调度)
依赖收集:track
“谁依赖谁”的信息要存起来。Vue 3 的核心结构非常经典:
targetMap: WeakMap<object, Map<key, Dep>>Dep通常是Set<ReactiveEffect>
也就是:
- 第一层 WeakMap:按“目标对象 target”分桶(避免内存泄漏,target 没引用了能 GC)
- 第二层 Map:按“属性 key”分桶(同对象不同 key 的依赖分开)
- 第三层 Set:同一个 key 被多个 effect 依赖,去重 + 可迭代触发
触发更新:trigger
set/delete/add 时,通过 target+key 找到 Dep(Set),遍历其中的 effect:
- 如果 effect 有 scheduler,交给 scheduler(比如组件更新进队列、computed 标记脏)
- 否则直接
effect.run()
3)中高级分水岭:为什么要“双向记录 + cleanup”,否则会脏依赖
很多“能讲依赖收集”的人,到了这里就开始含糊:为什么 effect 要 cleanup?
因为依赖关系不是静态的。一个 effect 里经常有分支:
effect(() => {
if (state.flag) {
state.a
} else {
state.b
}
})
第一次 flag=true,收集了 a;如果后面 flag=false,这次执行应该收集 b,并且把之前对 a 的依赖移除,否则:
a变了也会触发 effect(明明现在不读 a 了)- 依赖集合越来越大,性能和语义都脏
所以 Vue 3 的实现里,通常会让 effect 持有一个 deps: Dep[] 列表,做到双向引用:
- Dep(Set)里有很多 effect
- effect.deps 里也记录自己加入过哪些 Dep
每次 effect.run 前先 cleanup:从所有 Dep 中把自己删除,清空 deps,再重新收集。
4)能“看懂源码”的水平:effect 包装、栈、WeakMap→Map→Dep、调度器
下面是一段“接近 Vue 3 实现骨架”的伪源码(变量名和结构尽量贴近真实实现,但我做了删减,便于读主线)。
4.1 依赖容器:WeakMap → Map → Dep(Set)
type Key = string | symbol
type Dep = Set<ReactiveEffect>
const targetMap = new WeakMap<object, Map<Key, Dep>>()
let activeEffect: ReactiveEffect | undefined
const effectStack: ReactiveEffect[] = []
4.2 effect:run / cleanup / scheduler
class ReactiveEffect {
fn: Function
scheduler?: () => void
deps: Dep[] = []
active = true
constructor(fn: Function, scheduler?: () => void) {
this.fn = fn
this.scheduler = scheduler
}
run() {
if (!this.active) return this.fn()
cleanupEffect(this)
try {
effectStack.push(this)
activeEffect = this
return this.fn()
} finally {
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
}
stop() {
if (this.active) {
cleanupEffect(this)
this.active = false
}
}
}
function cleanupEffect(effect: ReactiveEffect) {
for (const dep of effect.deps) dep.delete(effect)
effect.deps.length = 0
}
这里有三个关键点:
- effectStack:嵌套 effect 的时候,activeEffect 必须正确恢复(computed 里跑 effect、watch 里跑 effect 都会嵌套)
- cleanupEffect:解决分支依赖
- scheduler:把“立即执行”变成“可调度”(组件更新批处理、computed 懒执行)
4.3 track:get 时收集
function track(target: object, key: Key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
// 双向记录
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
4.4 trigger:set/delete/add 时触发
function trigger(target: object, key: Key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (!dep) return
// 拷贝一份,避免触发过程中 dep 结构变化
const effects = new Set(dep)
effects.forEach((effect) => {
if (effect.scheduler) effect.scheduler()
else effect.run()
})
}
4.5 reactive:Proxy 入口(只看主线 get/set)
function reactive<T extends object>(raw: T): T {
return new Proxy(raw, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key as any)
return typeof res === "object" && res !== null ? reactive(res) : res
},
set(target, key, value, receiver) {
const old = (target as any)[key]
const result = Reflect.set(target, key, value, receiver)
if (old !== value) trigger(target, key as any)
return result
},
})
}
真实 Vue 3 会更复杂:要区分新增属性/修改属性、数组 length、Map/Set 的迭代 key、readonly/shallow、ref 解包、toRaw/markRaw 等等。但主线就是这几步。
5)再往上:computed / watch 为什么“不只是 effect.run()”
computed 的本质:一个带缓存的 effect
computed 之所以“只在依赖变了才重新算”,靠的是:
- effect 不直接触发重新执行,而是 scheduler 把 computed 标记为 dirty
- 下一次读取 computed.value 时,如果 dirty 才执行 effect.run 重新计算
这就是 scheduler 存在的价值:把 trigger 的动作从“重跑”改成“标脏 + 延迟重跑”。
watch 的本质:一个 effect + 对比 + 回调
watch 关心的是“值变化”而不是“渲染”,所以会:
- effect.run 得到新值
- 和旧值比较
- 触发回调,并处理 cleanup(onInvalidate)
6)容易被忽略的工程细节(能说出来就很加分)
- 为什么 WeakMap 放最外层:target 没人引用了,依赖表不应该阻止 GC,否则长期页面会泄漏。
- 为什么 Set 要拷贝再遍历:触发过程中 effect 可能会 cleanup / 重新 track,直接遍历原 Set 容易边遍历边变结构。
- 为什么要暂停追踪/恢复追踪:某些内部读取不应收集依赖(例如数组方法内部访问 length),否则会造成多余依赖和死循环风险。
- 数组、Map/Set、for...in/ownKeys 的特殊 track key:迭代本身也需要依赖(比如 length 变化会影响遍历结果)。
- ref 与 reactive 的关系:ref 通过 getter/setter 在
.value上做 track/trigger;在模板/代理里会做解包(unwrapping)。
7)面试式总结(但不做问答腔):不同级别一句话能看出差距
- 初级:能说明“Vue 3 用 Proxy 拦截 get/set,解决 Vue 2 defineProperty 的新增/删除问题”
- 中级:能把“track/trigger + activeEffect + 依赖集合”串起来,并解释依赖图是怎么存的
- 高级:能讲清楚 WeakMap→Map→Set 的原因、cleanup 的必要性、effect 栈、scheduler 的意义,以及 computed/watch 的差异
评论
登录后即可发表评论