Appearance
第 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,完成响应式系统的最后几块拼图。
5.1 effect():响应式系统的执行引擎
effect 的本质
effect 是响应式系统中"副作用"的载体。当你写 watchEffect、watch,或者 Vue 内部创建组件的渲染更新函数时,底层都是 effect。
typescript
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 类
typescript
// 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.run() 执行时,需要处理一个微妙的问题:依赖可能在两次执行之间发生变化。
考虑这个场景:
typescript
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——这就是"过期依赖"问题。
typescript
// 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——副作用的清理
Vue 3.5 引入了 onCleanup 回调,让 effect 可以在每次重新执行前执行清理逻辑:
typescript
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() 之前被调用:
typescript
// packages/reactivity/src/effect.ts(简化)
function run() {
// 在执行新函数之前,调用上一次注册的清理函数
if (this._cleanup) {
this._cleanup()
this._cleanup = undefined
}
// ... 执行 this._fn()
}5.2 effectScope:批量管理副作用
问题场景
在复杂的组合式函数中,你可能创建多个 watch、watchEffect、computed:
typescript
function useFeature() {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const formatted = computed(() => /* ... */)
watchEffect(() => { /* 自动请求数据 */ })
watch(data, () => { /* 数据变化时的副作用 */ })
// 如何一次性清理所有这些副作用?
}在没有 effectScope 的情况下,你需要手动收集每个副作用的停止句柄:
typescript
// 麻烦的手动管理
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 的解决方案更加优雅:
typescript
import { effectScope } from 'vue'
function useFeature() {
const scope = effectScope()
scope.run(() => {
const data = ref(null)
watchEffect(() => { /* ... */ })
watch(data, () => { /* ... */ })
// 在 scope.run() 内创建的所有 effect 都被自动收集
})
// 一行代码停止所有副作用
scope.stop()
}EffectScope 的实现
typescript
// 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
}
}
}