04-依赖收集与副作用系统
本章目标:深入理解track/trigger的实现原理,掌握ReactiveEffect的完整生命周期
上一章我们看到了Proxy如何拦截对象的读写操作,但拦截只是手段,真正的目的是在读取时收集依赖、在写入时触发更新。这一章,我们将深入响应式系统的心脏——track/trigger机制和ReactiveEffect,看看Vue是如何精确地知道"谁依赖了什么"以及"什么时候该更新谁"。
先看效果:响应式的魔法
在深入源码之前,让我们先感受一下响应式系统的神奇之处:
import { reactive, watchEffect } from 'vue'
const state = reactive({ count: 0 })
// 这个函数会在 count 变化时自动重新执行
watchEffect(() => {
console.log('count is:', state.count)
})
// 输出: count is: 0
state.count = 1 // 输出: count is: 1
state.count = 2 // 输出: count is: 2
神奇的地方在于:我们从未告诉 Vue "当 count 变化时请执行这个函数",Vue 是怎么知道的?
答案是:Vue 在函数执行时"偷偷"记录了它访问了哪些数据。这个过程叫做依赖收集(track),而当数据变化时通知相关函数的过程叫做触发更新(trigger)。
可以把它想象成一个"订阅-发布"系统:
- 当你读取
state.count时,相当于订阅了 count 的变化通知 - 当你修改
state.count时,Vue 会通知所有订阅者
接下来,让我们看看 Vue 是如何实现这套机制的。
3.1 依赖存储的数据结构设计
要实现"订阅-发布"机制,首先需要解决一个问题:把订阅关系存在哪里?
响应式系统需要回答一个核心问题:当某个属性变化时,应该通知哪些副作用函数?
这需要一个高效的数据结构来存储依赖关系。Vue使用了三层嵌套的结构:
targetMap: WeakMap<target, depsMap>
└── depsMap: Map<key, Dep>
└── Dep: { subs: Link, version: number, ... }
用一个具体例子来理解:
const state = reactive({ count: 0, name: 'Vue' })
watchEffect(() => {
console.log(state.count) // 依赖 state.count
})
watch(() => state.name, (name) => {
console.log(name) // 依赖 state.name
})
此时targetMap的结构大致是:
targetMap: WeakMap {
state原始对象 => Map {
'count' => Dep { subs: Link -> watchEffect },
'name' => Dep { subs: Link -> watch回调 }
}
}
为什么使用WeakMap作为最外层?因为它的键是弱引用——当target对象不再被使用时,可以被垃圾回收,相关的依赖信息也会自动清理。这避免了内存泄漏,也省去了手动清理的麻烦。
// 文件: packages/reactivity/src/dep.ts
export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()
type KeyToDepMap = Map<any, Dep>
现在我们知道了依赖关系存储在哪里,但 Dep 内部是如何组织订阅者的呢?这就涉及到 Vue 3.4 引入的一个重要优化——双向链表。
3.2 Dep与Link的双向链表设计
Vue 3.4对依赖追踪进行了重大重构,引入了基于双向链表的Link结构,取代了之前基于Set的实现。
为什么需要双向链表?
在响应式系统中,我们需要回答两个问题:
- 当数据变化时:哪些 Effect 需要被通知?(Dep → Effects)
- 当 Effect 重新执行时:它依赖了哪些数据?需要清理哪些旧依赖?(Effect → Deps)
这是一个多对多的关系:一个 Effect 可以依赖多个响应式属性,一个响应式属性也可以被多个 Effect 依赖。
const state = reactive({ count: 0, name: 'Vue' })
// Effect A 依赖 count 和 name
watchEffect(() => {
console.log(state.count, state.name)
})
// Effect B 也依赖 count
watchEffect(() => {
console.log(state.count * 2)
})
在这个例子中:
count的 Dep 需要知道:Effect A 和 Effect B 都订阅了我name的 Dep 需要知道:Effect A 订阅了我- Effect A 需要知道:我依赖了 count 和 name
- Effect B 需要知道:我依赖了 count
Link:连接 Effect 和 Dep 的桥梁
Link 就像一张"订阅卡",它同时记录了"谁订阅了谁"。每个 Link 节点包含:
class Link {
sub: Subscriber // 订阅者(Effect)
dep: Dep // 被订阅的依赖(响应式属性)
// 在 Effect 的"我的依赖"链表中的位置
nextDep?: Link // 下一个依赖
prevDep?: Link // 上一个依赖
// 在 Dep 的"我的订阅者"链表中的位置
nextSub?: Link // 下一个订阅者
prevSub?: Link // 上一个订阅者
version: number // 用于标记是否在本轮执行中被访问
}
关键洞察:同一个 Link 节点同时存在于两个链表中!
用具体例子理解双向链表
让我们用上面的代码例子,画出完整的数据结构:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 内存中的数据结构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Effect A │ │
│ │ deps ───────┼──────────────────────┐ │
│ │ depsTail ───┼──────────────────────┼───────────────┐ │
│ └──────────────┘ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Link 1 │◄──►│ Link 2 │ │
│ │ sub: A │ │ sub: A │ │
│ │ dep: X │ │ dep: Y │ │
│ └────┬────┘ └─────────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ Effect B │ │ │
│ │ deps ───────┼──────────────────────┼───────────────┐ │
│ │ depsTail ───┼──────────────────────┼───────────────┤ │
│ └──────────────┘ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Link 3 │ │ │ │
│ │ sub: B │ │ (无) │ │
│ │ dep: X │ │ │ │
│ └────┬────┘ └─────────┘ │
│ │ │
│ ════════════════════════════════════════════════════════════════════════ │
│ │ │
│ ┌──────────────┐ │ ┌──────────────┐ │
│ │ Dep X │ │ │ Dep Y │ │
│ │ (count) │ │ │ (name) │ │
│ │ subs ───────┼──────────────────────┤ │ subs ───────┼──┐ │
│ └──────────────┘ │ └──────────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Link 1 │◄───────────────►│ Link 2 │ │
│ │ (同上) │ nextSub │ (同上) │ │
│ └────┬────┘ └─────────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ Link 3 │ │
│ │ (同上) │ │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
图例说明:
- Link 1: 连接 Effect A 和 Dep X (count)
- Link 2: 连接 Effect A 和 Dep Y (name)
- Link 3: 连接 Effect B 和 Dep X (count)
横向链表(Effect 的依赖链):
Effect A: Link 1 ◄──► Link 2 (A 依赖 count 和 name)
Effect B: Link 3 (B 只依赖 count)
纵向链表(Dep 的订阅者链):
Dep X (count): Link 1 ◄──► Link 3 (count 被 A 和 B 订阅)
Dep Y (name): Link 2 (name 只被 A 订阅)
为什么这样设计?
场景1:数据变化时通知订阅者
当 state.count 变化时,我们需要通知所有订阅者:
Dep X (count)
└── subs 链表: Link 1 → Link 3
│ │
▼ ▼
Effect A Effect B
只需遍历 Dep 的 subs 链表,就能找到所有需要通知的 Effect。
场景2:Effect 重新执行时清理旧依赖
假设 Effect A 的代码变成了:
watchEffect(() => {
if (someCondition) {
console.log(state.count) // 只依赖 count
}
})
当 someCondition 为 true 时,Effect A 不再依赖 name。我们需要:
- 从 Effect A 的 deps 链表中移除 Link 2
- 从 Dep Y 的 subs 链表中移除 Link 2
因为 Link 2 同时在两个链表中,我们可以通过它快速完成双向解绑:
移除前:
Effect A: Link 1 ◄──► Link 2
Dep Y: Link 2
移除后:
Effect A: Link 1
Dep Y: (空)
场景3:停止 Effect
当调用 effect.stop() 时,需要从所有相关的 Dep 中移除订阅:
const stop = watchEffect(() => {
console.log(state.count, state.name)
})
stop() // 停止 effect
只需遍历 Effect 的 deps 链表,逐个从对应 Dep 的 subs 链表中移除:
遍历 Effect A 的 deps: Link 1 → Link 2
- Link 1: 从 Dep X 的 subs 中移除
- Link 2: 从 Dep Y 的 subs 中移除
双向链表 vs Set 的对比
Vue 3.4 之前使用 Set 存储依赖关系:
// 旧方案
class Dep {
subscribers = new Set<Effect>()
}
class Effect {
deps = new Set<Dep>()
}
双向链表的优势:
| 操作 | Set 方案 | 双向链表方案 |
|---|---|---|
| 添加依赖 | O(1) 但有哈希开销 | O(1) 直接链接 |
| 移除依赖 | O(1) 但需要两次 Set 操作 | O(1) 修改指针 |
| 遍历依赖 | 需要迭代器 | 直接遍历链表 |
| 内存占用 | Set 有额外开销(哈希表) | 只有 Link 节点 |
| 清理旧依赖 | 需要 diff 两个 Set | version 标记 + 遍历 |
最大的优势在于清理旧依赖:通过 version 机制,可以在一次遍历中完成清理,而 Set 方案需要对比新旧两个集合。
Link 类的源码实现
// 文件: packages/reactivity/src/dep.ts
export class Link {
version: number
// Effect的依赖链表指针(横向)
nextDep?: Link // 指向 Effect 的下一个依赖
prevDep?: Link // 指向 Effect 的上一个依赖
// Dep的订阅者链表指针(纵向)
nextSub?: Link // 指向 Dep 的下一个订阅者
prevSub?: Link // 指向 Dep 的上一个订阅者
// 用于嵌套effect场景
prevActiveLink?: Link
constructor(
public sub: Subscriber, // 订阅者(Effect)
public dep: Dep, // 被订阅的依赖
) {
this.version = dep.version
}
}
添加和移除 Link 的操作
当建立新的依赖关系时,需要将 Link 同时加入两个链表:
// 将 Link 加入 Dep 的订阅者链表
function addSub(link: Link) {
const dep = link.dep
// 加入链表尾部
if (dep.subs) {
link.prevSub = dep.subs
dep.subs.nextSub = link
}
dep.subs = link
dep.sc++ // 订阅者计数 +1
}
// 将 Link 从 Dep 的订阅者链表移除
function removeSub(link: Link) {
const { prevSub, nextSub } = link
if (prevSub) {
prevSub.nextSub = nextSub
}
if (nextSub) {
nextSub.prevSub = prevSub
}
// 如果是链表尾部,更新 subs 指针
if (link.dep.subs === link) {
link.dep.subs = prevSub
}
link.dep.sc-- // 订阅者计数 -1
}
Dep类是依赖容器,每个响应式属性对应一个Dep:
// 文件: packages/reactivity/src/dep.ts
export class Dep {
version = 0 // 每次trigger递增
activeLink?: Link = undefined // 当前活跃的Link
subs?: Link = undefined // 订阅者链表尾部
sc: number = 0 // 订阅者计数
// 用于清理不再被追踪的属性
map?: KeyToDepMap = undefined
key?: unknown = undefined
// computed专用
constructor(public computed?: ComputedRefImpl) {}
track() { /* 收集依赖 */ }
trigger() { /* 触发更新 */ }
notify() { /* 通知订阅者 */ }
}
数据结构已经就绪,接下来看看依赖收集的具体过程——当 Effect 执行时访问响应式数据,track 函数是如何工作的?
3.3 track依赖收集的完整流程
当副作用函数执行时访问响应式数据,会触发track收集依赖。让我们跟踪一次完整的依赖收集过程:
sequenceDiagram
participant Code as 用户代码
participant Proxy as Proxy get
participant Track as track()
participant Dep as Dep
participant Link as Link
participant Effect as activeSub
Code->>Proxy: 读取 state.count
Proxy->>Track: track(state, 'get', 'count')
Track->>Track: 检查 shouldTrack && activeSub
alt 条件不满足
Track-->>Proxy: 直接返回
else 条件满足
Track->>Track: 获取或创建 depsMap
Track->>Track: 获取或创建 dep
Track->>Dep: dep.track()
alt Link不存在或sub不匹配
Dep->>Link: new Link(activeSub, dep)
Link->>Effect: 添加到deps链表尾部
Link->>Dep: 添加到subs链表尾部
else Link存在且version=-1
Dep->>Link: 复用Link,更新version
end
end
Proxy-->>Code: 返回属性值
track函数的实现:
// 文件: packages/reactivity/src/dep.ts
export function track(target: object, type: TrackOpTypes, key: unknown): void {
// 1. 前置检查:是否应该追踪 && 是否有活跃的订阅者
if (shouldTrack && activeSub) {
// 2. 获取或创建target对应的depsMap
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 3. 获取或创建key对应的dep
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Dep()))
dep.map = depsMap // 保存引用,用于后续清理
dep.key = key
}
// 4. 调用dep.track()建立关联
dep.track()
}
}
Dep.track()的核心逻辑更加精妙:
// 文件: packages/reactivity/src/dep.ts
track(): Link | undefined {
// 没有活跃订阅者,或者是computed自己访问自己,跳过
if (!activeSub || !shouldTrack || activeSub === this.computed) {
return
}
let link = this.activeLink
// 情况1:Link不存在或属于其他订阅者 -> 创建新Link
if (link === undefined || link.sub !== activeSub) {
link = this.activeLink = new Link(activeSub, this)
// 添加到activeSub的deps链表尾部
if (!activeSub.deps) {
activeSub.deps = activeSub.depsTail = link
} else {
link.prevDep = activeSub.depsTail
activeSub.depsTail!.nextDep = link
activeSub.depsTail = link
}
// 添加到dep的subs链表
addSub(link)
}
// 情况2:Link存在且version=-1 -> 复用Link
else if (link.version === -1) {
link.version = this.version
// 如果不在链表尾部,移动到尾部
// 这确保deps链表的顺序与访问顺序一致
if (link.nextDep) {
// ... 移动逻辑
}
}
// 情况3:Link存在且version有效 -> 已经追踪过,无需操作
return link
}
version机制:自动清理不再使用的依赖
version机制是Vue 3.4的一个重要优化。考虑这个场景:
const state = reactive({ show: true, a: 1, b: 2 })
watchEffect(() => {
if (state.show) {
console.log(state.a) // 依赖 show 和 a
} else {
console.log(state.b) // 依赖 show 和 b
}
})
当state.show从true变为false时,effect不再依赖state.a,而是依赖state.b。如果不清理旧的依赖,state.a变化时仍会触发effect执行,造成不必要的开销。
version机制的工作流程:
- 执行前:
prepareDeps()将所有Link的version设为-1 - 执行中:访问到的依赖会更新Link的version为当前dep.version
- 执行后:
cleanupDeps()清理version仍为-1的Link
// 文件: packages/reactivity/src/effect.ts
function prepareDeps(sub: Subscriber) {
for (let link = sub.deps; link; link = link.nextDep) {
link.version = -1 // 标记为"待验证"
link.prevActiveLink = link.dep.activeLink
link.dep.activeLink = link
}
}
function cleanupDeps(sub: Subscriber) {
let head
let tail = sub.depsTail
let link = tail
while (link) {
const prev = link.prevDep
if (link.version === -1) {
// 未被访问 -> 移除
if (link === tail) tail = prev
removeSub(link) // 从dep的subs链表移除
removeDep(link) // 从effect的deps链表移除
} else {
head = link
}
// 恢复activeLink
link.dep.activeLink = link.prevActiveLink
link.prevActiveLink = undefined
link = prev
}
sub.deps = head
sub.depsTail = tail
}
依赖收集完成后,当数据变化时,Vue 需要通知所有订阅者。这就是 trigger 函数的职责。
3.4 trigger触发更新的完整流程
当响应式数据变化时,trigger负责通知所有订阅者。不同的操作类型需要触发不同的依赖:
// 文件: packages/reactivity/src/dep.ts
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
): void {
const depsMap = targetMap.get(target)
if (!depsMap) {
globalVersion++ // 即使没有依赖也要更新全局版本
return
}
const run = (dep: Dep | undefined) => {
if (dep) dep.trigger()
}
startBatch() // 开始批处理
if (type === TriggerOpTypes.CLEAR) {
// 集合清空 -> 触发所有依赖
depsMap.forEach(run)
} else {
const targetIsArray = isArray(target)
const isArrayIndex = targetIsArray && isIntegerKey(key)
// 数组length变化的特殊处理
if (targetIsArray && key === 'length') {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
// 触发length依赖、迭代依赖、以及索引>=newLength的依赖
if (key === 'length' || key === ARRAY_ITERATE_KEY ||
(!isSymbol(key) && key >= newLength)) {
run(dep)
}
})
} else {
// 触发具体key的依赖
if (key !== void 0 || depsMap.has(void 0)) {
run(depsMap.get(key))
}
// 数组索引变化 -> 触发迭代依赖
if (isArrayIndex) {
run(depsMap.get(ARRAY_ITERATE_KEY))
}
// ADD/DELETE -> 触发迭代相关依赖
switch (type) {
case TriggerOpTypes.ADD:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isArrayIndex) {
run(depsMap.get('length')) // 数组新增元素 -> length变化
}
break
case TriggerOpTypes.DELETE:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
run(depsMap.get(ITERATE_KEY))
}
break
}
}
}
endBatch() // 结束批处理
}
特殊key的处理
Vue定义了几个特殊的Symbol作为key,用于追踪迭代操作:
// 文件: packages/reactivity/src/dep.ts
export const ITERATE_KEY: unique symbol = Symbol('Object iterate')
export const MAP_KEY_ITERATE_KEY: unique symbol = Symbol('Map keys iterate')
export const ARRAY_ITERATE_KEY: unique symbol = Symbol('Array iterate')
ITERATE_KEY:for...in、Object.keys()等操作会追踪这个keyMAP_KEY_ITERATE_KEY:Map的keys()方法会追踪这个keyARRAY_ITERATE_KEY:数组的迭代方法(forEach、map等)会追踪这个key
当属性被添加或删除时,需要触发这些迭代相关的依赖,因为迭代结果可能发生变化。
你可能注意到了 trigger 函数中的 startBatch() 和 endBatch() 调用。这是 Vue 的批处理机制,用于避免同一个 Effect 被重复执行。让我们深入了解它的工作原理。
3.5 批处理机制——避免重复执行的关键
如果一次操作触发了多个依赖,或者多个数据同时变化,我们不希望同一个effect被执行多次。Vue通过批处理机制解决这个问题:
const state = reactive({ a: 1, b: 2 })
watchEffect(() => {
console.log(state.a + state.b) // 依赖a和b
})
// 同时修改a和b
state.a = 10
state.b = 20
// 期望:effect只执行一次,而不是两次
批处理的完整流程
批处理的实现:
// 文件: packages/reactivity/src/effect.ts
let batchDepth = 0
let batchedSub: Subscriber | undefined
let batchedComputed: Subscriber | undefined
export function startBatch(): void {
batchDepth++
}
export function endBatch(): void {
if (--batchDepth > 0) {
return // 嵌套batch未结束
}
// 先处理computed(它们可能触发更多effect)
if (batchedComputed) {
let e: Subscriber | undefined = batchedComputed
batchedComputed = undefined
while (e) {
const next = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
e = next
}
}
// 再处理普通effect
let error: unknown
while (batchedSub) {
let e: Subscriber | undefined = batchedSub
batchedSub = undefined
while (e) {
const next = e.next
e.next = undefined
e.flags &= ~EffectFlags.NOTIFIED
if (e.flags & EffectFlags.ACTIVE) {
try {
(e as ReactiveEffect).trigger()
} catch (err) {
if (!error) error = err
}
}
e = next
}
}
if (error) throw error
}
export function batch(sub: Subscriber, isComputed = false): void {
sub.flags |= EffectFlags.NOTIFIED // 标记已通知,避免重复加入
if (isComputed) {
sub.next = batchedComputed
batchedComputed = sub
} else {
sub.next = batchedSub
batchedSub = sub
}
}
嵌套批处理
批处理支持嵌套,这在trigger内部调用其他trigger时很重要:
// 嵌套batch示例
startBatch() // depth = 1
trigger(target1, ...)
startBatch() // depth = 2
// ... 触发逻辑
endBatch() // depth = 1,不执行
trigger(target2, ...)
startBatch() // depth = 2
// ... 触发逻辑
endBatch() // depth = 1,不执行
endBatch() // depth = 0,执行所有队列中的effect
为什么computed和effect分开处理?
computed需要优先处理,因为:
- 依赖链传播:computed可能被其他effect依赖,先处理computed可以确保effect执行时computed的值是最新的
- 避免重复计算:如果effect先执行,访问computed时会触发计算;computed再被通知时又会标记为dirty,造成不必要的重复
const count = ref(0)
const double = computed(() => count.value * 2)
watchEffect(() => {
console.log(double.value) // 依赖double
})
count.value++ // 触发更新
// 1. 先处理computed:double标记为dirty
// 2. 再处理effect:执行时访问double.value,触发重新计算
关键点:
NOTIFIED标记防止同一个effect被多次加入队列batchDepth支持嵌套batch,只有最外层endBatch才真正执行- computed和普通effect分开处理,computed优先
到目前为止,我们了解了依赖如何存储、如何收集、如何触发。但这些机制的载体是什么?答案是 ReactiveEffect 类。它是整个副作用系统的核心,管理着 Effect 从创建到销毁的完整生命周期。
3.6 ReactiveEffect的完整生命周期
ReactiveEffect是副作用的封装,它管理着依赖收集、执行、清理的完整生命周期:
// 文件: packages/reactivity/src/effect.ts
export class ReactiveEffect<T = any> implements Subscriber {
deps?: Link = undefined // 依赖链表头
depsTail?: Link = undefined // 依赖链表尾
flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING
next?: Subscriber = undefined // 批处理队列指针
cleanup?: () => void // 清理函数
scheduler?: EffectScheduler // 自定义调度器
onStop?: () => void // 停止回调
constructor(public fn: () => T) {
// 自动注册到当前活跃的effectScope
if (activeEffectScope && activeEffectScope.active) {
activeEffectScope.effects.push(this)
}
}
// 执行副作用
run(): T {
if (!(this.flags & EffectFlags.ACTIVE)) {
return this.fn() // 已停止,直接执行不追踪
}
this.flags |= EffectFlags.RUNNING
cleanupEffect(this) // 执行清理回调
prepareDeps(this) // 准备依赖追踪
const prevEffect = activeSub
const prevShouldTrack = shouldTrack
activeSub = this
shouldTrack = true
try {
return this.fn() // 执行副作用函数
} finally {
cleanupDeps(this) // 清理未使用的依赖
activeSub = prevEffect
shouldTrack = prevShouldTrack
this.flags &= ~EffectFlags.RUNNING
}
}
// 停止effect
stop(): void {
if (this.flags & EffectFlags.ACTIVE) {
// 移除所有依赖关系
for (let link = this.deps; link; link = link.nextDep) {
removeSub(link)
}
this.deps = this.depsTail = undefined
cleanupEffect(this)
this.onStop && this.onStop()
this.flags &= ~EffectFlags.ACTIVE
}
}
// 被通知更新
notify(): void {
if (this.flags & EffectFlags.RUNNING &&
!(this.flags & EffectFlags.ALLOW_RECURSE)) {
return // 正在执行且不允许递归,跳过
}
if (!(this.flags & EffectFlags.NOTIFIED)) {
batch(this) // 加入批处理队列
}
}
// 触发执行
trigger(): void {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this) // 暂停状态,加入待恢复队列
} else if (this.scheduler) {
this.scheduler() // 有调度器,交给调度器处理
} else {
this.runIfDirty() // 直接执行
}
}
}
effect()函数
effect()是创建ReactiveEffect的便捷API:
// 文件: packages/reactivity/src/effect.ts
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions,
): ReactiveEffectRunner<T> {
// 如果fn已经是effect runner,提取原始函数
if ((fn as ReactiveEffectRunner).effect instanceof ReactiveEffect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}
const e = new ReactiveEffect(fn)
// 应用选项
if (options) {
extend(e, options)
}
// 立即执行一次
try {
e.run()
} catch (err) {
e.stop()
throw err
}
// 返回runner函数
const runner = e.run.bind(e) as ReactiveEffectRunner
runner.effect = e
return runner
}
在 ReactiveEffect 的实现中,你可能注意到了 flags 属性和各种位运算。这是 Vue 用来高效管理 Effect 状态的方式。
3.7 EffectFlags状态机
ReactiveEffect使用位运算管理多个状态标记,这是一种高效的状态管理方式:
// 文件: packages/reactivity/src/effect.ts
export enum EffectFlags {
ACTIVE = 1 << 0, // 0001 是否激活(未被stop)
RUNNING = 1 << 1, // 0010 是否正在执行
TRACKING = 1 << 2, // 0100 是否追踪依赖
NOTIFIED = 1 << 3, // 1000 是否已加入批处理队列
DIRTY = 1 << 4, // computed专用:是否需要重新计算
ALLOW_RECURSE = 1 << 5, // 是否允许递归触发
PAUSED = 1 << 6, // 是否暂停
EVALUATED = 1 << 7, // computed专用:是否已求值
}
位运算的优势:
- 内存高效:一个数字可以存储多个布尔状态
- 操作快速:位运算是CPU原生支持的操作
// 检查状态
if (this.flags & EffectFlags.RUNNING) { /* 正在执行 */ }
// 设置状态
this.flags |= EffectFlags.NOTIFIED
// 清除状态
this.flags &= ~EffectFlags.RUNNING
// 同时检查多个状态
if (this.flags & (EffectFlags.ACTIVE | EffectFlags.TRACKING)) { /* ... */ }
状态转换图
最后,还有一个重要的优化机制值得一提——全局版本号。它为 computed 提供了一条"快速通道",避免不必要的依赖检查。
3.8 全局版本号:computed的快速路径
Vue维护了一个全局版本号globalVersion,每次响应式数据变化时递增:
// 文件: packages/reactivity/src/dep.ts
export let globalVersion = 0
// 在trigger中
trigger() {
this.version++
globalVersion++
this.notify()
}
这个全局版本号为computed提供了一个快速路径——如果globalVersion没有变化,说明没有任何响应式数据发生变化,computed可以直接返回缓存值,无需检查依赖:
// 文件: packages/reactivity/src/effect.ts
export function refreshComputed(computed: ComputedRefImpl): undefined {
// 快速路径:全局版本未变化,直接返回
if (computed.globalVersion === globalVersion) {
return
}
computed.globalVersion = globalVersion
// ... 检查依赖是否真的变化了
}
至此,我们已经完整地剖析了 Vue 响应式系统的核心机制。但在实际开发中,你可能会遇到一些"响应式失效"的问题。下面总结几个常见的坑和调试技巧。
🐛 常见问题与调试技巧
在实际开发中,你可能会遇到一些"响应式失效"的问题。这里总结几个常见的坑:
问题1:为什么我的 effect 没有触发?
const state = reactive({ user: { name: 'Vue' } })
watchEffect(() => {
console.log(state.user) // ❌ 只访问了 user,没有访问 user.name
})
state.user.name = 'React' // effect 不会触发!
原因:依赖收集是基于"访问"的。你只访问了 state.user,没有访问 state.user.name,所以 name 的变化不会触发 effect。
解决:确保访问到具体的属性:
watchEffect(() => {
console.log(state.user.name) // ✅ 访问了 name
})
问题2:解构导致响应式丢失
const state = reactive({ count: 0 })
const { count } = state // ❌ count 是一个普通数字,不是响应式的
watchEffect(() => {
console.log(count) // 永远是 0
})
state.count = 1 // effect 不会触发
原因:解构时 count 被赋值为 0(一个原始值),与 state.count 已经没有关系了。
解决:使用 toRefs 或直接访问:
import { toRefs } from 'vue'
const { count } = toRefs(state) // count 是一个 ref
watchEffect(() => {
console.log(count.value) // ✅ 响应式
})
问题3:异步操作中的依赖收集
watchEffect(async () => {
await fetch('/api')
console.log(state.count) // ❌ 在 await 之后访问
})
原因:依赖收集只发生在同步执行阶段。await 之后的代码在微任务中执行,此时 activeSub 已经被清空。
解决:在 await 之前访问需要追踪的数据:
watchEffect(async () => {
const count = state.count // ✅ 先同步访问
await fetch('/api')
console.log(count)
})
调试技巧:使用 onTrack 和 onTrigger
Vue 提供了调试钩子,帮助你追踪依赖收集和触发:
watchEffect(
() => {
console.log(state.count)
},
{
onTrack(e) {
console.log('依赖被收集:', e.key, e.target)
},
onTrigger(e) {
console.log('更新被触发:', e.key, e.oldValue, '->', e.newValue)
}
}
)
本章涉及源码:
packages/reactivity/src/dep.ts- Dep类、Link类、track()、trigger()、targetMappackages/reactivity/src/effect.ts- ReactiveEffect类、batch机制、effect()函数
理解了track/trigger机制后,我们已经掌握了响应式系统的核心。但还有一个重要的问题:Proxy 只能代理对象,无法代理原始值。那么,如何让一个数字、一个字符串也具有响应式能力呢?下一章,我们将深入 ref 的精妙设计。
🤔 深度思考
-
数据结构选择:Vue 3.4为什么要用双向链表取代Set来存储依赖关系?在什么场景下这种优化效果最明显?如果依赖数量很少,这种优化是否仍然有意义?
-
version机制:version机制是如何实现"自动清理不再使用的依赖"的?如果没有这个机制,会导致什么问题?请举一个具体的例子说明。
-
批处理顺序:批处理机制中,computed和普通effect分开处理,computed优先。为什么要这样设计?如果不分开会发生什么?
-
递归触发:如果在effect执行过程中又触发了同一个effect(递归触发),Vue是如何处理的?
ALLOW_RECURSE标记的作用是什么?在什么场景下需要允许递归?