Vue 3 的批量更新机制

357 阅读9分钟

Vue 3 的批量更新机制是其高性能响应式系统的关键组成部分。它确保了当你在一个同步代码块(一个 "tick")中多次修改响应式数据时,相关的副作用(如组件渲染、计算属性更新、侦听器执行)不会被触发多次,而是被合并到一次异步更新中执行。这极大地减少了不必要的计算和 DOM 操作,提高了性能。

其核心原理可以概括为:异步更新队列 + Microtask 调度

  1. 触发(Trigger)时不立即执行:当响应式数据(refreactive 对象)发生变化时,会调用 trigger 函数。trigger 函数负责找到所有依赖该数据的副作用(effect)。但是,它不会立即执行这些 effect
  2. 调度(Schedule)副作用trigger 会将需要执行的 effect 作为一个 “job” 添加到一个全局的异步队列(queue) 中。这个添加过程由调度器(scheduler)的核心函数 queueJob 处理。
  3. 去重(Deduplication)queueJob 函数会确保同一个 effect 在同一个队列刷新周期中只被添加一次,即使它依赖的数据被修改了多次。这是实现“批量”的关键。
  4. 异步刷新队列(Flush Queue)queueJob 在添加 job 后,会调用 queueFlush 来安排一个微任务(Microtask),通常使用 Promise.resolve().then()queueMicrotask()。这个微任务的回调函数是 flushJobs
  5. 执行副作用(Execute Effects):当 JavaScript 同步代码执行完毕,事件循环进入微任务阶段时,flushJobs 函数会被执行。它会遍历并执行队列中的所有 job(即调用 effect.run()),清空队列。因为所有在同一个 tick 内的修改都已完成,此时执行副作用可以获取到最新的状态,并且只执行一次。
  6. nextTick:Vue 提供 nextTick API,允许用户注册一个回调函数,该函数会在队列刷新(flushJobs 执行)之后执行,确保能访问到更新后的 DOM。

源码模拟与讲解

为了更清晰地理解,我们将构建一个 高度简化但原理一致 的模拟实现。这并非 Vue 3 源码的直接拷贝(真实源码分散在多个模块,包含更多优化、错误处理和边界情况),但它抓住了核心的调度逻辑。

// ============================================================
// 模拟 Vue 3 响应式系统的基础部分 (极度简化)
// ============================================================

let activeEffect = null; // 当前正在执行的副作用
const targetMap = new WeakMap(); // 存储依赖关系: target -> Map<key, Set<ReactiveEffect>>

// 模拟 ReactiveEffect 类 (简化版)
// 真实的 Effect 包含更多属性和方法,如 active, deps, onStop 等
class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn; // 副作用函数 (例如渲染函数、watch 回调)
        this.scheduler = scheduler; // 调度器函数
        this.active = true; // Effect 是否激活
        this.deps = []; // 存储该 effect 依赖的所有 dep (Set<ReactiveEffect>)
        console.log('[Effect] Created effect with scheduler:', !!scheduler);
    }

    run() {
        if (!this.active) {
            return this.fn(); // 如果 effect 已失活,直接运行函数但不收集依赖
        }

        // 设置全局 activeEffect 为当前 effect
        let parent = activeEffect;
        activeEffect = this;
        // 清理之前的依赖关系,防止遗留依赖
        cleanupEffect(this);

        // 执行副作用函数,期间会触发 getter -> track() 收集依赖
        console.log('[Effect] Running effect');
        const result = this.fn();

        // 恢复之前的 activeEffect
        activeEffect = parent;

        return result;
    }

    stop() {
      if (this.active) {
        cleanupEffect(this);
        this.active = false;
        console.log('[Effect] Stopped effect');
      }
    }
}

// 清理 effect 的所有依赖
function cleanupEffect(effect) {
    for (let i = 0; i < effect.deps.length; i++) {
        const dep = effect.deps[i];
        dep.delete(effect); // 从依赖集合中移除当前 effect
    }
    effect.deps.length = 0; // 清空 effect 的依赖数组
}


// 模拟 effect 函数 (对外 API)
function effect(fn, options = {}) {
    const _effect = new ReactiveEffect(fn, options.scheduler);
    // 立即执行一次以收集初始依赖
    _effect.run();
    // 返回 runner 函数,允许手动执行
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect; // 暴露 effect 实例
    return runner;
}

// 依赖收集
function track(target, key) {
    if (!activeEffect) {
        // console.log(`[Track] No active effect, skipping track for ${key.toString()}`);
        return; // 只有在 effect 运行时才收集依赖
    }

    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())); // dep 是一个 Set,存储所有依赖该 key 的 effect
    }

    // 双向记录依赖关系
    // 1. dep 存储 effect
    if (!dep.has(activeEffect)) {
       dep.add(activeEffect);
       // console.log(`[Track] Tracking: Target=${target}, Key=${key.toString()}, Effect added.`);
       // 2. effect 存储 dep
       activeEffect.deps.push(dep);
    } else {
        // console.log(`[Track] Tracking: Target=${target}, Key=${key.toString()}, Effect already tracked.`);
    }
}

// 触发更新 (核心调度入口)
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
        // console.log(`[Trigger] No dependencies found for target.`);
        return; // 没有依赖,直接返回
    }

    const dep = depsMap.get(key);
    if (!dep) {
        // console.log(`[Trigger] No dependencies found for key: ${key.toString()}`);
        return; // 该 key 没有依赖,直接返回
    }

    // 创建一个副本进行遍历,防止在遍历过程中修改原始 Set 导致问题
    const effectsToRun = new Set(dep);

    console.log(`[Trigger] Triggering effects for key: ${key.toString()}. Found ${effectsToRun.size} effects.`);

    // 核心:不再直接 run(),而是交给 scheduler (如果存在),否则直接 run
    effectsToRun.forEach(effect => {
        if (effect.scheduler) {
            console.log(`[Trigger] Scheduling effect via scheduler for key: ${key.toString()}`);
            // *** 关键点:调用调度器,而不是直接运行 effect.run() ***
            effect.scheduler(effect.run.bind(effect)); // 传入 effect.run 作为 job
        } else {
            console.log(`[Trigger] Running effect directly (no scheduler) for key: ${key.toString()}`);
            effect.run();
        }
    });
}

// 模拟 reactive (简化版 Proxy)
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      // 收集依赖
      // console.log(`[Proxy Get] Key: ${key.toString()}`);
      track(target, key);
      return res;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) { // 只有值真正改变时才触发更新
        console.log(`[Proxy Set] Key: ${key.toString()}, New Value: ${value}, Old Value: ${oldValue}. Triggering update.`);
        // 触发更新
        trigger(target, key);
      } else {
        // console.log(`[Proxy Set] Key: ${key.toString()}, Value unchanged. Skipping trigger.`);
      }
      return result;
    }
  });
}


// ============================================================
// 核心:调度器 (Scheduler) 和 异步更新队列 (The Core Logic)
// ============================================================

const queue = new Set(); // 使用 Set 存储待执行的 job (effect runner),利用 Set 自动去重
let isFlushing = false; // 标记是否正在刷新队列
let isFlushPending = false; // 标记是否已经安排了刷新任务 (防止重复安排 microtask)

const resolvedPromise = Promise.resolve(); // 用于创建 microtask
let currentFlushPromise = null; // 指向当前刷新周期的 Promise

/**
 * 将 job (effect runner) 添加到队列中。
 * 这是批量更新的核心入口。
 * @param {Function} job - 通常是 effect.run.bind(effect)
 */
function queueJob(job) {
    // console.log("[QueueJob] Attempting to queue job:", job);
    // 利用 Set 的特性自动去重,同一个 effect 在同一轮更新中只会被添加一次
    if (!queue.has(job)) {
        queue.add(job);
        console.log(`[QueueJob] Job added. Queue size: ${queue.size}`);
        // 安排队列刷新 (如果还没安排的话)
        queueFlush();
    } else {
        // console.log("[QueueJob] Job already in queue. Skipping.");
    }
}

/**
 * 安排队列在下一个 microtask 中刷新。
 */
function queueFlush() {
    // 如果当前没有正在刷新,并且没有已经安排的刷新任务
    if (!isFlushing && !isFlushPending) {
        console.log("[QueueFlush] Scheduling queue flush via microtask.");
        isFlushPending = true; // 标记已安排
        // 使用 Promise.resolve().then() 将 flushJobs 推入微任务队列
        currentFlushPromise = resolvedPromise.then(flushJobs);
    } else {
         // console.log(`[QueueFlush] Flush already pending or in progress (isFlushing: ${isFlushing}, isFlushPending: ${isFlushPending}). Skipping schedule.`);
    }
}

/**
 * 执行队列中的所有 job。
 * 这是在 microtask 中实际执行副作用的地方。
 */
function flushJobs() {
    isFlushing = true; // 标记开始刷新
    isFlushPending = false; // 重置安排标记
    console.log(`\n--- [FlushJobs] Starting flush. Queue size: ${queue.size} ---`);

    try {
        // 循环执行队列中的 job
        // 注意:Vue 源码中有更复杂的排序逻辑(例如,保证父组件先于子组件渲染,watch pre 效果先执行等)
        // 这里简化为按添加顺序执行
        queue.forEach(job => {
            console.log("[FlushJobs] Running job...");
            job(); // 执行 effect.run()
        });
    } finally {
        // 清空队列
        queue.clear();
        isFlushing = false; // 标记刷新结束
        currentFlushPromise = null; // 重置 Promise
        console.log("--- [FlushJobs] Finished flush. Queue cleared. ---\n");
    }
    // 在真实 Vue 中,这里还会处理 postFlush Cbs (比如 nextTick 的回调)
}

// 模拟 nextTick
// nextTick 的回调应该在当前刷新队列任务 (flushJobs) 完成之后执行
function nextTick(fn) {
    const p = currentFlushPromise || resolvedPromise; // 获取当前刷新 Promise 或已解决的 Promise
    return fn ? p.then(fn) : p; // 返回一个在队列刷新后解析的 Promise
}


// ============================================================
// 模拟组件渲染或 Watcher (使用调度器)
// ============================================================

// 模拟一个响应式状态对象
const state = reactive({
    count: 0,
    message: 'Hello'
});

// 模拟组件的渲染副作用
// **关键**:为 effect 提供 scheduler: queueJob
console.log(">>> Setting up component render effect with scheduler <<<");
const renderEffectRunner = effect(() => {
    // 这个函数模拟组件的渲染逻辑
    console.log(`[Render Effect] Component rendering... Count: ${state.count}, Message: ${state.message}`);
    // 模拟读取 DOM 或执行其他操作
    // document.getElementById('app').innerHTML = `Count is ${state.count}, Message is ${state.message}`;
}, {
    // **提供调度器函数**
    scheduler: (job) => {
        console.log("[Scheduler] Received job for render effect.");
        queueJob(job); // 将渲染任务推入队列,而不是立即执行
    }
});

// 模拟一个 Watcher (也使用调度器)
console.log("\n>>> Setting up watcher effect with scheduler <<<");
const watchEffectRunner = effect(() => {
    console.log(`[Watcher Effect] Watcher running... Count is ${state.count}`);
}, {
    scheduler: (job) => {
        console.log("[Scheduler] Received job for watch effect.");
        queueJob(job); // 也推入队列
    }
});


// ============================================================
// 模拟触发更新的操作 (在一个同步块中多次修改)
// ============================================================

console.log("\n>>> Starting synchronous updates <<<");

console.log("--- Modifying state.count (1st time) ---");
state.count++; // 触发 trigger -> scheduler -> queueJob(renderEffectRunner.run), queueJob(watchEffectRunner.run)

console.log("--- Modifying state.count (2nd time) ---");
state.count++; // 再次触发 trigger -> scheduler -> queueJob (但因为 Set 去重,队列不变)

console.log("--- Modifying state.message ---");
state.message = 'Vue 3'; // 触发 trigger -> scheduler -> queueJob(renderEffectRunner.run) (同样因为去重,队列不变)

console.log("--- Modifying state.count (3rd time) ---");
state.count = 10; // 触发 trigger -> scheduler -> queueJob (队列仍然不变)

console.log(`>>> Synchronous updates finished. Current state: count=${state.count}, message='${state.message}' <<<`);
console.log(`Current Queue Size (before microtask flush): ${queue.size}`); // 此时队列大小应该是 2 (render job + watch job)

// 模拟 nextTick 使用
nextTick(() => {
    console.log("--- [NextTick Callback] Executed after queue flush ---");
    console.log(`Final DOM state (simulated): Count is ${state.count}, Message is ${state.message}`);
});

console.log("\n(JavaScript synchronous execution ends here. Waiting for microtask queue to process...)");

// --- 微任务执行阶段 (由 JS 引擎自动处理) ---
// 1. Promise.resolve().then(flushJobs) 被执行
// 2. flushJobs() 开始执行
//    - isFlushing = true, isFlushPending = false
//    - 遍历 queue (包含 render job 和 watch job 各一个)
//    - 执行 render job (调用 renderEffectRunner.run) -> 输出 "[Render Effect] Component rendering... Count: 10, Message: Vue 3"
//    - 执行 watch job (调用 watchEffectRunner.run) -> 输出 "[Watcher Effect] Watcher running... Count is 10"
//    - 清空 queue
//    - isFlushing = false
// 3. flushJobs 的 Promise resolve
// 4. nextTick 的 .then(callback) 被执行 -> 输出 "[NextTick Callback] ..."

代码讲解概要:

  1. 响应式基础:

    • activeEffect, targetMap: 模拟依赖收集所需的基础结构。
    • ReactiveEffect: 模拟副作用对象的类,包含核心的 run 方法(执行副作用并收集依赖)、stop 方法和 scheduler 选项。deps 用于优化 cleanupEffect
    • cleanupEffect: 清理副作用与其依赖项之间的连接,在每次 run 之前调用,确保依赖关系是最新的。
    • effect: 创建 ReactiveEffect 实例的工厂函数,立即运行一次以收集初始依赖,并返回一个 runner。
    • track: 在响应式对象的 getter 中调用,将当前的 activeEffect 添加到目标属性的依赖集合(dep)中,并建立双向连接。
    • trigger: 在响应式对象的 setter 中调用,查找所有依赖该属性的 effect关键改动:如果 effectscheduler,则调用 scheduler 并传入 effect.run,否则直接运行 effect.run(Vue 3 组件渲染等总是有调度器的)。
    • reactive: 使用 Proxy 实现基本的响应式转换,在 get 中调用 track,在 set 中调用 trigger
  2. 调度器与队列核心 :

    • queue: 一个 Set,用于存储待执行的副作用 runner 函数 (job)。Set 的特性天然实现了自动去重,这是批量更新的关键。
    • isFlushing, isFlushPending: 状态标记,用于控制队列刷新逻辑,防止重复刷新和并发问题。
    • resolvedPromise, currentFlushPromise: 用于异步调度(微任务)。
    • queueJob(job):
      • 尝试将 job 添加到 queue Set 中。
      • 如果添加成功(即 job 原本不在队列中),则调用 queueFlush() 来安排刷新。
      • 如果 Set 中已存在该 job,则忽略,实现去重
    • queueFlush():
      • 检查是否可以安排刷新(!isFlushing && !isFlushPending)。
      • 如果可以,设置 isFlushPending = true
      • 核心:使用 resolvedPromise.then(flushJobs)flushJobs 函数推入微任务队列。这意味着 flushJobs 会在当前同步代码块执行完毕后、浏览器下次渲染前执行。
      • currentFlushPromise 保存这个 then 返回的 Promise,供 nextTick 使用。
    • flushJobs():
      • 在微任务回调中执行。
      • 设置 isFlushing = true,重置 isFlushPending = false
      • 遍历 queue 中的所有 job,并执行它们(即调用 effect.run())。注意:这里简化了执行顺序,真实 Vue 有排序逻辑(如 pre watcher、组件更新、post watcher)。
      • 使用 try...finally 确保即使某个 job 出错,队列也能被清空且状态能被重置。
      • 清空 queue,设置 isFlushing = false
    • nextTick(fn): (简化版)
      • 返回一个 Promise,该 Promise 会在 currentFlushPromise(即 flushJobs 完成)之后 resolve。
      • 如果提供了 fn,则将其注册为 .then 的回调。
  3. 模拟使用场景:

    • 创建响应式 state
    • 创建模拟的 renderEffectwatchEffect最关键的一点是,在 effectoptions 中传入 scheduler: queueJob。这告诉 trigger 不要直接运行这些 effect,而是把它们的 run 方法作为 job 交给 queueJob 处理。
    • 模拟同步代码块中的多次状态变更 (state.count++, state.message = ..., state.count = 10)。
      • 观察控制台输出:每次 set 都会调用 triggertrigger 发现有 scheduler,于是调用 queueJob
      • queueJob 第一次接收到 render job 和 watch job 时,会将它们加入 queue,并触发一次 queueFlush 来安排微任务。
      • 后续的 set 再次调用 triggerqueueJob,但因为 queue (Set) 中已经存在这两个 job,所以不会重复添加,queueFlush 也因为 isFlushPendingtrue 而不会重复安排微任务。
    • 在同步代码块结束后,打印队列大小,预期是 2。
    • 使用 nextTick 注册一个回调,验证它在 flushJobs 之后执行。
  4. 执行流程注释 (贯穿代码):

    • 详细的控制台日志 (console.log) 和注释解释了每一步的意图和执行流程,帮助跟踪数据变化、依赖收集、触发、调度、去重和最终的异步执行过程。

核心原理总结:

  1. 惰性执行与调度trigger 不直接执行副作用,而是将其交给调度器 (scheduler)。
  2. 队列与去重:调度器 (queueJob) 使用 Set 作为队列,天然地将同一 tick 内对同一副作用的多次触发合并为一次执行。
  3. 异步刷新 (Microtask)queueFlush 利用 Promise.resolve().then() (微任务) 将实际的副作用执行 (flushJobs) 推迟到当前同步代码执行栈清空之后。这确保了所有同步修改都完成后才进行一次性的更新。
  4. 用户时机 (nextTick):提供 nextTick 允许用户代码在这次批量更新完成后执行,通常用于获取更新后的 DOM 状态。

这个模拟虽然简化了许多细节(如错误处理、effect 的具体类型、复杂的调度优先级等),但它准确地反映了 Vue 3 批量更新机制的核心思想:通过带有去重功能的异步队列和微任务调度,将多次数据变更合并为单次高效的更新。