Vue 3 设计与实现
第 5 章 @vue/reactivity 源码深度剖析(下):effect / effectScope / shallowReactive / readonly
第 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 引入,首个 tagv3.2.0-beta.1;PR #5912 移除,首个 tagv3.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。关键要点:
-
ReactiveEffect 是响应式系统的执行引擎。每次执行时通过
startTracking → link → endTracking的"顺序游标复用 + 尾部裁剪"策略管理依赖的增减,确保不会保留过期依赖。 -
effectScope 实现了副作用的批量管理。它和 effect 共用
ReactiveNode接口,用同一套 Link 链表把内部的 effect / computed / 子 scope 串成树;每个组件实例持有一个 detached scope,卸载时scope.stop()一刀清理。 -
shallowReactive / shallowRef 通过跳过嵌套对象的递归代理来优化性能,适用于大型数据结构和不可变数据模式。
-
readonly 在运行时强制执行"不可写"的契约;Vue 3 在开发模式下用
shallowReadonly守住 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 → 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 异曲同工,能帮你从另一个角度理解响应式系统的组件分工。
思考题
-
概念理解:
effectScope的树形结构如何与 Vue 的组件树关联?当一个组件被卸载时,EffectScope 的stop()是在哪里被调用的?为什么组件 scope 要用detached = true创建?(提示:查看packages/runtime-core/src/component.ts的createComponentInstance和renderer.ts的unmountComponent) -
深入思考:
link()的"顺序游标复用"依赖于 effect 两次执行访问依赖的顺序基本一致。如果一个 effect 每次执行时以随机顺序读取同样的 10 个 ref,会发生什么?和 3.5 用link.version = -1做"标记-清扫"的prepareDeps/cleanupDeps相比,各自的最好 / 最坏情况开销是多少? -
工程实践:在什么场景下应该使用
shallowRef而非ref?如果你有一个包含 1000 条记录的列表,每条记录有 20 个嵌套字段,使用ref和shallowRef在内存占用上大约有多大差异? -
设计分析:
readonly的settrap 在生产环境中不输出警告(if (__DEV__))。如果在生产环境中也强制抛出错误,会有什么问题?为什么 Vue 选择了静默失败? -
开放讨论:
customRef将依赖追踪的控制权交给了开发者。这种设计是否违背了 Vue 响应式系统"自动追踪"的核心哲学?在什么情况下,手动控制追踪是必要的?