网上大部分 Vue 3 响应式文章还停在 3.2 / 3.3 的版本——那时依赖是 Set<ReactiveEffect>,触发是「收集起来挨个跑」。但从 Vue 3.5 开始,整套依赖存储被重写成了 Dep + Link 双向链表 + 版本号 的结构(内存占用降低约 40%,大数组响应式性能提升数倍)。这篇按 3.5.x 的真实源码逐层拆开讲。
targetMap → depsMap → Dep)是读路径与写路径唯一的共享状态,其余全围绕它打转。01全景:四层结构与一次完整往返
Vue 的响应式系统可以清晰地切成四层。理解这张图,后面的细节就都有了坐标系:
对应的源码文件分工也非常干净:
| 文件 | 职责 |
|---|---|
reactive.ts | 创建代理:reactive / readonly / shallow* |
baseHandlers.ts | Object / Array 的 get / set / deleteProperty / has / ownKeys 陷阱 |
collectionHandlers.ts | Map / Set / WeakMap / WeakSet 的陷阱 |
arrayInstrumentations.ts | 数组方法的改写(includes / push / splice…) |
dep.ts | Dep、Link、targetMap、track()、trigger() |
effect.ts | ReactiveEffect、批处理、refreshComputed、版本号判断 |
computed.ts | 三种面向用户的 API(含 ref.ts / watch.ts) |
effectScope.ts | 作用域与批量停止 |
02第一层:把对象变「活」——reactive() 的五道闸门
reactive() 本体只有一行转发,真正的逻辑在 createReactiveObject()。 它在 new Proxy 之前排了五道闸门,能不创建就不创建:
1function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
2 // 闸门 1:不是对象,原样返回
3 if (!isObject(target)) return target
4
5 // 闸门 2:已经是代理了,直接返回(例外:允许对 reactive 对象再包 readonly)
6 if (target[ReactiveFlags.RAW] && !(isReadonly && target[ReactiveFlags.IS_REACTIVE])) {
7 return target
8 }
9
10 // 闸门 3:被 markRaw 标记过,或对象不可扩展
11 if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) return target
12
13 // 闸门 4:同一个原始对象,复用同一个代理(幂等性)
14 const existingProxy = proxyMap.get(target)
15 if (existingProxy) return existingProxy
16
17 // 闸门 5:只支持 Object / Array / Map / Set / WeakMap / WeakSet
18 const targetType = targetTypeMap(toRawType(target))
19 if (targetType === TargetType.INVALID) return target
20
21 const proxy = new Proxy(
22 target,
23 targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
24 )
25 proxyMap.set(target, proxy)
26 return proxy
27}
两个值得记住的设计点
① 缓存用 WeakMap 而不是 Map。四个 reactiveMap / shallowReactiveMap / readonlyMap / shallowReadonlyMap 全是 WeakMap<Target, Proxy>。这既保证了「同一个原始对象永远拿到同一个代理」(reactive(obj) === reactive(obj)),又不会阻止原始对象被 GC。
② 为什么是 Proxy 而不是 Object.defineProperty。Vue 2 时代三个硬伤,Proxy 一次解决:
- 新增属性监听不到(要
$set)→ Proxy 的set天然覆盖; - 数组下标赋值 /
length修改监听不到 → Proxy 同样能拦; - 初始化要递归遍历整个对象把每个 key 都
defineProperty→ Proxy 是惰性的,只有真正访问到的嵌套对象才会被二次代理。
03第二层:读的世界——get 拦截链路
读操作是响应式的起点:谁在读、读了什么——这些信息必须在这一刻被记下来。
精简后的核心代码:
1get(target, key, receiver) {
2 // ① 内部标记位短路:isReactive() / toRaw() 就靠这里返回
3 if (key === ReactiveFlags.IS_REACTIVE) return !this._isReadonly
4 if (key === ReactiveFlags.RAW) return target
5
6 const targetIsArray = isArray(target)
7 if (!this._isReadonly) {
8 // ② 数组方法改写(见 §13)
9 if (targetIsArray && arrayInstrumentations[key]) return arrayInstrumentations[key]
10 if (key === 'hasOwnProperty') return hasOwnProperty
11 }
12
13 // ③ 关键:receiver 的处理,避免 ref 作为 target 时方法内 this 被二次代理
14 const res = Reflect.get(target, key, isRef(target) ? target : receiver)
15
16 // ④ 内置 Symbol、__proto__、__v_isRef 等不参与追踪
17 if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) return res
18
19 if (!this._isReadonly) track(target, TrackOpTypes.GET, key) // ← 依赖收集
20
21 if (this._isShallow) return res
22
23 // ⑤ ref 自动解包(数组 + 数字下标除外)
24 if (isRef(res)) return targetIsArray && isIntegerKey(key) ? res : res.value
25
26 // ⑥ 惰性深度代理:只有访问到、且是对象,才递归创建代理
27 if (isObject(res)) return this._isReadonly ? readonly(res) : reactive(res)
28
29 return res
30}
不是初始化时递归,而是访问时递归。reactive({ a: { b: 1 } }) 只创建一层代理;只有当你写下 obj.a 时,a 的代理才在 get 的第 ⑥ 步被创建并缓存。 这就是 Proxy 相对 defineProperty 最大的性能优势。
04第二层:写的世界——set 拦截链路
1set(target, key, value, receiver) {
2 let oldValue = target[key]
3 if (!this._isShallow) {
4 if (!isShallow(value) && !isReadonly(value)) {
5 oldValue = toRaw(oldValue)
6 value = toRaw(value) // 存原始值,避免把代理存进去
7 }
8 // 对 ref 赋值时直接穿透到 .value,并且不 trigger 外层(ref 自己会 trigger)
9 if (isRef(oldValue) && !isRef(value)) { oldValue.value = value; return true }
10 }
11
12 const hadKey = isArray(target) && isIntegerKey(key)
13 ? Number(key) < target.length
14 : hasOwn(target, key)
15
16 const result = Reflect.set(target, key, value, isRef(target) ? target : receiver)
17
18 // 只有写在这个代理「自己的」target 上才触发,防止原型链上的重复触发
19 if (target === toRaw(receiver) && result) {
20 if (!hadKey) {
21 trigger(target, TriggerOpTypes.ADD, key, value)
22 } else if (hasChanged(value, oldValue)) {
23 trigger(target, TriggerOpTypes.SET, key, value, oldValue)
24 }
25 }
26 return result
27}
其余三个陷阱
| 陷阱 | 行为 |
|---|---|
deleteProperty | 删成功了才 trigger(target, DELETE, key, undefined, oldValue) |
has | track(target, HAS, key)——所以 v-if="’a’ in obj" 也能被追踪 |
ownKeys | track(target, ITERATE, ITERATE_KEY)(数组则是 length)——所以 v-for="k in obj" 增删 key 会更新 |
05第三层:依赖怎么存——targetMap 三层结构
依赖关系的总仓库就一个全局变量:
1type KeyToDepMap = Map<any, Dep>
2export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()
3
4export function track(target, type, key) {
5 if (shouldTrack && activeSub) { // 没有正在运行的 effect,收集了也没用
6 let depsMap = targetMap.get(target)
7 if (!depsMap) targetMap.set(target, (depsMap = new Map()))
8 let dep = depsMap.get(key)
9 if (!dep) {
10 depsMap.set(key, (dep = new Dep()))
11 dep.map = depsMap // 反查用,方便回收(见 §13)
12 dep.key = key
13 }
14 dep.track()
15 }
16}
06核心重构:从 Set 到 Dep + Link 双向链表
这是 Vue 3.5 最重要的一次内部重构(PR #10397)。
3.4 及以前
1// 旧实现:一个 Dep 就是一个装 effect 的 Set
2const dep = new Set(effects)
3dep.w = 0 // was:上一次 traverse 时访问过
4dep.n = 0 // new:本次 traverse 时访问过
问题:每次 effect 重跑都要 dep.delete(effect) 再 dep.add(effect), Set 的增删有哈希开销;依赖列表顺序会被打乱;内存里同时存在大量 Set 实例。
3.5:Dep + Link 双向链表
1export class Link {
2 version // 建立链接时 Dep 的版本号;-1 表示「本轮还没被访问」
3 nextDep / prevDep // 挂在「订阅者的依赖链」上
4 nextSub / prevSub // 挂在「Dep 的订阅链」上
5 prevActiveLink
6 constructor(sub, dep) { this.sub = sub; this.dep = dep; this.version = dep.version }
7}
8
9export class Dep {
10 version = 0 // 每次 trigger 时 ++
11 activeLink // 当前 activeSub 与自己的链接(快速复用)
12 subs // 订阅链「尾」节点,沿 prevSub 往前遍历
13 subsHead // 订阅链「头」节点(DEV 下用于按序调用 onTrigger)
14 map / key // 反查自己在 targetMap 里的位置,用于回收
15 sc // subscriber counter,归零时从 map 中删除自己
16 constructor(computed) { this.computed = computed }
17}
一个 Link 代表「一个 Dep 与一个订阅者之间的一条边」,它同时挂在两条双向链表上:
track 时如何复用 Link
1track() {
2 if (!activeSub || !shouldTrack || activeSub === this.computed) return
3
4 let link = this.activeLink
5 if (link === undefined || link.sub !== activeSub) {
6 // 情况 A:第一次建立链接 —— new Link 并挂到两条链尾
7 link = this.activeLink = new Link(activeSub, this)
8 if (!activeSub.deps) activeSub.deps = activeSub.depsTail = link
9 else {
10 link.prevDep = activeSub.depsTail
11 activeSub.depsTail.nextDep = link
12 activeSub.depsTail = link
13 }
14 addSub(link)
15 } else if (link.version === -1) {
16 // 情况 B:上一轮就建过链 —— 直接把版本号同步回来即可
17 link.version = this.version
18 if (link.nextDep) {
19 // 不在尾部 → 移到尾部,保证 deps 链的顺序 == 代码里的访问顺序
20 // (省略指针搬运代码)
21 }
22 }
23 return link
24}
因为 deps 链表的顺序必须和 fn 中的实际访问顺序一致。computed 求值、isDirty() 判断都依赖这个顺序(比如短路求值:a && b 中 b 未被访问时,b 的变化不应触发)。保持顺序 = 保持语义。
07第四层:订阅者 ReactiveEffect 的一生
ReactiveEffect 是一次「响应式执行」的载体。组件的渲染函数、watchEffect、watch 的 getter,底层都是它。
1export class ReactiveEffect {
2 deps / depsTail // 依赖链的头 / 尾
3 flags // ACTIVE | RUNNING | TRACKING | NOTIFIED | DIRTY | ALLOW_RECURSE | PAUSED
4 scheduler // 自定义调度器(组件的渲染、watch 的 job 都在这里)
5 cleanup // onEffectCleanup 注册的清理函数(类比 React 的 cleanup)
6
7 run() {
8 if (!(this.flags & EffectFlags.ACTIVE)) return this.fn() // 已 stop:跑一次但不再收集
9
10 this.flags |= EffectFlags.RUNNING
11 cleanupEffect(this) // ① 执行上一轮注册的 cleanup
12 prepareDeps(this) // ② 所有 Link.version = -1(打上「待验证」标记)
13
14 const prevEffect = activeSub, prevShouldTrack = shouldTrack
15 activeSub = this // ③ 把自己设为「当前正在运行的订阅者」
16 shouldTrack = true
17 try {
18 return this.fn() // ④ 执行,期间每次读响应式数据都会 track()
19 } finally {
20 cleanupDeps(this) // ⑤ 摘掉 version 仍为 -1 的 Link
21 activeSub = prevEffect
22 shouldTrack = prevShouldTrack
23 this.flags &= ~EffectFlags.RUNNING
24 }
25 }
26}
分支切换:依赖清理的经典场景
1const flag = ref(true)
2const a = ref(1), b = ref(2)
3effect(() => { console.log(flag.value ? a.value : b.value) })
4
5flag.value = false // 此时 b 才被订阅
6a.value = 10 // 期望:不打印。如果没有清理机制,这里会误触发
08版本号机制:从「通知」到「询问」
Vue 3.5 另一个关键变化:trigger 时不再立刻执行 effect,而只是把版本号 +1。 真正要不要重跑,等到调度时再「询问」。
1// dep.ts —— 三个版本号
2export let globalVersion = 0 // 全局:任何一次响应式变更都会 ++
3class Dep { version = 0 } // 单个依赖:自己被 trigger 时 ++
4class Link { version } // 某次收集时「快照」下来的 dep.version
5
6trigger() {
7 this.version++ // 我这个 key 变了
8 globalVersion++ // 全局也变了(给 computed 做快路径)
9 this.notify()
10}
11
12// effect.ts —— 询问:我到底脏了吗?
13function isDirty(sub) {
14 for (let link = sub.deps; link; link = link.nextDep) {
15 if (
16 link.dep.version !== link.version || // 直接依赖变了
17 (link.dep.computed && // 或者依赖的 computed 需要重算
18 (refreshComputed(link.dep.computed) || link.dep.version !== link.version))
19 ) return true
20 }
21 return false
22}
这个设计的价值在于:同一个 tick 内改 10 次 obj.count,effect 只会被调度一次;而且真正执行前还会再问一次「我真的脏了吗」。配合下面的批处理,能省掉大量无效渲染。
09批处理:startBatch / endBatch 与调度器
1let batchDepth = 0
2let batchedSub, batchedComputed
3
4export function batch(sub, isComputed = false) {
5 sub.flags |= EffectFlags.NOTIFIED // 去重标记
6 sub.next = isComputed ? batchedComputed : batchedSub
7 if (isComputed) batchedComputed = sub; else batchedSub = sub
8}
9
10export function endBatch() {
11 if (--batchDepth > 0) return // 还在嵌套批次里,等最外层
12
13 let error
14 while (batchedSub) {
15 let e = batchedSub; batchedSub = undefined
16 while (e) {
17 const next = e.next
18 e.next = undefined
19 e.flags &= ~EffectFlags.NOTIFIED
20 if (e.flags & EffectFlags.ACTIVE) {
21 try { e.trigger() } catch (err) { if (!error) error = err }
22 }
23 e = next
24 }
25 }
26 if (error) throw error // 保证一个 effect 抛错不打断其它 effect
27}
10computed:既是订阅者,也是发布者
computed 是整个设计里最优雅的一块——ComputedRefImpl 同时实现了 Subscriber 接口并拥有自己的 Dep,所以它能「被别人订阅」也能「订阅别人」。
1get value() {
2 const link = this.dep.track() // ① 作为发布者:让外层 effect 订阅我
3 refreshComputed(this) // ② 作为订阅者:检查我是否脏了,脏了就重算
4 if (link) link.version = this.dep.version // 同步版本,避免刚算完就被判脏
5 return this._value
6}
7
8notify() {
9 this.flags |= EffectFlags.DIRTY // 只打脏标记,不立刻算
10 if (!(this.flags & EffectFlags.NOTIFIED) && activeSub !== this) {
11 batch(this, true)
12 return true // 返回 true:调用方会继续通知「我自己的 dep」,把脏标记往下游传
13 }
14}
refreshComputed 的三级短路是 computed 性能的精髓:
1export function refreshComputed(computed) {
2 if (computed.flags & TRACKING && !(computed.flags & DIRTY)) return // 短路 1
3 computed.flags &= ~DIRTY
4
5 if (computed.globalVersion === globalVersion) return // 短路 2:全局没变过
6 computed.globalVersion = globalVersion
7
8 if (dep.version > 0 && !computed.isSSR && computed.deps && !isDirty(computed)) {
9 return // 短路 3:我的依赖都没变
10 }
11 // …真正执行 getter…
12 const value = computed.fn(computed._value)
13 if (dep.version === 0 || hasChanged(value, computed._value)) {
14 computed._value = value
15 dep.version++ // 只有真的变了才推高版本,下游才会被判脏
16 }
17}
11ref:只有一个 key 的 Dep
ref 的本质是「一个自带 Dep 的对象」——它不需要 Proxy,因为要追踪的 key 永远只有一个:value。
1class RefImpl {
2 _value
3 _rawValue
4 dep = new Dep() // 整个 ref 只有一个 Dep,没有 targetMap 那一层
5
6 constructor(value, isShallow) {
7 this._rawValue = isShallow ? value : toRaw(value)
8 this._value = isShallow ? value : toReactive(value)
9 }
10
11 get value() {
12 this.dep.track()
13 return this._value
14 }
15
16 set value(newValue) {
17 const useDirectValue = this[IS_SHALLOW] || isShallow(newValue) || isReadonly(newValue)
18 newValue = useDirectValue ? newValue : toRaw(newValue)
19 if (hasChanged(newValue, this._rawValue)) { // 和 _rawValue 比,避免代理对象干扰
20 this._rawValue = newValue
21 this._value = useDirectValue ? newValue : toReactive(newValue)
22 this.dep.trigger()
23 }
24 }
25}
26
27export const toReactive = value => isObject(value) ? reactive(value) : value
于是 ref 与 reactive 的关系就非常清楚了:
ref(1)→RefImpl,Dep 直接挂在实例上;ref({ a: 1 })→RefImpl._value = reactive({ a: 1 }), 所以r.value.a也能被深层追踪;reactive({ r: ref(1) })→ reactive 的 get 陷阱会做 ref 解包,obj.r直接拿到1。
12watch:把 effect 包装成一个「任务」
watch 的核心在 watch.ts。它做两件事:把五花八门的 source 归一化成一个 getter,然后把这个 getter 包成一个带 oldValue 语义的 job。
1const job = (immediateFirstRun) => {
2 if (!(effect.flags & EffectFlags.ACTIVE) || (!effect.dirty && !immediateFirstRun)) return
3
4 if (cb) {
5 const newValue = effect.run()
6 if (immediateFirstRun || deep || forceTrigger ||
7 (isMultiSource
8 ? newValue.some((v, i) => hasChanged(v, oldValue[i]))
9 : hasChanged(newValue, oldValue))) {
10 if (cleanup) cleanup() // 先跑上一轮的 onCleanup
11 activeWatcher = effect
12 try {
13 cb(newValue, oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue, boundCleanup)
14 oldValue = newValue
15 } finally { activeWatcher = currentWatcher }
16 }
17 } else {
18 effect.run() // watchEffect:没有 cb,直接跑
19 }
20}
21
22effect = new ReactiveEffect(getter)
23effect.scheduler = scheduler ? () => scheduler(job, false) : job
几个容易踩坑的点
deep的实现就是traverse:把对象所有属性「读一遍」,从而把每个 key 都变成依赖。3.5 起deep可以传数字(deep: 2),限制递归层数,避免大对象深度遍历的性能问题。- 监听 reactive 对象时
oldValue === newValue:因为 getter 返回的是同一个代理对象引用。源码里靠forceTrigger = true强行让回调执行。 flush: 'post'才能拿到更新后的 DOM:pre(默认)在组件更新前执行,post在之后,sync同步执行(慎用,会破坏批处理)。onWatcherCleanup依赖activeWatcher:和onEffectCleanup用的是同一套思路——回调执行期间把当前 effect 挂到全局变量上, 让内部能「找到回家的路」。
13工程细节:数组 / 集合 / 内存回收
数组为什么需要特殊改写
数组有三个「读操作自带陷阱」的方法:includes / indexOf / lastIndexOf。 它们会遍历数组,如果不加处理:
1const obj = reactive({})
2const arr = reactive([obj])
3watchEffect(() => arr.includes(obj)) // obj 是原始对象,arr[0] 是代理
4// 若不改写,拿代理去 includes 原始对象会 false,且依赖收集不完整
Vue 的做法:先按原参数查一次,查不到再把参数 toRaw 后查一次;同时对整个数组做 track(ARRAY_ITERATE_KEY)。
另外 push / pop / shift / unshift / splice 会被临时 pauseTracking()——因为这些方法的内部实现会同时读写 length, 不暂停会造成「自己触发自己」的无限递归。
集合类型(Map / Set)
用 collectionHandlers 单独一套陷阱,额外引入两个虚拟 key:
ITERATE_KEY——size、forEach、values()等迭代操作;MAP_KEY_ITERATE_KEY—— 只有keys()用,因为 Map 的set(值变化但 key 不变)不应影响keys()。
内存回收:Dep 自己会「注销」
1function removeSub(link, soft = false) {
2 // …从订阅链上摘掉…
3 if (!soft && !--dep.sc && dep.map) {
4 dep.map.delete(dep.key) // 没有任何订阅者了 → 从 depsMap 里删掉自己
5 }
6}
Dep.sc 是订阅计数。当一个 key 再也没人关心时,它会被从 depsMap 中删除,避免长期存活的大对象上堆积成千上万个空 Dep(issue #11979)。
14动手:80 行实现一个 mini 响应式内核
把上面所有机制抽掉 Dev 分支、集合类型、shallow / readonly 之后,核心其实就这么多:
1// mini-reactive.js —— 还原 Vue 3.5 的 Dep + Link + 版本号内核
2let activeSub = null
3const targetMap = new WeakMap()
4
5class Dep {
6 version = 0
7 subs = undefined // 订阅链「尾」
8 activeLink = undefined
9
10 track() {
11 if (!activeSub) return
12 let link = this.activeLink
13 if (link === undefined || link.sub !== activeSub) {
14 link = this.activeLink = {
15 sub: activeSub, dep: this, version: this.version,
16 prevDep: null, nextDep: null, prevSub: null, nextSub: null,
17 }
18 // 挂到「订阅者的依赖链」尾部
19 if (!activeSub.deps) activeSub.deps = activeSub.depsTail = link
20 else { link.prevDep = activeSub.depsTail; activeSub.depsTail.nextDep = link; activeSub.depsTail = link }
21 // 挂到「Dep 的订阅链」尾部
22 link.prevSub = this.subs
23 if (this.subs) this.subs.nextSub = link
24 this.subs = link
25 } else if (link.version === -1) {
26 link.version = this.version // 复用上一轮的链接
27 }
28 }
29
30 trigger() {
31 this.version++
32 for (let l = this.subs; l; l = l.prevSub) l.sub.notify()
33 }
34}
35
36function unlink(link) {
37 const { dep, prevSub, nextSub, prevDep, nextDep } = link
38 if (prevSub) prevSub.nextSub = nextSub; else dep.subs = nextSub
39 if (nextSub) nextSub.prevSub = prevSub
40 if (prevDep) prevDep.nextDep = nextDep; else link.sub.deps = nextDep
41 if (nextDep) nextDep.prevDep = prevDep
42}
43
44class ReactiveEffect {
45 deps = undefined; depsTail = undefined; scheduler = undefined
46 constructor(fn) { this.fn = fn }
47
48 run() {
49 const prev = activeSub
50 activeSub = this
51 for (let l = this.deps; l; l = l.nextDep) l.version = -1 // prepareDeps
52 try {
53 return this.fn()
54 } finally {
55 let head, tail = this.depsTail, l = tail // cleanupDeps
56 while (l) {
57 const p = l.prevDep
58 if (l.version === -1) { unlink(l); if (l === tail) tail = p }
59 else head = l
60 l = p
61 }
62 this.deps = head; this.depsTail = tail
63 activeSub = prev
64 }
65 }
66
67 notify() {
68 if (this.scheduler) this.scheduler()
69 else if (this.isDirty()) this.run()
70 }
71
72 isDirty() {
73 for (let l = this.deps; l; l = l.nextDep) if (l.dep.version !== l.version) return true
74 return false
75 }
76}
77
78function reactive(target) {
79 return new Proxy(target, {
80 get(t, k, r) {
81 const res = Reflect.get(t, k, r)
82 track(t, k)
83 return res && typeof res === 'object' ? reactive(res) : res // 惰性深度代理
84 },
85 set(t, k, v, r) {
86 const had = Object.prototype.hasOwnProperty.call(t, k)
87 const old = t[k]
88 const ok = Reflect.set(t, k, v, r)
89 if (ok && (!had || !Object.is(v, old))) trigger(t, k)
90 return ok
91 },
92 })
93}
94
95function track(t, k) {
96 if (!activeSub) return
97 let m = targetMap.get(t)
98 if (!m) targetMap.set(t, m = new Map())
99 let dep = m.get(k)
100 if (!dep) m.set(k, dep = new Dep())
101 dep.track()
102}
103
104function trigger(t, k) {
105 const dep = targetMap.get(t)?.get(k)
106 if (dep) dep.trigger()
107}
跑一下验证分支切换:
1const state = reactive({ flag: true, a: 1, b: 2 })
2const e = new ReactiveEffect(() => console.log(state.flag ? state.a : state.b))
3e.run() // 打印 1,此时依赖 = [flag, a]
4state.a = 10 // 打印 10
5state.flag = false // 打印 2,依赖变为 [flag, b]
6state.a = 99 // 无输出 ✓ —— a 的 Link 已被 cleanupDeps 摘除
15面试高频追问速答
Q1:Vue 3 为什么用 Proxy 替代 defineProperty?
三点:能拦截新增/删除属性;能拦截数组下标与 length;无需初始化时递归遍历(惰性代理)。 代价是不兼容 IE11。
Q2:reactive 和 ref 的依赖存储有什么不同?
reactive 走 targetMap → depsMap → Dep 三层,一个 key 一个 Dep;ref 直接在实例上挂一个 Dep,key 恒为 value。ref(对象) 会再通过 toReactive 转成 reactive,所以深层依然可追踪。
Q3:3.5 为什么把 Set 换成双向链表?
省内存(少一层 Set 实例 + 少一次哈希)、依赖清理从「删除再插入」变成 O(1) 改指针、天然保持访问顺序 (computed 短路语义依赖顺序)、配合版本号可以做跳过式判断。官方数据:内存降约 40%,大数组场景快数倍。
Q4:为什么会「误触发」?Vue 怎么避免?
分支切换(a ? x : y)会让上一次的依赖失效。Vue 用 prepareDeps(version = -1)+ cleanupDeps(摘掉仍为 -1 的 Link) 在每轮 run 结束时做一次依赖裁剪。
Q5:同一个 tick 改 10 次,effect 会跑 10 次吗?
不会。trigger 只做 version++ + batch() 入队;NOTIFIED 标记去重;endBatch() 时才统一 trigger(); 而且执行前还会用 isDirty() 再确认一次。渲染侧再叠一层 queueJob + nextTick 微任务。
Q6:computed 是惰性的吗?
是,而且有两层惰性:没人订阅时 flags 没有 TRACKING,getter 根本不跑; 有人订阅后,refreshComputed 还有三级短路(DIRTY / globalVersion / isDirty)。 只有值真的通过 hasChanged,才会 dep.version++ 通知下游。
Q7:为什么 watch 一个 reactive 对象时 newValue 和 oldValue 一样?
getter 返回的是同一个代理对象引用。源码用 forceTrigger = true 绕过 hasChanged 判断。想拿到不同的旧值,请监听 getter 函数并用 deep, 或者对值做一次浅拷贝。
参考源码(vuejs/core,@vue/reactivity 3.5.x):reactive.ts · baseHandlers.ts · collectionHandlers.ts · arrayInstrumentations.ts · dep.ts · effect.ts · computed.ts · ref.ts · watch.ts · effectScope.ts
本文所有代码片段均按真实源码精简(去掉 __DEV__ 分支与类型标注),变量名与执行顺序与源码保持一致。
