Vue 3 设计与实现

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

作者 杨艺韬 · 10,156 字

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

本章要点

  • effect() 的完整生命周期:创建、执行、依赖收集、清理、销毁
  • effectScope:为什么需要”作用域”来管理副作用
  • shallowReactive / shallowRef:何时需要”浅层”响应式
  • readonly / shallowReadonly:编译器如何利用只读优化
  • 响应式工具函数全景: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 章的地基上搭建一层管理层”——地基不熟就搭上去的楼会摇晃。

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

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

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

effect 的本质

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

effect 是响应式系统中”副作用”的载体。当你写 watchEffectwatch,或者 Vue 内部创建组件的渲染更新函数时,底层都是 effect

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 也实现了 Subscriber 接口)。读完这一小节你应该能从”对 effect 的感觉”上升到”对 ReactiveEffect 类的清晰理解”——以后所有上层 API 的工作机制,你都可以推回到 ReactiveEffect 的执行流程上。

// packages/reactivity/src/effect.ts(简化)

export class ReactiveEffect<T = any> implements Subscriber {
  // --- Subscriber 接口 ---
  _deps: Link | undefined = undefined
  _depsTail: Link | undefined = undefined
  _flags: number = SubscriberFlags.ACTIVE

  // --- Effect 特有 ---
  _fn: () => T
  _scheduler: EffectScheduler | undefined
  _cleanup: (() => void) | undefined

  // 当前作用域
  _scope: EffectScope | undefined

  constructor(fn: () => T) {
    this._fn = fn
    // 自动注册到当前活跃的 EffectScope
    if (activeEffectScope) {
      this._scope = activeEffectScope
      activeEffectScope.effects.push(this)
    }
  }

  run(): T {
    // 1. 如果已停止,直接执行函数(不收集依赖)
    if (!(this._flags & SubscriberFlags.ACTIVE)) {
      return this._fn()
    }

    // 2. 设置当前活跃 subscriber
    const prevSub = activeSub
    activeSub = this

    // 3. 开启追踪
    const prevShouldTrack = shouldTrack
    shouldTrack = true

    try {
      // 4. 准备清理旧依赖
      prepareDeps(this)

      // 5. 执行函数 — 触发 getter → track → 建立新依赖
      const result = this._fn()

      // 6. 清理不再需要的旧依赖
      cleanupDeps(this)

      return result
    } finally {
      // 7. 恢复之前的上下文
      activeSub = prevSub
      shouldTrack = prevShouldTrack
    }
  }

  stop(): void {
    if (this._flags & SubscriberFlags.ACTIVE) {
      // 清理所有依赖链接
      removeDeps(this)
      // 执行清理回调
      if (this._cleanup) {
        this._cleanup()
      }
      this._flags &= ~SubscriberFlags.ACTIVE
    }
  }
}

依赖的生命周期管理

很多同学读响应式源码最困惑的地方就是”依赖怎么就既加了又删呢?“——因为这部分涉及到 effect 的重复执行、依赖图的动态变化,是整个响应式系统里最”动态”的部分。本小节用一个条件渲染的例子把这个问题讲透——你会看到 Alien Signals 的 prepareDeps + cleanupDeps 为什么不是简单的”全删全建”、而是”标记-清扫”。这个精巧设计是 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.valuetrue 变为 false 时,effect 重新执行。此时它不再依赖 a,而是依赖 b。旧的对 a 的依赖必须被清理,否则 a.value 变化时仍会触发这个 effect——这就是”过期依赖”问题。

// packages/reactivity/src/dep.ts(简化)

function prepareDeps(sub: Subscriber): void {
  // 遍历所有旧的依赖 Link,标记版本号为 -1
  let link = sub._deps
  while (link) {
    link.version = -1  // ← 标记为"待验证"
    link = link.nextDep
  }
}

function cleanupDeps(sub: Subscriber): void {
  // 遍历所有 Link,移除版本号仍为 -1 的(未被重新访问的依赖)
  let link = sub._deps
  let prev: Link | undefined

  while (link) {
    const next = link.nextDep
    if (link.version === -1) {
      // 这个依赖在最近一次执行中没有被访问 → 移除
      unlinkDep(link)
      if (prev) {
        prev.nextDep = next
      } else {
        sub._deps = next
      }
    } else {
      prev = link
    }
    link = next
  }
  sub._depsTail = prev
}

🔥 深度洞察

prepareDeps + cleanupDeps 的”标记-清扫”策略,与 GC(垃圾回收)的标记-清扫算法如出一辙。在 Vue 3.0–3.4 中,处理过期依赖的方式是”先全部删除,再重新收集”——每次执行都从零开始。Alien Signals 的方式更聪明:先标记所有旧依赖为”待验证”(version = -1),执行过程中重新访问的依赖会被更新为新版本号。执行结束后,仍然是 -1 的就是过期依赖——只移除这些。这样,稳定的依赖(每次执行都被访问的)不需要任何删除-重建操作,只有真正变化的依赖才产生开销。

onCleanup——副作用的清理

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

Vue 3.5 引入了 onCleanup 回调,让 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._cleanup,在下一次 effect.run() 之前被调用:

// packages/reactivity/src/effect.ts(简化)

function run() {
  // 在执行新函数之前,调用上一次注册的清理函数
  if (this._cleanup) {
    this._cleanup()
    this._cleanup = undefined
  }
  // ... 执行 this._fn()
}

5.2 effectScope:批量管理副作用

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

问题场景

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

在复杂的组合式函数中,你可能创建多个 watchwatchEffectcomputed

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

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

  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 形成的是一棵树——组件 A 的 scope 里可以有子组件 B 的 scope、B 的 scope 里又可以有 useXxx 的子 scope。读下面的实现时注意 scopes: EffectScope[]parent: EffectScope | undefined 这两个字段——它们就是构建”作用域树”的骨架。一个 scope stop 时会递归 stop 所有子 scope——这让整个树的清理变成”根上一刀”的操作。对比”每个组件手动维护清理列表”的方式,这个设计在 50-100 层深的组件树里节省的工作量是指数级的。

// packages/reactivity/src/effectScope.ts(简化)

export let activeEffectScope: EffectScope | undefined

export class EffectScope {
  _active = true
  effects: ReactiveEffect[] = []
  cleanups: (() => void)[] = []

  // 子作用域(树形结构)
  scopes: EffectScope[] | undefined
  parent: EffectScope | undefined

  constructor(detached = false) {
    // 如果不是"分离的",自动注册到父作用域
    if (!detached && activeEffectScope) {
      this.parent = activeEffectScope
      ;(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(this)
    }
  }

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

  stop(fromParent?: boolean): void {
    if (this._active) {
      // 停止所有收集到的 effect
      for (const effect of this.effects) {
        effect.stop()
      }
      // 执行所有清理回调
      for (const cleanup of this.cleanups) {
        cleanup()
      }
      // 递归停止子作用域
      if (this.scopes) {
        for (const scope of this.scopes) {
          scope.stop(true)
        }
      }
      // 从父作用域中移除自己
      if (!fromParent && this.parent) {
        const i = this.parent.scopes!.indexOf(this)
        if (i > -1) this.parent.scopes!.splice(i, 1)
      }
      this._active = false
    }
  }
}

作用域树

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

EffectScope 形成树形结构——组件实例有自己的作用域,组合式函数可以在其中创建子作用域:

graph TD
    Root["根作用域<br/>(App 组件)"]
    A["组件 A 的作用域"]
    B["组件 B 的作用域"]
    C["useFeature() 的作用域"]
    D["useAuth() 的作用域"]

    Root --> A
    Root --> B
    A --> C
    A --> D

    C --> E1["watchEffect 1"]
    C --> E2["watch 1"]
    D --> E3["watchEffect 2"]

    style Root fill:#4ecdc4,stroke:#333
    style A fill:#ffd93d,stroke:#333
    style B fill:#ffd93d,stroke:#333
    style C fill:#ff6b6b,stroke:#333,color:#fff
    style D fill:#ff6b6b,stroke:#333,color:#fff

当组件 A 被卸载时,它的作用域调用 stop(),递归停止所有子作用域——useFeature()useAuth() 中创建的所有 effect 都被自动清理。

🔥 深度洞察

EffectScope 的树形结构与 DOM 树和组件树形成了三棵平行的树。当一个组件被卸载时:(1)DOM 节点从 DOM 树中移除;(2)组件实例从组件树中移除;(3)EffectScope 从作用域树中移除并停止所有副作用。这种”三树同步”的设计确保了资源清理的完整性——不会出现”组件卸载了但定时器还在跑”的内存泄漏问题。Vue 内部将组件实例的 setup() 执行包裹在一个 EffectScope 中,这就是为什么在 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,就会出现”第一个 provide pinia 的组件卸载了、整个 pinia 挂掉、所有 store 失效”的问题——detached 的 true 这个小参数把这种风险彻底堵死。一个布尔值参数背后往往藏着一整套设计考虑——这种”小参数、大后果”的细节在 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 几乎相同,唯一的区别在 get trap 中:

// packages/reactivity/src/baseHandlers.ts(简化)

// reactive 的 get — 递归代理
function reactiveGet(target, key, receiver) {
  const res = Reflect.get(target, key, receiver)
  track(target, TrackOpTypes.GET, key)
  if (isObject(res)) {
    return reactive(res)  // ← 递归代理
  }
  return res
}

// shallowReactive 的 get — 不递归
function shallowReactiveGet(target, key, receiver) {
  const res = Reflect.get(target, key, receiver)
  track(target, TrackOpTypes.GET, key)
  // 不递归!直接返回原始值
  return res
}

就这么简单——去掉一行 reactive(res),嵌套对象就不再被代理。

这种”去掉一行代码就是一个新 API”的模式在 Vue 源码里随处可见——reactiveshallowReactive 的差别就一行、refshallowRef 的差别也就一行、readonlyshallowReadonly 也是……这种”一个字段控制行为”的设计让 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'  // 不触发!

实现上,shallowRefRefImpl 构造函数中跳过 toReactive 调用:

constructor(value: T, isShallow: boolean) {
  this._rawValue = isShallow ? value : toRaw(value)
  this._value = isShallow ? value : toReactive(value)
  //                        ↑ shallow 时不调用 toReactive
}

💡 最佳实践

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

// 推荐:大量数据用 shallowRef
const users = shallowRef&lt;User[]&gt;([])

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

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

readonly 是第 5 章里”用工具保证纪律”哲学最典型的落地。它本身不做任何新的响应式能力——它只是在 reactive 的基础上禁用写操作。但这个”禁用”的价值很大:它让”这个数据不该被修改”从开发约定变成了运行时强制。Vue 自己大量使用 readonly——defineProps 的返回、inject 的默认建议、Pinia store 的 state(通过 readonly() 暴露)——所有”消费方只应该读、不应该写”的场景都用 readonly 包装。

readonly 的设计意图

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

readonly() 创建一个只读的响应式代理。读取属性仍然会被追踪(用于依赖收集),但任何写入操作都会被拦截并在开发环境下发出警告:

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 中做依赖收集(但不递归为 reactive),setdeleteProperty 直接返回而不执行:

// packages/reactivity/src/baseHandlers.ts(简化)

export const readonlyHandlers: ProxyHandler<object> = {
  get(target, key, receiver) {
    if (key === ReactiveFlags.IS_READONLY) return true
    if (key === ReactiveFlags.RAW) return target

    const res = Reflect.get(target, key, receiver)
    // 依赖收集(readonly 也需要追踪,因为原始对象可能被修改)
    track(target, TrackOpTypes.GET, key)

    if (isObject(res)) {
      return readonly(res)  // ← 嵌套对象也变为 readonly
    }
    return res
  },

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

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

readonly 在编译器优化中的作用

响应式系统和编译器的协作是 Vue 3 相对其他框架的独特优势之一。readonly 就是这种协作的一个典型例子——响应式系统提供”不可写”的运行时标记,编译器读取这个标记做针对性优化。这种”运行时提供信息、编译期利用信息”的模式在 Vue 3 里大量出现——patchFlag(第 11 章)、block tree(第 11 章)、vapor(第 9 章)都是这种思路的不同应用。如果你想理解”什么是现代前端框架”,这种”编译器 + 运行时联合设计”就是答案的一部分。

Vue 的编译器会利用 readonly 信息进行优化。当编译器检测到一个 prop 被传递给子组件时,它知道 props 是只读的——子组件不会修改它。这让编译器可以跳过对 props 的变更检测:

// 编译器知道 props 是 readonly 的
// 因此不需要为 props 的变化设置 watcher
const props = defineProps<{ msg: string }>()
// props 内部就是 shallowReadonly(rawProps)

readonly vs Object.freeze

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

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

🔥 深度洞察

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

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)。这三类场景每一个在真实项目里都很常见——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 不会被代理

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

export function markRaw<T extends object>(value: T): Raw<T> {
  def(value, ReactiveFlags.SKIP, true)
  return value
}

reactive() 在创建代理前会检查这个标记,如果存在就直接返回原始对象。

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

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

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

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

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

实现:

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] = toRef(object, key)
  }
  return ret
}

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K
): Ref<T[K]> {
  return new ObjectRefImpl(object, key)
}

class ObjectRefImpl<T extends object, K extends keyof T> {
  readonly [ReactiveFlags.IS_REF] = true

  constructor(
    private readonly _object: T,
    private readonly _key: K
  ) {}

  get value(): T[K] {
    return this._object[this._key]  // ← 每次读取都代理到原始对象
  }

  set value(newVal: T[K]) {
    this._object[this._key] = newVal  // ← 每次写入都代理到原始对象
  }
}

ObjectRefImpl 不存储自己的值——它只是一个”代理 ref”,读写都转发到原始对象的属性上。因为原始对象是 reactive 的,读写操作自然会触发依赖收集和更新通知。

工具函数速查表

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

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

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

export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  return new CustomRefImpl(factory)
}

class CustomRefImpl<T> {
  private readonly _get: () => T
  private readonly _set: (value: T) => void
  public readonly dep = new Dep()
  public readonly [ReactiveFlags.IS_REF] = true

  constructor(factory: CustomRefFactory<T>) {
    const { get, set } = factory(
      () => this.dep.track(),    // track 函数
      () => this.dep.trigger()   // trigger 函数
    )
    this._get = get
    this._set = set
  }

  get value() {
    return this._get()
  }

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

💡 最佳实践

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/>_version, _subs"]
        Link["Link<br/>双向链表节点"]
    end

    subgraph "执行层"
        Effect["ReactiveEffect<br/>_fn, _deps, _flags"]
        EffectScope["EffectScope<br/>effects[], scopes[]"]
        Scheduler["Scheduler<br/>queueJob / queueFlush"]
    end

    reactive --> Proxy
    shallowReactive --> Proxy
    readonly --> Proxy
    ref --> RefImpl
    shallowRef --> RefImpl
    computed --> RefImpl
    computed --> Effect

    Proxy --> track
    Proxy --> trigger
    RefImpl --> track
    RefImpl --> trigger

    track --> targetMap
    targetMap --> Dep
    Dep --> Link
    Link --> Effect

    trigger --> Dep
    Dep -->|propagate| Effect
    Effect --> Scheduler

    Effect -.->|注册到| 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:#fff
    style EffectScope fill:#ff6b6b,stroke:#333,color:#fff

5.7 本章小结

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

  1. ReactiveEffect 是响应式系统的执行引擎。每次执行时通过”标记-清扫”策略管理依赖的增减,确保不会保留过期依赖。

  2. effectScope 实现了副作用的批量管理。作用域形成树形结构,与组件树平行,确保组件卸载时所有副作用被自动清理。

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

  4. readonly 在运行时强制执行”不可写”的契约,是 Vue 3 中 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 → 沿 Link 链表传播 → 通知 computed 标脏 / effect 调度 → 下次读 computed 触发 checkDirty / 微任务里执行 effect。下一章讲 Alien Signals 将在这个骨架上重写存储层和执行层。

延伸阅读

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

思考题

  1. 概念理解effectScope 的树形结构如何与 Vue 的组件树关联?当一个组件被卸载时,EffectScope 的 stop() 是在哪里被调用的?(提示:查看 packages/runtime-core/src/component.ts

  2. 深入思考prepareDeps + cleanupDeps 的”标记-清扫”策略与 GC 的标记-清扫算法有什么相似点和不同点?这种策略相比 Vue 3.0 的”全删全建”有什么具体的性能优势?

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

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

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


延伸阅读:调试三板斧

  • Vue DevTools:浏览器扩展,展示组件的响应式状态、触发重渲染的原因、computed 当前值。日常 90% 的响应式问题靠它就能定位。
  • onRenderTracked / onRenderTriggered:Vue 提供的生命周期钩子,在组件重渲染时告诉你哪些依赖被命中 / 被触发——排查”组件为什么频繁更新”非常有用。
  • effect 选项 onTrack / onTrigger:定制到某一个 effect 级别,更底层。

延伸阅读:响应式清理常见陷阱

  • setup 外部创建 effect:effect 不绑定任何组件,永远不会清理,造成内存泄漏。修:要么在 setup 内创建,要么用 effectScope 显式管理。
  • watchEffect 里的 setTimeout 忘了清理:定时器不是响应式依赖,下次 effect 执行时老定时器还在跑,累积成孤儿定时器。修:watchEffect((onCleanup) => { const t = setTimeout(...); onCleanup(() => clearTimeout(t)) })
  • async watchEffect 只追踪第一个 await 之前的读取:之后的响应式读取不会被捕获。修:把响应式读取都放在第一个 await 之前,或改用 asyncComputed/useAsyncState
  • 循环依赖:A 的 effect 改 B、B 的 effect 改 A。Vue 有递归检测(MAX_RECURSION_LIMIT)但某些边界会绕过。修:设计成单向数据流。
  • watch 忘了用 oldVal:需要前后对比的场景下只用 newVal 会出错且测试阶段难暴露。

延伸阅读:shallowReactive 选型

三类场景优先考虑 shallow:

  1. 大型只读数据(后端响应 JSON、已冻结的业务数据)——深度代理没意义。
  2. 性能敏感的高频读写(实时图表、游戏帧循环)——Proxy 栈开销会被放大。
  3. 外部类实例(Three.js Mesh、Monaco Editor)——内部状态复杂,深度代理可能触发其内部断言失败。