LOG // 048 · Vue 3 · Reactivity

Vue 3 响应式系统源码实现分析

写在前面

网上大部分 Vue 3 响应式文章还停在 3.2 / 3.3 的版本——那时依赖是 Set<ReactiveEffect>,触发是「收集起来挨个跑」。但从 Vue 3.5 开始,整套依赖存储被重写成了 Dep + Link 双向链表 + 版本号 的结构(内存占用降低约 40%,大数组响应式性能提升数倍)。这篇按 3.5.x 的真实源码逐层拆开讲。

结论先行整套系统只有一条主线索:读的时候登记依赖,写的时候抬高版本号。中间那张依赖表 (targetMap → depsMap → Dep)是读路径与写路径唯一的共享状态,其余全围绕它打转。

01全景:四层结构与一次完整往返

Vue 的响应式系统可以清晰地切成四层。理解这张图,后面的细节就都有了坐标系:

① 数据入口reactive / ref / computed② 拦截层Proxy get / set③ 依赖表targetMap → Dep④ 订阅者ReactiveEffect⑤ 调度层batch / schedulergettrack()Linktrigger()notifyre-run fn
图 1:一次完整的响应式往返。**读路径**(上排):数据被读 → Proxy 的 `get` 拦截 → `track()` 写入依赖表 → 用 `Link` 把 Dep 和订阅者连起来。**写路径**(下排):数据被写 → `set` 拦截 → `trigger()` → 订阅者入队 → 调度层批量重跑再回到数据层。两条路径共用同一份依赖表,这也是整个系统唯一的共享状态。

对应的源码文件分工也非常干净:

文件职责
reactive.ts创建代理:reactive / readonly / shallow*
baseHandlers.tsObject / Array 的 get / set / deleteProperty / has / ownKeys 陷阱
collectionHandlers.tsMap / Set / WeakMap / WeakSet 的陷阱
arrayInstrumentations.ts数组方法的改写(includes / push / splice…)
dep.tsDep、Link、targetMap、track()、trigger()
effect.tsReactiveEffect、批处理、refreshComputed、版本号判断
computed.ts三种面向用户的 API(含 ref.ts / watch.ts)
effectScope.ts作用域与批量停止

02第一层:把对象变「活」——reactive() 的五道闸门

reactive() 本体只有一行转发,真正的逻辑在 createReactiveObject()。 它在 new Proxy 之前排了五道闸门,能不创建就不创建:

packages/reactivity/src/reactive.ts(精简)ts
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}
reactive(target)原样返回 target原样返回 target复用已有代理INVALID → 原样返回new Proxy + proxyMap.set① 是对象吗?② 已代理 / SKIP / 不可扩展?③ proxyMap 有缓存吗?④ targetType 支持吗?全部放行
图 2:createReactiveObject 的决策链。四道提前返回 + 一道类型校验,全部走完之后才真正创建 Proxy。

两个值得记住的设计点

① 缓存用 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 拦截链路

读操作是响应式的起点:谁在读、读了什么——这些信息必须在这一刻被记下来。

②③ 之间:res = Reflect.get(target, key, receiver)③④ 之间:track(target, GET, key)obj.count(读操作)返回标记值,不 track返回改写版本直接 return resreactive(res) 惰性代理return res① key 是内部标记?② 数组改写方法命中?③ key 不可追踪?④ res 是对象?是否:基本类型
图 3:`BaseReactiveHandler.get` 的完整分支。注意 `track()` 的位置——它在 `Reflect.get` 之后,因为要先拿到真实值,才能决定要不要惰性代理。

精简后的核心代码:

packages/reactivity/src/baseHandlers.ts(精简)ts
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}
面试常问:Vue 3 的深度响应式是递归的吗?

不是初始化时递归,而是访问时递归。reactive({ a: { b: 1 } }) 只创建一层代理;只有当你写下 obj.a 时,a 的代理才在 get 的第 ⑥ 步被创建并缓存。 这就是 Proxy 相对 defineProperty 最大的性能优势。

04第二层:写的世界——set 拦截链路

①② 之间:toRaw 取值 + Reflect.setobj.count = 2(写操作)oldValue.value = v原型链写入,不触发trigger(ADD)trigger(SET)return result① 旧值 ref、新值非 ref?② target === toRaw(receiver)?③ hadKey 为假(新增)?④ hasChanged?是否是是否:不触发
图 4:`MutableReactiveHandler.set`。写路径比读路径多了两个关键判断:`hadKey`(区分 ADD / SET)与 `hasChanged`(值是否真的变了)。
packages/reactivity/src/baseHandlers.ts(精简)ts
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)
hastrack(target, HAS, key)——所以 v-if="’a’ in obj" 也能被追踪
ownKeystrack(target, ITERATE, ITERATE_KEY)(数组则是 length)——所以 v-for="k in obj" 增删 key 会更新

05第三层:依赖怎么存——targetMap 三层结构

依赖关系的总仓库就一个全局变量:

packages/reactivity/src/dep.ts(精简)ts
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}
targetMap(WeakMap)key = 原始对象depsMap(Map)key = 'count' / ITERATE_KEYDepversion / subs
图 5:依赖表三层结构。最外层用 WeakMap 是内存安全的关键——对象被回收时,它的整个 depsMap 也会一起被 GC。

06核心重构:从 Set 到 Dep + Link 双向链表

这是 Vue 3.5 最重要的一次内部重构(PR #10397)。

3.4 及以前

旧实现(3.4)ts
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 双向链表

packages/reactivity/src/dep.ts(精简)ts
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 与一个订阅者之间的一条边」,它同时挂在两条双向链表上:

① Dep.subs —— 谁订阅了我② Sub.deps —— 我依赖谁同一个 LinkDep ALink A,E1Link A,E2Link A,E3Sub E1Link A,E1Link B,E1Link C,E1prevSubnextDep
图 6:Link 的「双重身份」。注意 `Link(A, E1)` 在两行里是同一个对象——这就是双向链表的精髓。

track 时如何复用 Link

packages/reactivity/src/dep.ts(精简)ts
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,底层都是它。

packages/reactivity/src/effect.ts(精简)ts
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}
run() 入口cleanupEffect执行上一轮注册的 cleanupprepareDeps所有 Link.version = -1activeSub = this → fn()读取响应式数据触发 track()cleanupDeps摘掉 version 仍为 -1 的 Linktrack() 同步 version
图 7:run() 的生命周期。prepareDeps 打标记 + cleanupDeps 做清除,本质是一次标记-清除 GC,只是对象换成了依赖关系。

分支切换:依赖清理的经典场景

分支切换示例ts
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         // 期望:不打印。如果没有清理机制,这里会误触发
第 1 轮 · flag = true第 2 轮 · flag = falseDep a 的 Link 被摘除effect fnDep flagDep aeffect fnDep flagDep b
图 8:分支切换时的依赖清理。这正是「响应式系统不会误触发」的根本保证。

08版本号机制:从「通知」到「询问」

Vue 3.5 另一个关键变化:trigger 时不再立刻执行 effect,而只是把版本号 +1。 真正要不要重跑,等到调度时再「询问」。

dep.ts / effect.ts(精简)ts
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 与调度器

set 陷阱 → trigger()startBatchbatchDepth++dep.trigger()version++ / globalVersion++notify() 入队沿 subs 链,batch 去重endBatch()batchDepth 归零,遍历队列scheduler → queueJobrunIfDirty → isDirty → run执行前再问一次「真的脏了吗」有 scheduler 吗?是否
图 9:从 trigger() 到 scheduler()。批处理保证「一次变更只刷新一次」,NOTIFIED 标记保证「一个 effect 只入队一次」。
packages/reactivity/src/effect.ts(精简)ts
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,所以它能「被别人订阅」也能「订阅别人」。

渲染 effectComputedRefImpl既是订阅者,也是发布者Dep count读 .value → dep.track()值变了 → notifygetter 内读取 → trackcount 变 → 标记 DIRTY
图 10:computed 的双向身份。它是响应式图里唯一一个「既是节点又是边」的角色。
packages/reactivity/src/computed.ts(精简)ts
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 性能的精髓:

packages/reactivity/src/effect.ts(精简)ts
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。

packages/reactivity/src/ref.ts(精简)ts
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。

source 归一化ref / reactive / array / fnjobeffect.schedulercb(new, old)跳过本次回调dirty 且有 cb?gettereffect.dirty是否
图 11:watch 的两步走。先归一化 source,再交给 job 判断要不要执行回调。
packages/runtime-core/src/apiWatch.ts(精简)ts
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。 它们会遍历数组,如果不加处理:

数组陷阱示例ts
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 自己会「注销」

packages/reactivity/src/dep.ts(精简)ts
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 之后,核心其实就这么多:

mini-reactive.jsjs
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}

跑一下验证分支切换:

验证分支切换js
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__ 分支与类型标注),变量名与执行顺序与源码保持一致。

hello-world-4k-0o-2560x1440.jpg