第 5 章

effect、track、trigger:Vue 3 依赖追踪引擎的完整机制

第5章:effect、track、trigger——Vue 3 依赖追踪引擎的完整机制

Vue 3 的依赖追踪核心数据结构是 WeakMap<target, Map<key, Set<ReactiveEffect>>>,三层嵌套,层层递进。这个结构之所以是三层而不是两层或四层,是因为响应式系统需要同时满足四个约束:精确追踪(谁依赖了什么)、自动清理(对象被回收时清理依赖)、去重(同一个 effect 不重复订阅)、快速查找(O(1) 的依赖查询)。

本章核心问题ref.value 的赋值是如何触发模板重新渲染的?这中间经历了多少层调用?

读完本章你将理解


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 实例,这个实例有几个关键属性:

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 是整个依赖追踪系统的核心纽带:


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 执行完后 activeEffectinner 而不是 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。组件的所有 watchwatchEffectcomputed 都属于这个 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=falsecount 的变化触发不必要的 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); // 更新后的值

时序规则(按执行顺序):

  1. 同步代码(响应式数据变化)
  2. pre flush effect(默认 watchEffect,在 DOM 更新前)
  3. DOM 更新(组件 re-render + patch)
  4. post flush effect(watchPostEffect,在 DOM 更新后)
  5. nextTick 的 Promise 回调

本章小结

  1. 依赖追踪的核心数据结构是三层嵌套WeakMap<target, Map<key, Set<effect>>>。WeakMap 实现自动 GC、Map 支持 Symbol key、Set 实现去重,三层结构缺一不可,每层都有其精确的设计动机。

  2. activeEffect 全局指针是依赖收集的桥梁:执行 effect 时设置 activeEffect = this,effect 内的响应式属性访问通过 track 把自己加入 dep Set。执行完毕后恢复上一个 activeEffect,嵌套 effect 通过 parent 指针(Vue 3.2)实现正确的栈式管理。

  3. computed 是带缓存的懒 effectdirty 标志位控制是否需要重新计算,依赖变化时只设置 dirty=true 而不立即重算,读取时才执行 getter。这确保了多次依赖变化只会导致一次重新计算(如果 computed 的值只被读取一次)。

  4. 调度器是批量更新的关键:组件渲染 effect 使用 queueJob 作为 scheduler,把渲染任务推入 microtask 队列。同一个组件在一个 tick 内的多次数据变化只触发一次重新渲染,这是 Vue 应用不会因为频繁数据变化而性能崩溃的根本原因。

  5. effect 的依赖会在每次执行前清理并重新收集:这确保了 if/else 等条件分支导致的依赖集合变化能被正确追踪——不再访问的响应式属性不会继续触发 effect,避免了无效的重新执行。

本章评分
4.9  / 5  (67 评分)

💬 留言讨论