Vue 3 设计与实现
第 5 章 @vue/reactivity 源码深度剖析(下):effect / effectScope / shallowReactive / readonly
第 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 是响应式系统里最底层也最强大的原语——所有上层的 watchEffect、watch、组件渲染函数,底下都是 effect。理解它相当于拿到整个响应式系统的万能钥匙——剩下那些上层 API 都是在 effect 基础上包装出来的特定用法。下面的代码你可能在很多教程里见过,但每次重读都能有新收获——因为当你对上层 API 理解得越深,回头看底层原语时能看出的”这里为什么要这样设计”就越多。
effect 是响应式系统中”副作用”的载体。当你写 watchEffect、watch,或者 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.value 从 true 变为 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 给这类痛苦的根治方案——它把”手动收集每个清理函数”变成”自动绑定到一个作用域”,错误的唯一方式是”你没用它”。
在复杂的组合式函数中,你可能创建多个 watch、watchEffect、computed:
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 源码里随处可见——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' // 不触发!
实现上,shallowRef 在 RefImpl 构造函数中跳过 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<User[]>([]) 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),set 和 deleteProperty 直接返回而不执行:
// 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 开销)。但 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 中,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) | 检查是否是 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
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。关键要点:
-
ReactiveEffect 是响应式系统的执行引擎。每次执行时通过”标记-清扫”策略管理依赖的增减,确保不会保留过期依赖。
-
effectScope 实现了副作用的批量管理。作用域形成树形结构,与组件树平行,确保组件卸载时所有副作用被自动清理。
-
shallowReactive / shallowRef 通过跳过嵌套对象的递归代理来优化性能,适用于大型数据结构和不可变数据模式。
-
readonly 在运行时强制执行”不可写”的契约,是 Vue 3 中 props 单向数据流的底层保障。
-
工具函数(toRaw、markRaw、toRefs、customRef 等)提供了对响应式系统的精细控制,让开发者可以在性能和便利性之间灵活取舍。
-
整个
@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.ts、effectScope.ts、baseHandlers.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 异曲同工,能帮你从另一个角度理解响应式系统的组件分工。
思考题
-
概念理解:
effectScope的树形结构如何与 Vue 的组件树关联?当一个组件被卸载时,EffectScope 的stop()是在哪里被调用的?(提示:查看packages/runtime-core/src/component.ts) -
深入思考:
prepareDeps+cleanupDeps的”标记-清扫”策略与 GC 的标记-清扫算法有什么相似点和不同点?这种策略相比 Vue 3.0 的”全删全建”有什么具体的性能优势? -
工程实践:在什么场景下应该使用
shallowRef而非ref?如果你有一个包含 1000 条记录的列表,每条记录有 20 个嵌套字段,使用ref和shallowRef在内存占用上大约有多大差异? -
设计分析:
readonly的settrap 在生产环境中不输出警告(if (__DEV__))。如果在生产环境中也强制抛出错误,会有什么问题?为什么 Vue 选择了静默失败? -
开放讨论:
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)) })。asyncwatchEffect 只追踪第一个await之前的读取:之后的响应式读取不会被捕获。修:把响应式读取都放在第一个await之前,或改用asyncComputed/useAsyncState。- 循环依赖:A 的 effect 改 B、B 的 effect 改 A。Vue 有递归检测(
MAX_RECURSION_LIMIT)但某些边界会绕过。修:设计成单向数据流。 watch忘了用oldVal:需要前后对比的场景下只用newVal会出错且测试阶段难暴露。
延伸阅读:shallowReactive 选型
三类场景优先考虑 shallow:
- 大型只读数据(后端响应 JSON、已冻结的业务数据)——深度代理没意义。
- 性能敏感的高频读写(实时图表、游戏帧循环)——Proxy 栈开销会被放大。
- 外部类实例(Three.js
Mesh、MonacoEditor)——内部状态复杂,深度代理可能触发其内部断言失败。