effect、track、trigger:Vue 3 依赖追踪引擎的完整机制
第5章:effect、track、trigger——Vue 3 依赖追踪引擎的完整机制
Vue 3 的依赖追踪核心数据结构是
WeakMap<target, Map<key, Set<ReactiveEffect>>>,三层嵌套,层层递进。这个结构之所以是三层而不是两层或四层,是因为响应式系统需要同时满足四个约束:精确追踪(谁依赖了什么)、自动清理(对象被回收时清理依赖)、去重(同一个 effect 不重复订阅)、快速查找(O(1) 的依赖查询)。
本章核心问题:ref.value 的赋值是如何触发模板重新渲染的?这中间经历了多少层调用?
读完本章你将理解:
effect()、track()、trigger()三个函数的完整实现逻辑和调用时机- 嵌套 effect 的处理机制:为什么需要 effectStack 或 parent 指针
- 调度器(scheduler)如何把同步的数据变化批量合并成异步的 DOM 更新
Level 1 · 你需要知道的(1-3年经验)
5.1 从一个赋值到 DOM 更新的完整链路
当你写下 count.value++ 时,Vue 内部发生了什么?从赋值到 DOM 更新,完整链路如下:
count.value++ 触发的调用链:
1. count.value = count.value + 1
│
2. Proxy set trap 被触发
│
3. Reflect.set(target, 'value', newValue, receiver)
│ target 的 value 属性真正被更新
│
4. trigger(target, TriggerOpTypes.SET, 'value', newValue, oldValue)
│ 查找 targetMap.get(target).get('value') 得到 dep Set
│
5. 遍历 dep Set,对每个 ReactiveEffect 调度执行
│ 如果 effect 有 scheduler,调用 scheduler
│ 如果没有 scheduler,直接调用 effect.run()
│
6. 组件的渲染 effect 有 scheduler(queueJob)
│ 把渲染任务加入 queue
│
7. nextTick 中批量刷新 queue
│
8. 组件 re-render:调用 render function,生成新 VNode 树
│
9. patch(oldVNode, newVNode):diff 并更新 DOM
整个链路涉及三个关键函数:track(建立依赖关系)、trigger(触发依赖执行)、effect(定义依赖执行单元)。
5.2 核心数据结构:三层嵌套 Map
// Vue 3 依赖追踪的核心存储
const targetMap = new WeakMap();
// targetMap 的结构:
// WeakMap {
// target1: Map {
// 'key1': Set { effect1, effect2, effect3 },
// 'key2': Set { effect4 }
// },
// target2: Map {
// 'keyA': Set { effect1, effect5 }
// }
// }
为什么是 WeakMap?
WeakMap 的 key 是弱引用,不阻止垃圾回收。当 target 对象不再被其他地方引用时,GC 可以回收它,targetMap 中对应的 Map(depsMap)也会自动消失。这避免了内存泄漏:不需要手动清理已销毁的组件对应的依赖记录。
为什么第二层是 Map,不是对象?
对象的 key 只能是字符串或 Symbol,Map 的 key 可以是任意值(包括 Symbol)。响应式属性名可以是 Symbol(如 Symbol.iterator),用 Map 确保完整支持。
为什么第三层是 Set,不是数组? Set 自动去重。同一个 effect 读取同一个属性多次,只需订阅一次。用数组需要手动去重。
5.3 effect() 函数:创建响应式副作用
// 最简单的使用方式
import { reactive, effect } from 'vue';
const state = reactive({ count: 0 });
// 创建一个 effect,它会立即执行一次,并在依赖变化时重新执行
const myEffect = effect(() => {
console.log('count is:', state.count); // 立即打印:count is: 0
});
state.count = 1; // 自动重新执行:count is: 1
state.count = 2; // 自动重新执行:count is: 2
// effect 返回一个 runner 函数,可以手动调用
myEffect(); // 手动触发
effect() 内部创建了一个 ReactiveEffect 实例,这个实例有几个关键属性:
fn:用户传入的副作用函数deps:这个 effect 所依赖的所有 dep Set(反向引用,用于清理)scheduler:可选的调度器,当依赖变化时替代直接执行 fnactive:是否激活状态(stop()后变为 false)
5.4 track() 函数:收集依赖
// track 在 Proxy 的 get trap 中被调用
function track(target, type, key) {
if (shouldTrack && activeEffect) {
// 1. 获取/创建 target 对应的 depsMap
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 2. 获取/创建 key 对应的 dep Set
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = createDep()));
}
// 3. 建立双向依赖关系
trackEffects(dep);
}
}
function trackEffects(dep) {
// 把 activeEffect 加入 dep Set(effect 订阅了这个 dep)
dep.add(activeEffect);
// 把 dep 加入 activeEffect.deps(反向引用,effect 记录了自己订阅了哪些 dep)
activeEffect.deps.push(dep);
}
关键设计:双向引用
依赖关系双向图:
dep Set ←─────────────── activeEffect.deps
(保存订阅了这个dep的effect) (保存这个effect订阅了哪些dep)
│ │
▼ ▼
[effect1, effect2] [dep1, dep2, dep3]
这个双向引用是依赖清理的基础:当 effect 重新执行时,需要清除旧的依赖关系(因为 if 分支可能导致依赖集合变化),然后重新收集。有了 activeEffect.deps,可以快速找到并从所有相关的 dep Set 中移除这个 effect。
5.5 trigger() 函数:触发依赖
// trigger 在 Proxy 的 set/deleteProperty trap 中被调用
function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 没有任何 effect 订阅这个 target,直接返回
let deps: (Dep | undefined)[] = [];
if (type === TriggerOpTypes.CLEAR) {
// Map/Set 的 clear 操作:触发所有 key 的依赖
deps = [...depsMap.values()];
} else if (key === 'length' && isArray(target)) {
// 数组 length 变化:触发 length 依赖和所有大于等于新 length 的索引依赖
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
deps.push(dep);
}
});
} else {
// 普通属性操作
if (key !== void 0) {
deps.push(depsMap.get(key)); // 触发这个 key 的 dep
}
// 根据操作类型触发额外的依赖
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY)); // 对象新增属性,触发 ownKeys 依赖
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
} else if (isIntegerKey(key)) {
deps.push(depsMap.get('length')); // 数组新增元素,触发 length 依赖
}
break;
case TriggerOpTypes.DELETE:
// 删除操作,类似 ADD
break;
case TriggerOpTypes.SET:
if (isMap(target)) {
deps.push(depsMap.get(ITERATE_KEY)); // Map set 操作
}
break;
}
}
// 统一调度所有收集到的 deps
triggerEffects(createDep(deps.flat()));
}
5.6 activeEffect:全局指针的工作方式
// 全局变量,指向当前正在执行的 effect
let activeEffect: ReactiveEffect | undefined;
class ReactiveEffect {
run() {
if (!this.active) {
return this.fn(); // 已停止的 effect 直接执行,不追踪
}
try {
// 把当前 effect 设置为全局活动 effect
activeEffect = this;
enableTracking();
// 清理旧的依赖(重新收集)
cleanupEffect(this);
// 执行用户的 fn:fn 内部的响应式属性访问会触发 track
return this.fn();
} finally {
// 无论成功失败,执行完后恢复之前的 activeEffect
activeEffect = this.parent; // parent 指向外层 effect(嵌套场景)
resetTracking();
}
}
}
activeEffect 是整个依赖追踪系统的核心纽带:
- 执行 effect 前,设置
activeEffect = this - 执行 effect 时,任何响应式属性的 get 操作都会调用
track,track检查activeEffect不为 null 时,把activeEffect加入 dep Set - 执行 effect 后,恢复
activeEffect
Level 2 · 它是怎么运行的(3-5年经验)
5.7 嵌套 effect 的处理
当一个 effect 内部触发了另一个 effect 时:
const outer = effect(() => {
console.log('outer:', state.a);
const inner = effect(() => {
console.log('inner:', state.b);
});
});
如果用一个全局变量 activeEffect 而没有任何保存/恢复机制,内层 effect 执行时会覆盖 activeEffect,导致外层 effect 执行完后 activeEffect 是 inner 而不是 outer:
错误情况(无保存/恢复):
1. outer 开始执行,activeEffect = outer
2. outer 读取 state.a → track(state, 'a'),dep.add(outer) ✓
3. inner 开始执行,activeEffect = inner(覆盖了 outer!)
4. inner 读取 state.b → track(state, 'b'),dep.add(inner) ✓
5. inner 执行完毕
6. outer 继续执行,但如果此后还有响应式读取,track 会 dep.add(inner)!错误!
Vue 3.2 之前的解决方案:effectStack
// effectStack:维护当前执行栈
const effectStack: ReactiveEffect[] = [];
class ReactiveEffect {
run() {
if (!effectStack.includes(this)) {
effectStack.push(this); // 入栈
activeEffect = this;
// ...执行 fn...
effectStack.pop(); // 出栈
activeEffect = effectStack[effectStack.length - 1]; // 恢复到上一个
}
}
}
Vue 3.2 的优化:parent 指针
effectStack 每次都要做 includes 检查(O(n)),Vue 3.2 把它优化成 parent 指针(O(1)):
class ReactiveEffect {
parent: ReactiveEffect | undefined = undefined;
run() {
if (!this.active) return this.fn();
try {
this.parent = activeEffect; // 记录父 effect
activeEffect = this;
// ...执行 fn...
} finally {
activeEffect = this.parent; // 恢复到父 effect
this.parent = undefined;
}
}
}
parent 指针的嵌套 effect 示意:
正在执行:outer effect
├── activeEffect = outer
├── outer.parent = undefined(没有父 effect)
│
└── 内部触发 inner effect
├── inner.parent = outer(记录父 effect)
├── activeEffect = inner
│
├── inner 执行完毕:
└── activeEffect = inner.parent = outer(恢复)
inner.parent = undefined(清理)
5.8 computed 的 lazy effect 与 dirty 标志
computed() 内部创建了一个特殊的 ReactiveEffect,它不会在依赖变化时立即执行,而是设置一个 dirty 标志,等到下次被读取时才重新计算:
// computed 的简化实现(与实际源码相似)
class ComputedRefImpl {
private _value: T;
private _dirty = true; // 初始为 dirty,第一次读取时计算
private effect: ReactiveEffect;
constructor(getter) {
// 创建一个有 scheduler 的 effect
this.effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时,不立即重新执行,只标记为 dirty
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this); // 通知依赖了这个 computed 的外层 effect
}
});
}
get value() {
trackRefValue(this); // 追踪对 computed 值的依赖
if (this._dirty) {
this._dirty = false; // 标记为非 dirty
this._value = this.effect.run(); // 重新计算
}
return this._value;
}
}
computed 的懒执行流程:
初始状态: 依赖变化时: 读取时:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ dirty = true │ │ dirty = false│ ──────► │ dirty = true │
│ _value: ??? │ │ _value: 旧值 │ scheduler│ 执行 getter │
└──────────────┘ └──────────────┘ 设 dirty │ dirty = false│
│ _value: 新值 │
└──────────────┘
这种"计算懒到最后一刻"的策略有重要性能含义:如果你的 computed 依赖了多个 ref,这些 ref 在同一个 tick 内多次变化,但 computed 只会在被实际读取时计算一次。
5.9 调度器(Scheduler)与 nextTick
Vue 3 的组件渲染 effect 使用了调度器,把渲染任务推入队列而不是立即执行:
// packages/runtime-core/src/scheduler.ts(简化版)
const queue: SchedulerJob[] = [];
let isFlushing = false;
let isFlushPending = false;
// 添加任务到队列(去重)
export function queueJob(job: SchedulerJob) {
if (!queue.includes(job, isFlushing && flushIndex)) {
queue.push(job);
queueFlush();
}
}
// 在 nextTick 中刷新队列
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
// resolvedPromise = Promise.resolve()
// 利用 Promise microtask 在当前同步代码执行完后执行
}
}
async function flushJobs() {
isFlushPending = false;
isFlushing = true;
// 对队列排序(确保父组件先于子组件更新)
queue.sort((a, b) => getId(a) - getId(b));
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
if (job.active !== false) {
callWithErrorHandling(job);
}
}
} finally {
flushIndex = 0;
queue.length = 0;
isFlushing = false;
currentFlushPromise = null;
// 处理 postFlush 队列(watchPostFlush 等)
flushPostFlushCbs();
}
}
调度器的时序图:
同步代码
────────────────────────────────────────────────────────────
count.value = 1 → trigger → queueJob(renderEffect) → isFlushPending=true
count.value = 2 → trigger → queueJob(renderEffect,已在队列,去重,跳过)
count.value = 3 → trigger → queueJob(renderEffect,已在队列,去重,跳过)
// 同步代码执行完毕
────────────────────────────────────────────────────────────
微任务队列(Promise.then)
────────────────────────────────────────────────────────────
flushJobs()
└── 执行 renderEffect(只执行一次!count 已经是 3)
└── 更新 DOM(只更新一次,直接到最终状态)
────────────────────────────────────────────────────────────
这是 Vue 批量更新的核心机制:三次赋值只触发一次 DOM 更新。
5.10 手写迷你响应式系统(100 行)
// mini-reactivity.js
// 全局 effect 追踪
let activeEffect = null;
const targetMap = new WeakMap();
// track:建立依赖关系
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
}
// trigger:触发依赖
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
const effects = [...dep]; // 复制,避免在执行中修改 Set
effects.forEach(effect => {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
});
}
}
// ReactiveEffect 类
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn;
this.scheduler = scheduler;
this.deps = []; // 反向引用:这个 effect 订阅了哪些 dep
this.active = true;
this.parent = undefined;
}
run() {
if (!this.active) return this.fn();
const prevEffect = activeEffect;
try {
activeEffect = this;
cleanupEffect(this); // 清理旧依赖
return this.fn();
} finally {
activeEffect = prevEffect;
}
}
stop() {
if (this.active) {
cleanupEffect(this);
this.active = false;
}
}
}
function cleanupEffect(effect) {
const { deps } = effect;
if (deps.length) {
deps.forEach(dep => dep.delete(effect));
deps.length = 0;
}
}
// reactive:创建响应式对象
function reactive(raw) {
return new Proxy(raw, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key);
if (typeof res === 'object' && res !== null) {
return reactive(res); // 懒代理(这里简化为即时递归)
}
return res;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (value !== oldValue) {
trigger(target, key);
}
return result;
}
});
}
// ref:包装基础类型
class RefImpl {
constructor(value) {
this._value = value;
this.dep = new Set(); // 直接用 Set,不需要 Map 层
}
get value() {
if (activeEffect) {
this.dep.add(activeEffect);
activeEffect.deps.push(this.dep);
}
return this._value;
}
set value(newValue) {
if (newValue !== this._value) {
this._value = newValue;
const effects = [...this.dep];
effects.forEach(effect => effect.scheduler ? effect.scheduler() : effect.run());
}
}
}
function ref(value) {
return new RefImpl(value);
}
// computed:带缓存的懒 effect
function computed(getter) {
let _value;
let dirty = true;
const effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时只标记 dirty,不立即重算
dirty = true;
});
return {
get value() {
if (dirty) {
dirty = false;
_value = effect.run();
}
return _value;
}
};
}
// effect:创建副作用
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler);
_effect.run(); // 立即执行一次
return _effect.run.bind(_effect); // 返回 runner
}
// 验证
const state = reactive({ count: 0, name: 'Vue' });
const count = ref(0);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log(`state.count = ${state.count}, doubled = ${doubled.value}`);
});
// 立即打印:state.count = 0, doubled = 0
state.count++; // 打印:state.count = 1, doubled = 0
count.value = 5; // 打印:state.count = 1, doubled = 10
Level 3 · 设计文档与源码(资深开发者)
5.11 ReactiveEffect 的完整源码
// packages/reactivity/src/effect.ts(核心部分)
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = [] // 反向依赖引用
parent: ReactiveEffect | undefined = undefined // 嵌套 effect 的父指针
// 关联的计算属性(如果这是一个 computed 的 effect)
computed?: ComputedRefImpl<T>
// 是否允许递归触发自身
allowRecurse?: boolean
// effect 被停止时的回调
onStop?: () => void
// 仅开发模式:追踪开始/结束的钩子
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope | null
) {
recordEffectScope(this, scope) // 注册到 EffectScope
}
run() {
if (!this.active) {
return this.fn()
}
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
// 检测递归:沿 parent 链查找是否已经在执行中
while (parent) {
if (parent === this) {
return // 检测到递归,不再执行
}
parent = parent.parent
}
try {
this.parent = activeEffect // 保存父 effect
activeEffect = this // 设置为当前 active effect
shouldTrack = true
trackOpBit = 1 << ++effectTrackDepth // 位掩码优化
if (effectTrackDepth <= maxMarkerBits) {
// 优化路径:用位标记追踪已访问的 dep
initDepMarkers(this)
} else {
// 降级路径:完全清理并重新收集
cleanupEffect(this)
}
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
// 清理不再需要的依赖(只保留本次执行中访问到的)
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent // 恢复父 effect
shouldTrack = lastShouldTrack
this.parent = undefined
}
}
stop() {
if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
}
5.12 位掩码优化的依赖清理
Vue 3.2 引入了一个聪明的优化:用位掩码(bit mask)来标记哪些 dep 在本次执行中被访问过,避免完全清理和重新订阅:
// packages/reactivity/src/dep.ts
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0 // wasTracked 位标记
dep.n = 0 // newTracked 位标记
return dep
}
// 初始化:标记所有已有依赖为"之前追踪过"
function initDepMarkers({ deps }: ReactiveEffect) {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit // 设置 wasTracked 位
}
}
}
// 完成后:清理只在"之前"但不在"现在"的依赖
function finalizeDepMarkers(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
if (wasTracked(dep) && !newTracked(dep)) {
// 这个 dep 之前追踪过,但本次执行没有访问(说明依赖消失了)
dep.delete(effect)
} else {
deps[ptr++] = dep // 保留仍然有效的依赖
}
// 清理位标记
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = ptr // 截断数组,只保留有效的
}
}
这个优化避免了大量不必要的 Set.delete/add 操作,在 effect 依赖集合基本稳定(无分支变化)的场景下,性能显著提升。
5.13 EffectScope:批量管理 effect 的生命周期
Vue 3.2 引入了 effectScope() API,用于批量管理一组 effect 的生命周期:
// packages/reactivity/src/effectScope.ts
export class EffectScope {
active = true
effects: ReactiveEffect[] = [] // 属于这个 scope 的所有 effect
cleanups: (() => void)[] = [] // 清理回调
parent: EffectScope | undefined // 父 scope
scopes: EffectScope[] | undefined // 子 scope
run<T>(fn: () => T): T | undefined {
if (this.active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn() // 在 fn 内创建的所有 effect 都属于这个 scope
} finally {
activeEffectScope = currentEffectScope
}
}
}
stop(fromParent?: boolean) {
if (this.active) {
let i, l
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop() // 停止所有 effect
}
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]() // 执行清理回调
}
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true) // 停止所有子 scope
}
}
if (!fromParent && this.parent) {
// 从父 scope 的 scopes 列表中删除自己
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
}
}
this.active = false
}
}
}
在组件中的应用:每个 Vue 组件实例都有一个对应的 EffectScope。组件的所有 watch、watchEffect、computed 都属于这个 scope。当组件卸载时,调用 scope.stop() 即可一次性停止所有相关 effect,避免内存泄漏。
Level 4 · 边界与陷阱(全体适用)
陷阱1:在 effect 外修改响应式数据的追踪问题
import { reactive, effect } from 'vue';
const state = reactive({ count: 0, flag: true });
effect(() => {
if (state.flag) {
console.log('count:', state.count); // 只有 flag=true 时才追踪 count
}
});
state.flag = false; // effect 重新执行,flag=false,count 不再被追踪
state.count = 99; // 不触发 effect!count 的依赖在上次执行中被清理了
state.flag = true; // effect 重新执行,重新追踪 count
state.count = 100; // 触发 effect!
根本原因:每次 effect 执行时,Vue 先清理所有旧依赖,再重新收集。当 state.flag = false 导致 effect 重新执行时,effect 内部没有访问 state.count(因为 if 条件为 false),所以 state.count 不再在 effect 的依赖集合中。这是正确的行为——避免了当 flag=false 时 count 的变化触发不必要的 effect 执行。
陷阱2:triggerRef 的必要场景
import { ref, triggerRef, watchEffect } from 'vue';
// 场景:ref 内部的对象被直接修改(不通过赋值)
const arr = ref([1, 2, 3]);
watchEffect(() => {
console.log('length:', arr.value.length);
});
// 错误:直接修改数组内容,不会触发 arr 的 ref 更新
arr.value.push(4); // watchEffect 不会重新执行!
// 为什么?push 触发的是 arr.value(Array Proxy)的更新
// 但 arr(RefImpl)的 setter 没有被触发
// 这里有一个响应式传递问题
// 正确方案1:整体替换
arr.value = [...arr.value, 4]; // 触发 arr 的 setter → 触发 ref 追踪的 effect
// 正确方案2:用 triggerRef 手动触发
arr.value.push(4);
triggerRef(arr); // 手动告知所有依赖了 arr 的 effect 需要更新
根本原因:arr.value 返回的是 Array 的 Proxy,对它的操作(push、splice 等)触发的是数组内部的响应式。但 watchEffect 追踪的是 arr.value(对 RefImpl 的 .value 的访问),当 arr.value 引用的数组对象本身没有变化时(只是内容变了),ref 的 dep 不会被触发。
陷阱3:watch 的旧值在首次执行时是 undefined
import { ref, watch } from 'vue';
const count = ref(0);
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`);
}, { immediate: true });
// 打印:undefined → 0
// 首次执行时 oldVal 是 undefined,不是什么初始值
// 对比:不加 immediate
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`);
});
count.value = 1;
// 打印:0 → 1(正常,有旧值)
实际影响:如果你在 watch 回调中对 oldVal 做属性访问(oldVal.someProperty),在 immediate 模式下会导致 TypeError: Cannot read properties of undefined。需要做 null 检查:oldVal?.someProperty。
陷阱4:effect 的调度时序与 DOM 更新
import { ref, watchEffect, nextTick } from 'vue';
const count = ref(0);
let domValue = '';
// watchEffect 默认在 DOM 更新之前执行
watchEffect(() => {
domValue = document.getElementById('counter')?.textContent ?? '';
console.log('DOM 内容:', domValue, 'count:', count.value);
});
count.value = 1;
// watchEffect 执行时,DOM 还没有更新!
// domValue 是旧的 DOM 内容,count.value 已经是 1
// 如果需要在 DOM 更新后执行:
watchEffect(() => {
console.log('after DOM update:', document.getElementById('counter')?.textContent);
}, { flush: 'post' }); // 或者使用 watchPostEffect()
// 或者手动用 nextTick
count.value = 1;
await nextTick();
// 此时 DOM 已经更新
console.log(document.getElementById('counter')?.textContent); // 更新后的值
时序规则(按执行顺序):
- 同步代码(响应式数据变化)
preflush effect(默认 watchEffect,在 DOM 更新前)- DOM 更新(组件 re-render + patch)
postflush effect(watchPostEffect,在 DOM 更新后)nextTick的 Promise 回调
本章小结
-
依赖追踪的核心数据结构是三层嵌套:
WeakMap<target, Map<key, Set<effect>>>。WeakMap 实现自动 GC、Map 支持 Symbol key、Set 实现去重,三层结构缺一不可,每层都有其精确的设计动机。 -
activeEffect 全局指针是依赖收集的桥梁:执行 effect 时设置
activeEffect = this,effect 内的响应式属性访问通过 track 把自己加入 dep Set。执行完毕后恢复上一个activeEffect,嵌套 effect 通过 parent 指针(Vue 3.2)实现正确的栈式管理。 -
computed 是带缓存的懒 effect:
dirty标志位控制是否需要重新计算,依赖变化时只设置 dirty=true 而不立即重算,读取时才执行 getter。这确保了多次依赖变化只会导致一次重新计算(如果 computed 的值只被读取一次)。 -
调度器是批量更新的关键:组件渲染 effect 使用
queueJob作为 scheduler,把渲染任务推入 microtask 队列。同一个组件在一个 tick 内的多次数据变化只触发一次重新渲染,这是 Vue 应用不会因为频繁数据变化而性能崩溃的根本原因。 -
effect 的依赖会在每次执行前清理并重新收集:这确保了 if/else 等条件分支导致的依赖集合变化能被正确追踪——不再访问的响应式属性不会继续触发 effect,避免了无效的重新执行。