Vue 3 设计与实现

第 5 章 @vue/reactivity 源码深度剖析(下):effect / effectScope / shallowReactive / readonly

作者 杨艺韬 · 12,131 字 · 发布于 · 更新于

第 5 章 @vue/reactivity 源码深度剖析(下):effect / effectScope / shallowReactive / readonly

本章要点

  • effect() 的完整生命周期:创建、执行、依赖收集、清理、销毁
  • effectScope:为什么需要"作用域"来管理副作用
  • shallowReactive / shallowRef:何时需要"浅层"响应式
  • readonly / shallowReadonly:只读代理的实现,以及它与 props 的真实关系
  • 响应式工具函数全景:toRaw / markRaw / isRef / unref / toRefs

你是否遇到过这样的场景:一个页面打开了 WebSocket 连接、启动了定时器、注册了 watchEffect——当用户离开页面时,这些副作用都需要被清理。忘记清理任何一个,都会导致内存泄漏。

在 Vue 2 中,你需要在 beforeUnmount 中手动管理每一个副作用的清理。在 Vue 3 中,effectScope 让你可以一行代码批量清理所有副作用。

但 effectScope 是如何做到的?它和 effect 之间的关系是什么?effect 本身的生命周期又是怎样的?

本章将继续深入 @vue/reactivity,完成响应式系统的最后几块拼图。

承接第 4 章——这次我们讨论"副作用和边界"。

第 4 章讨论的是响应式"数据层":reactive、ref、computed、track、trigger——这些 API 回答的是"值怎么变、依赖怎么连"。本章讨论的是响应式"副作用层":effect、effectScope、shallowReactive、readonly——这些 API 回答的是"变化怎么产生具体的行为"、"一组行为怎么被一起管理"、"哪些变化我们想追踪、哪些我们想刻意放过"。如果说第 4 章讲的是"响应式系统怎么感知变化",本章讲的就是"响应式系统怎么把感知到的变化转化为行动、又怎么在需要时主动选择不感知"。

本章会反复用到第 4 章讨论过的 Alien Signals 基础设施——Dep、Link、propagate、checkDirty。如果你对它们还不够熟悉,强烈建议先翻回第 4 章复习。第 5 章的内容是"在第 4 章的地基上搭建一层管理层"——地基不熟就搭上去的楼会摇晃。

本章也和第 10 章(组件系统)、第 15 章(Pinia)紧密关联——effectScope 是 Pinia 管理 store 生命周期的核心机制、第 10 章讲的组件 setup 阶段就是 effectScope 的一个典型应用场景。读完本章后你会对"Vue 怎么把一组副作用的生死绑定到一个上层对象(组件、store)的生命周期"有非常具体的理解。

5.1 effect():响应式系统的执行引擎

effect 对外是 @vue/reactivity 暴露的低层 API,但它更重要的角色是内部基础设施——Vue 所有"数据变化 → 执行一段代码"的场景都基于它。组件渲染、watch、watchEffect、devtool 集成、甚至第三方库(如 Pinia)都在用 effect 做底层。理解 effect 等于获得了 Vue 响应式系统里唯一的执行原语——再复杂的上层 API 也只是它的特定组合。

effect 的本质

effect 是响应式系统里最底层、也最通用的原语——所有上层的 watchEffect、watch、组件渲染函数,底下都是 effect。理解它相当于拿到整个响应式系统的万能钥匙——剩下那些上层 API 都是在 effect 基础上包装出来的特定用法。下面的代码你可能在很多教程里见过,但每次重读都能有新收获——因为当你对上层 API 理解得越深,回头看底层原语时能看出的"这里为什么要这样设计"就越多。

import { ref, effect } from '@vue/reactivity'

const count = ref(0)

// 创建一个 effect
const runner = effect(() => {
  console.log(`count is: ${count.value}`)
})
// 输出: count is: 0

count.value = 1
// 输出: count is: 1(自动重新执行)

count.value = 2
// 输出: count is: 2(再次自动重新执行)

ReactiveEffect 类

ReactiveEffect 这个类是全专栏所有响应式代码里出场频率最高的一个类——它既是对外 API(effect())的底层、又是 watchEffect / watch 的底层、还是组件渲染函数的底层、甚至是 computed(第 4 章)的亲戚(ComputedRefImpl 实现的是同一个 ReactiveNode 接口)。读完这一小节你应该能从"对 effect 的感觉"上升到"对 ReactiveEffect 类的清晰理解"——以后所有上层 API 的工作机制,你都可以推回到 ReactiveEffect 的执行流程上。

// packages/reactivity/src/effect.ts:51(精简,rc.4)

export enum EffectFlags {
  ALLOW_RECURSE = 1 << 7,
  PAUSED = 1 << 8,
  STOP = 1 << 10,
}

export class ReactiveEffect<T = any>
  implements ReactiveEffectOptions, ReactiveNode
{
  // --- ReactiveNode 接口:和 Dep / ComputedRefImpl / EffectScope 共用 ---
  deps: Link | undefined = undefined
  depsTail: Link | undefined = undefined
  subs: Link | undefined = undefined      // 谁"订阅"了这个 effect?——它所属的 EffectScope
  subsTail: Link | undefined = undefined
  flags: number = ReactiveFlags.Watching | ReactiveFlags.Dirty

  // --- Effect 特有 ---
  cleanups: (() => void)[] = []
  cleanupsLength = 0

  constructor(fn?: () => T) {
    if (fn !== undefined) this.fn = fn
    // 自动"挂"到当前活跃的 EffectScope:scope 是 sub,effect 是 dep
    if (activeEffectScope) {
      link(this, activeEffectScope)
    }
  }

  get active(): boolean {
    return !(this.flags & EffectFlags.STOP)
  }

  // propagate() 把 effect 放进 notifyBuffer,flush() 时逐个调用 notify()
  notify(): void {
    if (!(this.flags & EffectFlags.PAUSED) && this.dirty) {
      this.run()
    }
  }

  run(): T {
    // 1. 已停止:直接执行函数(不收集依赖)
    if (!this.active) {
      return this.fn()
    }
    // 2. 执行上一次注册的 cleanup
    cleanup(this)
    // 3. startTracking:清 Dirty/Pending、打上 RecursedCheck、depsTail 归零、activeSub = this
    const prevSub = startTracking(this)
    incRunDepth()
    try {
      // 4. 执行函数——每次 getter 触发 track → link(dep, this)
      return this.fn()
    } finally {
      decRunDepth()
      // 5. endTracking:恢复 activeSub,砍掉 depsTail 之后没被重新访问的 Link
      endTracking(this, prevSub)
      // 6. 执行期间又被自己触发了(Recursed)且允许递归 → 再跑一次
      const flags = this.flags
      if (
        (flags & (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE)) ===
        (ReactiveFlags.Recursed | EffectFlags.ALLOW_RECURSE)
      ) {
        this.flags = flags & ~ReactiveFlags.Recursed
        this.notify()
      }
    }
  }

  stop(): void {
    if (!this.active) return
    this.flags = EffectFlags.STOP
    // 断开所有依赖 Link
    let dep = this.deps
    while (dep !== undefined) {
      dep = unlink(dep, this)
    }
    // 从所属 scope 上摘下来
    const sub = this.subs
    if (sub !== undefined) {
      unlink(sub)
    }
    // 执行清理回调
    cleanup(this)
  }

  // Dirty → 肯定要跑;Pending → 先 checkDirty() 向上游核实
  get dirty(): boolean {
    const flags = this.flags
    if (flags & ReactiveFlags.Dirty) return true
    if (flags & ReactiveFlags.Pending) {
      if (checkDirty(this.deps!, this)) {
        this.flags = flags | ReactiveFlags.Dirty
        return true
      } else {
        this.flags = flags & ~ReactiveFlags.Pending
      }
    }
    return false
  }
}

注意几个和旧版本不同的地方:(1) rc.4 里没有 Subscriber 接口,effect、computed、Dep、EffectScope 实现的是同一个 ReactiveNode(deps / depsTail / subs / subsTail / flags 五个字段),这就是为什么 EffectScope 能像 effect 订阅 Dep 那样"订阅"它内部的 effect(把它们挂在自己的 deps 链上);(2) 没有 _scheduler 字段——effect(fn, { scheduler }) 是通过覆写实例的 notify 方法实现的,组件渲染 effect(packages/runtime-core/src/renderer.ts 里的 SetupRenderEffect)也是覆写 notify() 去调 queueJob;(3) 每次 run() 的依赖增删完全交给 system.ts 的 startTracking / link / endTracking 三个函数,下一小节展开。

依赖的生命周期管理

很多同学读响应式源码最困惑的地方就是"依赖怎么就既加了又删呢?"——因为这部分涉及到 effect 的重复执行、依赖图的动态变化,是整个响应式系统里最"动态"的部分。本小节用一个条件渲染的例子把这个问题讲透——你会看到 Alien Signals 的 startTracking → link → endTracking 为什么不是简单的"全删全建"、而是"顺序游标复用 + 尾部裁剪"。这个精巧设计是 Vue 3.6 响应式性能相对 3.4 大幅提升的关键原因之一。

每次 effect.run() 执行时,需要处理一个微妙的问题:依赖可能在两次执行之间发生变化。

考虑这个场景:

const show = ref(true)
const a = ref('hello')
const b = ref('world')

effect(() => {
  if (show.value) {
    console.log(a.value)  // 第一次执行:依赖 show 和 a
  } else {
    console.log(b.value)  // 第二次执行:依赖 show 和 b
  }
})

当 show.value 从 true 变为 false 时,effect 重新执行。此时它不再依赖 a,而是依赖 b。旧的对 a 的依赖必须被清理,否则 a.value 变化时仍会触发这个 effect——这就是"过期依赖"问题。

// packages/reactivity/src/system.ts:235(精简,rc.4)

export function startTracking(sub: ReactiveNode): ReactiveNode | undefined {
  ++globalVersion
  sub.depsTail = undefined          // ← 游标归零:旧链表原样保留,等待被复用
  sub.flags =
    (sub.flags &
      ~(ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending)) |
    ReactiveFlags.RecursedCheck
  return setActiveSub(sub)
}

// 以下取自同文件 :78
export function link(dep: ReactiveNode, sub: ReactiveNode): void {
  const prevDep = sub.depsTail
  // 连续两次读同一个 dep → 直接忽略
  if (prevDep !== undefined && prevDep.dep === dep) {
    return
  }
  // 游标后面那个旧 Link 恰好就是这个 dep → 原地复用,游标前进一格
  const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
  if (nextDep !== undefined && nextDep.dep === dep) {
    nextDep.version = globalVersion
    sub.depsTail = nextDep
    return
  }
  // 本轮已经在 dep 的订阅者链尾挂过这个 sub → 去重
  const prevSub = dep.subsTail
  if (prevSub !== undefined && prevSub.version === globalVersion && prevSub.sub === sub) {
    return
  }
  // 真的是新依赖 → 创建 Link,插到游标位置(既在 sub 的 deps 链,也在 dep 的 subs 链)
  const newLink = (sub.depsTail = dep.subsTail = {
    version: globalVersion, dep, sub, prevDep, nextDep, prevSub, nextSub: undefined,
  })
  if (nextDep !== undefined) nextDep.prevDep = newLink
  if (prevDep !== undefined) prevDep.nextDep = newLink
  else sub.deps = newLink
  if (prevSub !== undefined) prevSub.nextSub = newLink
  else dep.subs = newLink
}

export function endTracking(sub: ReactiveNode, prevSub: ReactiveNode | undefined): void {
  activeSub = prevSub
  // 游标(depsTail)之后的 Link 全是本轮没被访问到的旧依赖 → 逐个 unlink
  const depsTail = sub.depsTail
  let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps
  while (toRemove !== undefined) {
    toRemove = unlink(toRemove, sub)
  }
  sub.flags &= ~ReactiveFlags.RecursedCheck
}

回到上面的例子:第一次执行后 effect 的依赖链是 show → a。show 变为 false 后重新执行:startTracking 把游标归零;读 show.value 时 link() 发现游标后面第一个旧 Link 就是 show——复用,游标前进;读 b.value 时游标后面是 a 的 Link、对不上——在它前面插入新的 b Link;endTracking 时游标停在 b,b 之后的 a Link 被 unlink 掉。整个过程只分配了一个对象(b 的 Link),show 的 Link 原地复用,只改写了 version 和 nextDep 两个字段。

🔥 深度洞察

这套"顺序游标"策略赌的是一个经验事实:effect 两次执行的依赖访问顺序高度一致。只要顺序一致,link() 就永远命中"游标后面正好是它"的快速路径,依赖收集退化成一次指针比较加一次赋值。Vue 3.0–3.1 的做法是每次执行前把 effect 从所有 Dep 的 Set 里 delete 掉再重新 add;3.2–3.3 用 w/n 两个位标记减少了 Set 操作但仍要遍历(PR #4017 引入,首个 tag v3.2.0-beta.1;PR #5912 移除,首个 tag v3.4.0-alpha.1),3.4 换成 Map<ReactiveEffect, number> + _depsLength 游标;3.5 把 Set 换成双向链表、用 link.version = -1 做"标记-清扫"(prepareDeps / cleanupDeps);3.6 的 Alien Signals 则连标记这一步都省了——旧链表原地待命,访问到就复用,访问不到的留在游标后面被一刀砍掉。稳定的依赖零分配,只有真正变化的依赖才产生分配与释放。

onCleanup——副作用的清理

onCleanup 是前端开发里一个极其容易被忽视、但用好了能彻底改变代码质量的 API。最典型的使用场景就是异步请求的 race condition 防护——用户在搜索框快速输入 "a → ab → abc",每次输入触发一个 fetch,如果不做 cleanup,三个请求可能按任意顺序返回,最后显示的结果可能是 "a" 的而不是 "abc" 的——这就是经典的"最后的请求不一定最后返回"bug。onCleanup 让你能优雅地在新请求开始前取消旧请求,bug 根子上消失。写 composable 时把 onCleanup 的使用当成第二本能——这是专业前端和业余前端之间的一道分水岭。

watchEffect 的 onCleanup 参数从 Vue 3.0 起就有(3.0 源码里这个形参叫 onInvalidate);Vue 3.5 又加上了不依赖回调参数、可以在任何地方调用的 onWatcherCleanup()(PR #9927,实现在 packages/reactivity/src/watch.ts;裸 effect() 对应的是同一版加入的 onEffectCleanup())。两者都让 effect 可以在每次重新执行前执行清理逻辑:

import { ref, watchEffect } from 'vue'

const id = ref(1)

watchEffect((onCleanup) => {
  const controller = new AbortController()

  fetch(`/api/data/${id.value}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => { /* 使用数据 */ })

  // 当 id 变化导致 effect 重新执行时,取消上一次的请求
  onCleanup(() => {
    controller.abort()
  })
})

onCleanup 的实现原理很简单——它把清理函数追加到 effect 的 cleanups 数组里,在下一次 effect.run() 开头(以及 stop() 时)被统一调用:

// packages/reactivity/src/effect.ts:298(精简,rc.4)

export function onEffectCleanup(fn: () => void, failSilently = false): void {
  if (activeSub instanceof ReactiveEffect) {
    activeSub.cleanups[activeSub.cleanupsLength++] = () => cleanupEffect(fn)
  } else if (__DEV__ && !failSilently) {
    warn(`onEffectCleanup() was called when there was no active effect to associate with.`)
  }
}

// 同文件 :274(签名在源码里折成多行)
export function cleanup(sub: ReactiveNode & { cleanups: (() => void)[]; cleanupsLength: number }): void {
  const l = sub.cleanupsLength
  if (l) {
    for (let i = 0; i < l; i++) sub.cleanups[i]()
    sub.cleanupsLength = 0
  }
}

// ReactiveEffect.run() 的第一步就是 cleanup(this)

注意 cleanupsLength 这个细节:Vue 没有用 cleanups.length = 0 清空数组,而是只把长度计数归零、让旧的函数槽位被下一轮直接覆盖——又一个"不产生垃圾"的小优化。watch / watchEffect(packages/reactivity/src/watch.ts)复用的是同一个 cleanups / cleanup() 机制,只是把用户的清理函数包了一层错误处理(WatchErrorCodes.WATCH_CLEANUP)。

5.2 effectScope:批量管理副作用

如果说 effect 是"单个副作用的载体",effectScope 就是"一组副作用的统一管家"。这个管家的唯一工作就是:把一组 effect 的生命周期绑定到一个共同的起点和终点。起点是 effectScope() 创建的时刻、终点是 scope.stop() 被调用的时刻。在这两个时刻之间创建的所有 effect 都会被这个 scope 自动登记——到终点时一起销毁。

问题场景

每个 Vue 开发者都会在某个阶段遇到"副作用清理"的痛苦。写了五个 watchEffect、三个 setTimeout、两个 event listener——组件卸载时你必须保证所有这些副作用都被正确清理,漏一个就是内存泄漏。在 Vue 2 时代,这类问题有一半的情况是"先跑起来再说",等到压测或者长时间运行时才暴露——排查路径长、修复成本高。effectScope 是 Vue 3 给这类痛苦的根治方案——它把"手动收集每个清理函数"变成"自动绑定到一个作用域"(setTimeout、事件监听这类非响应式副作用,要用 onScopeDispose 登记清理函数才会被一并清理)。

在复杂的组合式函数中,你可能创建多个 watch、watchEffect、computed:

function useFeature() {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const formatted = computed(() => /* ... */ data.value)

  watchEffect(() => { /* 自动请求数据 */ })
  watch(data, () => {  })

  // 如何一次性清理所有这些副作用?
}

在没有 effectScope 的情况下,你需要手动收集每个副作用的停止句柄:

// 麻烦的手动管理
function useFeature() {
  const stops: (() => void)[] = []

  const data = ref(null)
  stops.push(watchEffect(() => { /* ... */ }))
  stops.push(watch(data, () => { /* ... */ }))

  function cleanup() {
    stops.forEach(stop => stop())
  }

  return { data, cleanup }
}

effectScope 的解决方案更加优雅:

import { effectScope } from 'vue'

function useFeature() {
  const scope = effectScope()

  scope.run(() => {
    const data = ref(null)
    watchEffect(() => { /* ... */ })
    watch(data, () => { /* ... */ })
    // 在 scope.run() 内创建的所有 effect 都被自动收集
  })

  // 一行代码停止所有副作用
  scope.stop()
}

EffectScope 的实现

先预告一个关键设计:EffectScope 形成的是一棵树——组件的 scope 里可以有 useXxx 创建的子 scope、子 scope 里还可以再嵌套。读下面的实现时注意:rc.4 的 EffectScope 没有 effects[] / scopes[] / parent 这些字段,它实现的是和 effect 一样的 ReactiveNode 接口——scope 把自己内部的 effect、computed、子 scope 当作"依赖"(deps 链)挂在身上,自己又作为"依赖"挂在父 scope 的 deps 链上。作用域树就是这条 Link 链表串出来的。一个 scope stop 时会沿 deps 链逐个 stop——这让整个树的清理变成"根上一刀"的操作。对比"每个组件手动维护清理列表"的方式,这个设计在复杂的 composable 嵌套里节省的工作量是巨大的。

// packages/reactivity/src/effectScope.ts:5(精简,rc.4)

export let activeEffectScope: EffectScope | undefined

export class EffectScope implements ReactiveNode {
  // deps 链:挂在我身上的 effect / computed / 子 scope
  deps: Link | undefined = undefined
  depsTail: Link | undefined = undefined
  // subs 链:我挂在哪个父 scope 身上(最多一条 Link)
  subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  flags: number = 0

  cleanups: (() => void)[] = []      // onScopeDispose 注册的回调
  cleanupsLength = 0

  constructor(detached = false) {
    // 如果不是"分离的",把自己作为 dep 挂到父作用域上
    if (!detached && activeEffectScope) {
      link(this, activeEffectScope)
    }
  }

  get active(): boolean {
    return !(this.flags & EffectFlags.STOP)
  }

  run<T>(fn: () => T): T | undefined {
    const prevScope = activeEffectScope
    try {
      activeEffectScope = this   // ← 设为当前活跃作用域
      return fn()                // ← fn 内创建的 effect / computed / scope 自动 link 到 this
    } finally {
      activeEffectScope = prevScope
    }
  }

  stop(): void {
    if (!this.active) return
    this.flags = EffectFlags.STOP
    this.reset()
    // 从父作用域的 deps 链上摘掉自己
    const sub = this.subs
    if (sub !== undefined) {
      unlink(sub)
    }
  }

  /** @internal */
  reset(): void {
    let dep = this.deps
    while (dep !== undefined) {
      const node = dep.dep
      if ('stop' in node) {
        // effect / 子 scope:递归 stop(它们 stop 时会自己 unlink 这条 Link)
        dep = dep.nextDep
        node.stop()
      } else {
        // computed 没有 stop():直接断开 Link
        dep = unlink(dep, this)
      }
    }
    cleanup(this)   // 执行 onScopeDispose 回调
  }

  // 3.5 起还有 pause() / resume():沿 deps 链逐个 pause / resume 内部的 effect 和子 scope
}

注意 reset() 里对 computed 的特殊处理:3.6 的 ComputedRefImpl.get value() 在没有 activeSub 但有 activeEffectScope 时会 link(this, activeEffectScope)——也就是说在 scope.run() 里直接读取的 computed 也会挂到 scope 上,scope 停止时把它们的 Link 断开;如果 computed 因此没有了任何订阅者,unlink 会级联断开它对上游的依赖并置 Dirty(packages/reactivity/src/system.ts:150-157),释放它对上游的订阅。

作用域树

"作用域树"这个概念一旦被建立,你会发现它和 Vue 里很多其他概念是同构的——组件树、DOM 树、provides 原型链(第 14 章)都是类似的树结构。Vue 的整个运行时其实可以看作多棵树的联合同构系统:组件树决定父子关系、作用域树决定副作用清理、DOM 树决定视觉层次、provides 链决定依赖注入……这些树彼此镜像、彼此同步。当你理解了其中一棵,其他的就容易上手——它们都是同一种树状组织思想的不同面。

EffectScope 形成树形结构——每个组件实例有自己的作用域(createComponentInstance 里 new EffectScope(true /* detached */),注意是 detached 的:组件 scope 之间互不嵌套,各自由 unmountComponent 单独 stop()),组合式函数可以在组件作用域内创建子作用域:

graph TD
    Root["组件 A 的作用域<br/>(detached,由 unmountComponent 停止)"]
    B["组件 B 的作用域<br/>(另一棵独立的 detached scope)"]
    C["useFeature() 的作用域"]
    D["useAuth() 的作用域"]
    Root --> C
    Root --> D
    C --> F["useFeature 内部 effectScope()"]
    C --> E1["watchEffect 1"]
    C --> E2["watch 1"]
    D --> E3["watchEffect 2"]
    style Root fill:#4ecdc4,stroke:#333
    style B fill:#4ecdc4,stroke:#333
    style C fill:#ff6b6b,stroke:#333,color:#333
    style D fill:#ff6b6b,stroke:#333,color:#333
    style F fill:#ffd93d,stroke:#333

当组件 A 被卸载时,unmountComponent(packages/runtime-core/src/renderer.ts)调用它的 scope.stop(),沿 deps 链递归停止所有子作用域——useFeature() 和 useAuth() 中创建的所有 effect 都被自动清理。子组件 B 的 scope 不挂在 A 的 scope 下,它由 B 自己的 unmountComponent 负责——组件树的"父子"关系由渲染器维护,不靠 EffectScope 嵌套。

🔥 深度洞察

EffectScope 的树形结构与 DOM 树和组件树形成了三棵平行的树。当一个组件被卸载时:(1)DOM 节点从 DOM 树中移除;(2)组件实例从组件树中移除;(3)EffectScope 从作用域树中移除并停止所有副作用。这种"三树同步"的设计确保了资源清理的完整性——不会出现"组件卸载了但 watcher 还在跑"的内存泄漏问题(setTimeout、事件监听这类非响应式资源仍要靠 onScopeDispose / onUnmounted 注册清理)。Vue 内部将组件实例的 setup() 执行包裹在实例自己的 EffectScope 中(setCurrentScope(instance.scope)),这就是为什么在 setup() 中创建的 watch / watchEffect 会在组件卸载时自动停止。

detached scope——分离的作用域

"detached"(分离)这个词值得解释一下。默认情况下,一个 effectScope 会被当前活跃的外层 scope"捕获"——形成父子关系、随父亲一起死。detached scope 就是故意跳出这种捕获——"我不要被任何上层 scope 拴住、我的生死由我自己掌控"。这个设计给应用级、跨组件级的长寿命副作用(全局状态、WebSocket、后台任务)提供了合适的容器。

有时你需要创建一个不跟随父组件生命周期的作用域:

const scope = effectScope(true /* detached */)

// 这个作用域不会被自动清理
// 你必须手动调用 scope.stop()

使用场景包括:跨组件共享的全局状态、需要在组件卸载后继续执行的后台任务等。

detached scope 最经典的用法是 Pinia(第 15 章会展开)。Pinia 的 createPinia() 内部创建了一个 effectScope(true)——这个 scope 故意不绑定任何组件,生命周期和应用本身一致。所有 store 里的 computed / watch 都挂在这个 detached scope 下,应用不退出就不会被清理。如果 Pinia 用的是普通 scope,而 createPinia() 恰好在某个组件的 setup 或另一个 effectScope 里被调用,它就会被那个外层 scope 捕获——外层一停,所有 store 里的 computed / watch 跟着失效。detached 的 true 让 pinia 的 scope 不受调用位置影响。一个布尔值参数背后往往藏着一整套设计考虑——这种"小参数、大后果"的细节在 Vue 源码里反复出现,读源码时遇到 API 的布尔 / 枚举参数一定要多问一句"为什么需要这个选项"。

5.3 shallowReactive / shallowRef:浅层响应式

前两节我们在讨论"怎么让响应式发挥最大能力"——本节反过来讨论"怎么让响应式退出某个场景"。框架提供强大能力的同时也要提供"退出能力"——没有退出通道,用户遇到不适合的场景只能硬上框架、产生各种别扭的变通。shallowReactive / shallowRef / markRaw(后面 5.5 节讲)是 Vue 响应式系统的三个主要"退出通道",组合起来覆盖了所有"我不想让 Vue 代理这个对象"的场景。

为什么需要浅层响应式

reactive() 的"深度代理 + 按需展开"策略在大多数场景下很优秀(第 4 章讲过),但在两种特殊场景下它就成了负担:(1)数据规模大且内部变化你不关心(比如从 API 拉回来的嵌套几十层的 JSON 配置),(2)数据由外部库管理、自己不想被 Vue 干预(比如 Three.js 的 Object3D、ECharts 的 option、d3-selection 返回的对象)。这两种场景里,深度代理不仅浪费性能,甚至可能触发外部库内部的防护机制报错。shallowReactive / shallowRef 就是 Vue 给这类场景的"响应式退出阀"——顶层响应式、内部保持原样。

"浅层响应式"的哲学意义比它的性能优化更重要。它代表着 Vue 作为框架的一个姿态:"我给你能力,但不替你做决定"。Vue 不假设所有数据都应该被深度代理——它提供深层、浅层、只读、不代理等多种选项,让开发者根据具体场景选最合适的。这种"不做假设、提供选项"的设计哲学比"强制一种方式、用户没得选"更成熟——它承认不同场景有不同需求、不同开发者有不同偏好。这也是为什么 Vue 的响应式 API 看起来比 React 的 useState 多很多——不是冗余,而是覆盖更多场景的细粒度选项。

reactive() 会递归地将所有嵌套对象转为响应式。对于大型数据结构(如从 API 获取的深层嵌套 JSON),这种递归代理可能带来不必要的性能开销:

// 深层对象——reactive 会递归代理所有层级
const deepData = reactive({
  level1: {
    level2: {
      level3: {
        // ... 可能有几十层嵌套
        value: 42
      }
    }
  }
})

如果你只关心顶层属性的变化,不需要追踪嵌套属性,shallowReactive 是更高效的选择:

const shallow = shallowReactive({
  user: { name: 'Alice', age: 25 },
  items: [1, 2, 3]
})

// ✅ 顶层属性变化会触发更新
shallow.user = { name: 'Bob', age: 30 }

// ❌ 嵌套属性变化不会触发更新
shallow.user.name = 'Charlie'  // 不是响应式的!

实现原理

shallowReactive 是 Vue 响应式系统里"最小实现差异的 API"——和 reactive 比几乎就改了一行。读下面的对比代码时重点看这个差异在哪里:

shallowReactive 的实现与 reactive 几乎相同——rc.4 里两者用的是同一个 MutableReactiveHandler 类,只是构造参数 isShallow 不同。最关键的差别在 get trap 中:

// packages/reactivity/src/baseHandlers.ts:49(精简,rc.4)

class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _isShallow = false,
  ) {}

  get(target: Target, key: string | symbol, receiver: object): any {
    // ...ReactiveFlags 标记、数组方法、ref 解包等分支省略
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }

    if (isShallow) {
      return res              // ← shallow:到这里就返回,不再往下递归
    }

    if (isRef(res)) {
      const value = targetIsArray && isIntegerKey(key) ? res : res.value
      return isReadonly && isObject(value) ? readonly(value) : value
    }

    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)   // ← 深层:递归代理
    }
    return res
  }
}

export const mutableHandlers = new MutableReactiveHandler()
export const shallowReactiveHandlers = new MutableReactiveHandler(true)

就这么简单——isShallow 为真时提前 return res,嵌套对象就不再被代理(顺带也不做 ref 解包)。set trap 里 shallow 版本同样会跳过 toRaw(value) 和 ref 赋值透传这两步,但骨架是同一个类。

这种"去掉一行代码就是一个新 API"的模式在 Vue 源码里随处可见——reactive 和 shallowReactive 的差别就一行、ref 和 shallowRef 的差别也就一行、readonly 和 shallowReadonly 也是……这种"一个字段控制行为"的设计让 Vue 能用很少的代码支持多种变体。如果让新手做类似设计,可能会写两套独立的 handler、重复 80% 的代码——这样不仅代码冗余,还容易出现"维护时只改了一边、另一边忘了"的 bug。Vue 的做法更聪明:用参数化控制行为差异、共享核心逻辑。这是任何写库的工程师都值得学习的 DRY(Don't Repeat Yourself)实践。

shallowRef

shallowRef 的用法会让一些初学者困惑:"既然修改嵌套属性不触发更新、那我还要它干嘛?"——这个问题的答案藏在"不可变更新(immutable update)"这种编程模式里。React 生态推崇的 immer、Redux Toolkit 等库都基于一个核心思想:不修改原对象、而是生成新对象替换。在这种模式下,响应式只需要检测"整个对象被换了"这一件事——深度追踪嵌套变化纯属浪费。shallowRef 就是为这种模式量身定制的原语。配合 immer 库用得最爽——immer 生成新对象、shallowRef 整体替换、深度追踪的开销全部免除。

shallowRef 的原理类似:

const data = shallowRef({ name: 'Vue' })

// ✅ 替换整个值会触发更新
data.value = { name: 'React' }

// ❌ 修改嵌套属性不会触发更新
data.value.name = 'Svelte'  // 不触发!

实现上,ref() 调用的是 createRef(value, toReactive),shallowRef() 调用的是 createRef(value)——差别只是有没有传那个 wrap 函数:

// packages/reactivity/src/ref.ts:146(rc.4)
constructor(value: T, wrap: (<T>(v: T) => T) | undefined) {
  this._oldValue = this._rawValue = wrap ? toRaw(value) : value
  this._value = wrap ? wrap(value) : value
  //                   ↑ shallow 时 wrap 为 undefined,不调用 toReactive
  this._wrap = wrap
  this[ReactiveFlags.IS_SHALLOW] = !wrap
}

💡 最佳实践

shallowRef 是处理大型不可变数据的最佳选择。从 API 获取的数据通常不需要深层响应式——你只在意"数据是否更新了"(整体替换),而不是"数据的某个嵌套字段是否变了"。使用 shallowRef 可以避免对整个响应数据的递归 Proxy 创建:

// 推荐:大量数据用 shallowRef
const users = shallowRef<User[]>([])

async function fetchUsers() {
  const data = await api.getUsers()
  users.value = data  // 整体替换,触发更新
}

5.4 readonly / shallowReadonly:不可变的响应式

readonly 是第 5 章里"用工具保证纪律"哲学最典型的落地。它本身不做任何新的响应式能力——它只是在 reactive 的基础上禁用写操作。但这个"禁用"的价值很大:它让"这个数据不该被修改"从开发约定变成了运行时强制。Vue 自己也在用它——开发模式下 setup(props) 收到的 props 是 shallowReadonly(instance.props)、官方文档建议 provide 出去的状态用 readonly() 包一层——所有"消费方只应该读、不应该写"的场景都适合用 readonly 包装。

readonly 的设计意图

readonly 的表面功能很简单——"这个值不准改"。但它的深层设计意图是架构级的契约执行:Vue 用它来规范"数据流方向"这件事。Props 必须从父流向子、provide 的 state 应该只读地被 inject——这些都是"最佳实践"层面的约定,但约定总会被违反。readonly 把这些约定从"文档里写的"变成"代码里强制的"——你违反,开发环境就报警告、写入被直接拦下。本节讲的不只是一个 API,还是"用工具保证纪律"的工程哲学在 Vue 的具体应用。

readonly() 创建一个只读的代理。它的 get trap 自己不调用 track——但当它包裹的是一个 reactive 代理时(最常见的用法 readonly(reactive(...))),Reflect.get 会穿透到内层的 reactive 代理,由内层完成依赖收集;任何写入操作则被拦截并在开发环境下发出警告:

const original = reactive({ count: 0 })
const copy = readonly(original)

copy.count++  // ⚠️ 开发环境警告: Set operation on key "count" failed: target is readonly.

// 但 original 变化时,copy 的依赖者仍然会被通知
original.count++  // copy.count 现在也是 1

实现原理

readonly 的实现可以看作是"reactive 的减法"——在相同的 Proxy 框架下禁用 set 和 deleteProperty 的实际行为。读下面这段代码时注意一个细节:set 被禁用但 return true——这是为了兼容严格模式,严格模式下 set trap 返回 false 会直接抛 TypeError,用户代码就会挂。返回 true 让写操作"静默失败"——对用户体验更友好、也不会炸裂。这种"禁用但不爆炸"的做法在所有成熟库里都很常见。

readonly 的 Proxy handler 非常直接——get 复用 BaseReactiveHandler(isReadonly 为真时跳过 track,嵌套对象递归包成 readonly),set 和 deleteProperty 直接返回 true 而不执行:

// packages/reactivity/src/baseHandlers.ts:230(精简,rc.4)

class ReadonlyReactiveHandler extends BaseReactiveHandler {
  constructor(isShallow = false) {
    super(true /* isReadonly */, isShallow)
  }

  set(target: object, key: string | symbol) {
    if (__DEV__) {
      warn(`Set operation on key "${String(key)}" failed: target is readonly.`, target)
    }
    return true
  }

  deleteProperty(target: object, key: string | symbol) {
    if (__DEV__) {
      warn(`Delete operation on key "${String(key)}" failed: target is readonly.`, target)
    }
    return true
  }
}

export const readonlyHandlers = new ReadonlyReactiveHandler()
export const shallowReadonlyHandlers = new ReadonlyReactiveHandler(true)

readonly 与 props:只在开发期收紧的守卫

响应式系统和编译器的协作是 Vue 3 相对其他框架的独特优势之一——patchFlag(第 11 章)、block tree(第 11 章)、Vapor(第 9 章)都是"编译期提供信息、运行时利用信息"的例子。但 readonly 不是这种协作的一部分:编译器不知道也不关心一个对象是不是 readonly 代理,readonly 纯粹是运行时的行为。把这一点说清楚很重要,因为网上有不少"编译器利用 readonly 跳过 props 变更检测"的说法,源码里找不到任何依据。

props 的真实处理方式是(packages/runtime-core/src/componentProps.ts / component.ts):

// initProps:props 本体是 shallowReactive,父组件更新时 Vue 直接改它
instance.props = isSSR ? props : shallowReactive(props)

// setup(props, ctx):只有开发模式才包一层 shallowReadonly
const setupResult = callWithErrorHandling(setup, instance, ErrorCodes.SETUP_FUNCTION, [
  __DEV__ ? shallowReadonly(instance.props) : instance.props,
  setupContext,
])

也就是说"子组件不能改 props"这条契约,在开发模式下由 shallowReadonly 的 set trap 发出警告来守,在生产模式下就只是一层 shallowReactive——写进去不会报错,只是不应该这么做。用 shallowReadonly 而不是 readonly,是因为 props 需要允许父组件显式传下来的 ref 保持为 ref(不解包),只在顶层拦写即可。

readonly vs Object.freeze

很多同学第一次接触 readonly() 时会问"为什么不用 Object.freeze 就好了?"——这是一个非常好的问题。JavaScript 原生的 Object.freeze 确实能阻止写操作、而且性能更好(没有 Proxy 开销)。但 freeze 和 readonly 解决的问题其实不同:freeze 改变的是对象本身的物理属性(让属性不可修改)、readonly 只是创建一个"看起来只读"的代理壳(原对象还是能被修改)。差别在哪里?freeze 之后原对象就是冻的、所有持有引用的地方都不能写;readonly 只是给这个代理壳加约束、原始对象依然能被它的持有者修改——这样就能实现"消费者只读、生产者可写"的单向数据流。Vue 的场景就是后者——组件接收 props 应该是只读的、但父组件仍然要能修改数据源。freeze 会把父组件也冻住、破坏整个数据流。这就是为什么 Vue 必须自己实现 readonly 而不是用原生 API。

维度 readonly() Object.freeze()
层级 深层递归 仅顶层
响应式 保持响应式追踪 破坏响应式(不可代理)
运行时检测 ✅(拦截写入) 静默失败或严格模式报错
开发警告 ✅(仅开发环境) ❌
性能 有 Proxy 开销 零开销
可逆 ✅(通过 toRaw 获取原始对象) ❌(不可逆)

🔥 深度洞察

readonly 在架构层面扮演着"契约执行者"的角色。在大型应用中,数据的流动方向至关重要——props 应该从父组件流向子组件,子组件不应该直接修改 props。readonly 在运行时强制执行这个契约。在 Vue 2 中,修改 props 只会产生一个容易被忽略的控制台警告。在 Vue 3 的开发模式下,shallowReadonly 代理让这个约束更加显式——你不是"不应该"修改,而是"无法"修改(生产模式下不包这一层,见上一小节)。这种从"软约束"到"硬约束"的演进,是框架成熟化的标志。

5.5 响应式工具函数全景

前四节介绍了响应式系统的核心 API——这一节补上它们周围的"工具函数生态"。如果说核心 API 是"主菜",工具函数就是"调味料"——单独看不起眼,用上之后菜的风味完全不同。

@vue/reactivity 除了核心 API 外,还提供了一组实用的工具函数。让我们逐一解析。

这一组工具函数是响应式系统的"万能配件箱"——每一个都短小、每一个都在某个场景下救命。它们分为三类:"逃生通道"类(toRaw / markRaw——临时退出响应式)、"形态转换"类(toRef / toRefs / unref——在 ref 和原始值之间自由切换)、"检测判断"类(isRef / isReactive / isProxy——代码里判断变量是否响应式)。实战中这些工具函数用得最多的是前两类——VueUse 库里几乎每个 composable 都反复使用它们。把它们熟练掌握,你写 composable 的能力会跃升一个档次。

toRaw:获取原始对象

toRaw 是最常被低估的响应式工具函数——很多人知道它但不知道什么时候该用。它的典型使用场景有三类:(1)需要把响应式对象传给不了解响应式的库(比如 fetch 的 body、JSON.stringify、第三方图表库);(2)想用原始对象做严格引用比较(代理对象不等于原始对象);(3)需要在不触发依赖追踪的情况下读写原始数据(比如存入 sessionStorage 前取出原始对象再 JSON.stringify——注意 toRaw 返回的是同一个原始对象而不是拷贝,之后对响应式对象的修改仍会反映在它身上,真正的"快照"来自序列化那一步)。这三类场景每一个在真实项目里都很常见——toRaw 一旦学会就会被你反复用到。

const state = reactive({ count: 0 })
const raw = toRaw(state)  // 返回原始的 { count: 0 }

raw === state  // false
raw.count = 1  // 不触发任何更新

toRaw 的实现里有一个小细节值得注意——它是递归的:如果代理背后还有代理(多层包装),它会一路拆到最底层。这个细节在大多数场景下用不到,但遇到"readonly(reactive(obj))"这种嵌套包装时会体现价值。

实现:

export function toRaw<T>(observed: T): T {
  const raw = observed && (observed as any)[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed  // ← 递归解包(应对多层代理)
}

toRaw 通过读取 ReactiveFlags.RAW 属性获取原始对象。这个属性在 Proxy 的 get trap 中被特殊处理——当检测到对 __v_raw 的访问时,直接返回原始 target。

markRaw:标记不可代理

markRaw 的使用场景和 toRaw 相反:toRaw 是"我有一个代理、想拿到它的原始",markRaw 是"我有一个原始、想让它永远不被代理"。典型场景:第三方类的实例(D3 选择集、Three.js Object3D、ECharts instance)、大型只读字典(枚举表、i18n 语言包)、性能敏感的热点对象(游戏里的每帧 state)。用 markRaw 标记这些对象后,即使它们被放进 reactive 容器里也不会被代理——避免了 Proxy 的开销、也避免了某些库因为被代理而触发的内部保护机制。

const obj = markRaw({ count: 0 })
const state = reactive({ obj })

isReactive(state.obj)  // false — obj 不会被代理

实现极其简单——给对象添加一个标记:

// packages/reactivity/src/reactive.ts:420(rc.4)
export function markRaw<T extends object>(value: T): Raw<T> {
  if (!hasOwn(value, ReactiveFlags.SKIP) && Object.isExtensible(value)) {
    def(value, ReactiveFlags.SKIP, true)
  }
  return value
}

reactive() 在创建代理前会检查这个 __v_skip 标记,如果存在就直接返回原始对象。这一步在 createReactiveObject() 里,和 Object.isExtensible 写在同一个 if 上:if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) { return target }(packages/reactivity/src/reactive.ts:288),跑在类型判定 targetTypeMap()(packages/reactivity/src/reactive.ts:43,调用点 packages/reactivity/src/reactive.ts:296)之前。多出来的 Object.isExtensible 判断是为了兼容已经被 Object.freeze 的对象——冻结对象本来就不能加属性,直接原样返回、不需要标记。

toRefs:批量解构不丢失响应式

toRefs 是 composable 作者的必备工具——几乎每个返回多个状态的 composable 都在最后用 toRefs 把 reactive 对象展开成 ref 集合。这样消费者可以自由解构、每个解构出来的字段都保持响应式。这个模式在 VueUse 里被发扬到极致——数一下 VueUse 里有多少个 composable 用了 toRefs,你会发现是主流用法而不是边角 case。

const state = reactive({ name: 'Vue', version: 3.6 })

// ❌ 解构后丢失响应式
const { name: plainName } = state  // plainName 是普通字符串

// ✅ toRefs 保持响应式
const { name, version } = toRefs(state)
// name 和 version 是 ref,仍然是响应式的
console.log(name.value)  // 'Vue'

实现:

// packages/reactivity/src/ref.ts:422(精简,rc.4)
export function toRefs<T extends object>(object: T): ToRefs<T> {
  const ret: any = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = propertyToRef(object, key)
  }
  return ret
}

function propertyToRef(source: Record<PropertyKey, any>, key: PropertyKey, defaultValue?: unknown) {
  return new ObjectRefImpl(source, key, defaultValue) as any
}

class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly [ReactiveFlags.IS_REF] = true
  public _value: T[K] = undefined!

  constructor(
    private readonly _object: T,
    key: K,
    private readonly _defaultValue?: T[K],
  ) {  }

  get value() {
    let val = this._object[this._key]          // ← 每次读取都转发到源对象
    if (this._shallow) val = unref(val)
    return (this._value = val === undefined ? this._defaultValue! : val)
  }

  set value(newVal) {
    this._object[this._key] = newVal           // ← 每次写入都转发到源对象
  }

  get dep(): ReactiveNode | undefined {
    return getDepFromReactive(this._raw, this._key)   // triggerRef 用
  }
}

ObjectRefImpl 不持有独立的值——它只是一个"代理 ref",读写都转发到源对象的属性上(_value 字段只是给 devtools 看的缓存)。因为源对象是 reactive 的,读写操作自然会触发依赖收集和更新通知。toRef(obj, key, defaultValue) 走的也是同一个 propertyToRef。

工具函数速查表

下面这张表是响应式工具函数的完整速查表——建议保存到自己的笔记里。工具函数的最大挑战不是理解它们的实现(每个的代码都不超过 10 行),而是在什么场景下想起来用哪个。下面每个工具函数的典型场景都值得你在自己的项目里至少用一次——只有亲手用过,你才会真正掌握它们。

函数 用途 返回类型
isRef(val) 检查是否是 ref boolean
isReactive(val) 检查是否是 reactive 代理 boolean
isReadonly(val) 检查是否是 readonly 代理 boolean
isProxy(val) 检查是否是 reactive 或 readonly boolean
unref(val) 如果是 ref 则返回 .value,否则返回自身 T
toRaw(proxy) 获取代理背后的原始对象 原始对象
markRaw(obj) 标记对象不可被代理 原始对象
toRefs(reactive) 将 reactive 对象的每个属性转为 ref { [K]: Ref }
toRef(obj, key) 将对象的单个属性转为 ref Ref
triggerRef(ref) 手动触发 shallowRef 的更新 void
customRef(factory) 创建自定义 ref Ref

customRef:自定义依赖追踪

customRef 是响应式系统里最"逃逸舱口"的 API——它把 track / trigger 的控制权直接暴露给用户。这是非常危险的能力、但也是解锁特定需求的钥匙。普通 ref 的依赖收集和触发规则是"读即追踪、写即触发",但有些场景需要打破这个规则——比如防抖(输入时不立即触发、停顿后才触发)、节流(高频变化只保留最后一次)、异步校验(值变化后等验证完成才算"真正变化")。这些场景靠普通 ref + watch 也能做,但代码会很散;customRef 让你把整个"时序控制 + 数据"封装成一个对消费者透明的响应式原语——消费方看到的是一个普通 ref,不知道它内部在做任何时序魔法。这种"把复杂度封装到对象内部"的能力是高级 Vue 工程师的必备技能。

customRef 让你完全控制 ref 的依赖收集和更新触发时机:

import { customRef } from 'vue'

// 防抖 ref — 值变化后延迟 delay 毫秒才触发更新
function useDebouncedRef<T>(value: T, delay = 200) {
  let timeout: ReturnType<typeof setTimeout>

  return customRef<T>((track, trigger) => ({
    get() {
      track()      // 手动调用依赖收集
      return value
    },
    set(newValue: T) {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        value = newValue
        trigger()  // 延迟触发更新
      }, delay)
    }
  }))
}

// 使用
const searchQuery = useDebouncedRef('', 300)
// 用户连续输入时,只有停顿 300ms 后才触发搜索

实现:

// packages/reactivity/src/ref.ts:387(精简,rc.4)

export function customRef<T, S = T>(factory: CustomRefFactory<T, S>): Ref<T, S> {
  return new CustomRefImpl(factory) as any
}

class CustomRefImpl<T, S = T> implements ReactiveNode {
  // 自己就是一个 Dep 节点:直接持有订阅者链表
  subs: Link | undefined = undefined
  subsTail: Link | undefined = undefined
  flags: _ReactiveFlags = _ReactiveFlags.None

  private readonly _get: ReturnType<CustomRefFactory<T, S>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T, S>>['set']
  public readonly [ReactiveFlags.IS_REF] = true
  public _value: T = undefined!

  constructor(factory: CustomRefFactory<T, S>) {
    const { get, set } = factory(
      () => trackRef(this),                       // track:link(this, activeSub)
      () => triggerRef(this as unknown as Ref),   // trigger:propagate + shallowPropagate + flush
    )
    this._get = get
    this._set = set
  }

  get dep() { return this }

  get value(): T {
    return (this._value = this._get())
  }

  set value(newVal: S) {
    this._set(newVal)
  }
}

注意 CustomRefImpl 没有单独的 Dep 对象——在 Alien Signals 架构下,它自己就实现了 ReactiveNode,subs / subsTail 直接长在 ref 上。RefImpl、ComputedRefImpl 也是同样的做法:get dep() 只是返回 this。ComputedRefImpl 源码里明确标注这个 getter 是为了向后兼容;而 RefImpl / CustomRefImpl 的 dep 还有实际用途——triggerRef() 就是统一读 ref.dep 来拿订阅者链的(上面 trigger 回调走的正是它)。

💡 最佳实践

customRef 是实现防抖、节流、异步验证等模式的利器。与在 watch 回调中手动管理定时器相比,customRef 将"数据"和"时序控制"封装在同一个响应式原语中,使得消费者完全不需要知道内部的时序逻辑——它只是一个普通的 ref,用起来和其他 ref 没有任何区别。

5.6 响应式系统的全景架构

到本节为止,我们完整走过了 @vue/reactivity 的所有核心部件:reactive / ref(第 4 章)、track / trigger(第 4 章)、computed(第 4 章)、effect / effectScope(本章)、shallowReactive / readonly(本章)、工具函数(本章)。这些部件不是孤立存在的——它们之间有紧密的协作关系。本节把它们在一张图里串起来,让你对响应式系统有一个完整的拼图感。这张图就是第 3-5 章所有讨论的"最终成品"——读完再回头看,你会感到一种"地图全都画完了"的踏实。

重点强调一下这张架构图的学习价值:Vue 响应式系统的所有 API 看起来五花八门(ref / reactive / computed / watch / effect / effectScope / readonly / shallowRef ……),但它们全都服务于同一个核心目标——"在数据和副作用之间建立精确的、可管理的、可清理的因果关系"。下面的架构图就是这个核心目标的可视化呈现——一切 API 都能在图上找到自己的位置。把这张图印在脑子里,以后你看任何响应式相关的代码都能迅速定位到具体位置,bug 排查会变得轻松得多。

到这里,我们已经解析了 @vue/reactivity 的全部核心实现。让我们用一张架构图串联所有概念:

graph TB
    subgraph "创建层"
        reactive["reactive()"]
        ref["ref()"]
        computed["computed()"]
        shallowReactive["shallowReactive()"]
        shallowRef["shallowRef()"]
        readonly["readonly()"]
    end
    subgraph "追踪层"
        Proxy["Proxy Handler<br/>get → track<br/>set → trigger"]
        RefImpl["RefImpl<br/>getter → track<br/>setter → trigger"]
        track["track()"]
        trigger["trigger()"]
    end
    subgraph "存储层"
        targetMap["targetMap<br/>WeakMap → Map → Dep"]
        Dep["Dep<br/>subs / subsTail / flags"]
        Link["Link<br/>双向链表节点(7 字段)"]
    end
    subgraph "执行层"
        Effect["ReactiveEffect<br/>fn, deps, flags"]
        EffectScope["EffectScope<br/>deps 链:子 effect / 子 scope"]
        Scheduler["Scheduler<br/>queueJob / queueFlush"]
    end
    reactive --> Proxy
    shallowReactive --> Proxy
    readonly --> Proxy
    ref --> RefImpl
    shallowRef --> RefImpl
    computed --> RefImpl
    Proxy --> track
    Proxy --> trigger
    RefImpl --> track
    RefImpl --> trigger
    track --> targetMap
    targetMap --> Dep
    Dep --> Link
    Link --> Effect
    trigger --> Dep
    Dep -->|propagate| Effect
    Effect --> Scheduler
    Effect -.->|link 到| EffectScope
    style reactive fill:#4ecdc4,stroke:#333
    style ref fill:#4ecdc4,stroke:#333
    style computed fill:#ffd93d,stroke:#333
    style Effect fill:#ff6b6b,stroke:#333,color:#333
    style EffectScope fill:#ff6b6b,stroke:#333,color:#333

5.7 本章小结

本章加上第 4 章覆盖了 @vue/reactivity 对外暴露的主要 API。关键要点:

  1. ReactiveEffect 是响应式系统的执行引擎。每次执行时通过 startTracking → link → endTracking 的"顺序游标复用 + 尾部裁剪"策略管理依赖的增减,确保不会保留过期依赖。

  2. effectScope 实现了副作用的批量管理。它和 effect 共用 ReactiveNode 接口,用同一套 Link 链表把内部的 effect / computed / 子 scope 串成树;每个组件实例持有一个 detached scope,卸载时 scope.stop() 一刀清理。

  3. shallowReactive / shallowRef 通过跳过嵌套对象的递归代理来优化性能,适用于大型数据结构和不可变数据模式。

  4. readonly 在运行时强制执行"不可写"的契约;Vue 3 在开发模式下用 shallowReadonly 守住 props 的单向数据流。

  5. 工具函数(toRaw、markRaw、toRefs、customRef 等)提供了对响应式系统的精细控制,让开发者可以在性能和便利性之间灵活取舍。

  6. 整个 @vue/reactivity 的架构可以概括为四层:创建层(API 入口)→ 追踪层(Proxy/getter 拦截)→ 存储层(Dep + Link 链表)→ 执行层(Effect + Scope + Scheduler)。

四层分层可用于响应式 bug 定位:数据变化没触发更新 → 追踪层(Proxy trap 没收集到依赖);依赖没清理 → 存储层(Link 链表没更新)或执行层(Effect 没 stop);组件卸载后副作用继续运行 → 执行层(EffectScope 没被正确 stop)。

骨架:数据变化 → dep 被 trigger → propagate 沿 Link 链表打 Pending 标记(reactive 属性写入再由 shallowPropagate 把直接下游升成 Dirty)→ effect 进 notifyBuffer → flush 时 notify → 组件渲染 effect 交给 queueJob 异步执行、裸 effect 同步执行 / 下次读 computed 时 checkDirty 校验后重算。下一章讲 Alien Signals,会把这个骨架里的每个函数——link、propagate、checkDirty、shallowPropagate——逐行拆开。

延伸阅读

  • Vue 3 源码 packages/reactivity/src/effect.ts、effectScope.ts、system.ts、baseHandlers.ts:本章讨论的主要文件。
  • VueUse 源码:大量使用 effectScope、shallowRef、customRef 的实战案例库,每个 composable 都值得读。
  • Angular 官方文档 Signals & reactivity:Angular 自 v16(2023 年)起引入 Signals,其 effect / DestroyRef 和 Vue 的 effect / effectScope 概念高度相似。
  • RxJS 官方文档 Subscription 章节:RxJS 的 Subscription 和 Vue 的 effectScope 在生命周期管理思路上有深刻类似,对比阅读有启发。
  • Michel Weststrate Becoming fully reactive: an in-depth explanation of MobX(Medium):MobX 作者的经典文章,其中对 autorun / reaction / computed 的区分和 Vue 的 effect / watch / computed 异曲同工,能帮你从另一个角度理解响应式系统的组件分工。

思考题

  1. 概念理解:effectScope 的树形结构如何与 Vue 的组件树关联?当一个组件被卸载时,EffectScope 的 stop() 是在哪里被调用的?为什么组件 scope 要用 detached = true 创建?(提示:查看 packages/runtime-core/src/component.ts 的 createComponentInstance 和 renderer.ts 的 unmountComponent)

  2. 深入思考:link() 的"顺序游标复用"依赖于 effect 两次执行访问依赖的顺序基本一致。如果一个 effect 每次执行时以随机顺序读取同样的 10 个 ref,会发生什么?和 3.5 用 link.version = -1 做"标记-清扫"的 prepareDeps / cleanupDeps 相比,各自的最好 / 最坏情况开销是多少?

  3. 工程实践:在什么场景下应该使用 shallowRef 而非 ref?如果你有一个包含 1000 条记录的列表,每条记录有 20 个嵌套字段,使用 ref 和 shallowRef 在内存占用上大约有多大差异?

  4. 设计分析:readonly 的 set trap 在生产环境中不输出警告(if (__DEV__))。如果在生产环境中也强制抛出错误,会有什么问题?为什么 Vue 选择了静默失败?

  5. 开放讨论:customRef 将依赖追踪的控制权交给了开发者。这种设计是否违背了 Vue 响应式系统"自动追踪"的核心哲学?在什么情况下,手动控制追踪是必要的?