Skip to content

L35 · 调度器原理:nextTick 与批量更新 ​

🎯 本节目标:理解 Vue 3 的异步更新队列和 nextTick 实现
📦 本节产出:理解同一轮同步修改如何合并更新 + 手写 mini scheduler
🔗 前置钩子:L32 的 effect scheduler + L34 编译优化
🔗 后续钩子:L36 将讲完整的组件渲染流程

1. 为什么组件更新需要合并 ​

typescript
// 示意:页面已存在 #counter,使用 L32 的同步 mini effect
const state = reactive({ count: 0 })

effect(() => {
  // 假设这个 effect 负责更新 DOM
  document.getElementById('counter')!.textContent = String(state.count)
})

// 连续修改 100 次
for (let i = 0; i < 100; i++) {
  state.count++
}
// 不计首次渲染:同步触发会重跑 100 次,并尝试写 DOM 100 次
// 实际上只需要最终值,更新 1 次就够了

以下只比较这段同步循环中的更新,不把初次执行计入:

操作同步更新批量更新
effect 执行100 次1 次
DOM 读写100 次1 次
浏览器绘制由浏览器安排,不等于 DOM 写次数同样由浏览器安排

2. Vue 3 的解法:异步批量更新 ​


3. 手写 mini scheduler ​

这是独立的教学调度器,保存为 labs/mini-reactivity/scheduler.ts。它有主任务和 post 回调,pre 任务通过标记进入主队列;保存当前刷新 Promise,让 nextTick 等待本次刷新及期间加入的任务。

typescript
// labs/mini-reactivity/scheduler.ts
export type SchedulerJob = (() => void) & { id?: number; pre?: boolean; active?: boolean }
const queue: SchedulerJob[] = []
const queued = new Set<SchedulerJob>()
const postQueue = new Set<SchedulerJob>()
const resolvedPromise = Promise.resolve()
let currentFlushPromise: Promise<void> | null = null
let flushIndex = -1
function compare(a: SchedulerJob, b: SchedulerJob) {
  const aId = a.id ?? Infinity
  const bId = b.id ?? Infinity
  if (aId !== bId) return aId < bId ? -1 : 1
  return Number(!!b.pre) - Number(!!a.pre)
}
function queueFlush() {
  if (!currentFlushPromise) currentFlushPromise = resolvedPromise.then(flushJobs)
}
export function queueJob(job: SchedulerJob) {
  if (job.active === false || queued.has(job)) return
  queued.add(job)
  // 只在尚未执行的区域插入。教学版线性找位置,真实 Vue 用二分等优化。
  let index = queue.length
  while (index > flushIndex + 1 && compare(queue[index - 1]!, job) > 0) index--
  queue.splice(index, 0, job)
  queueFlush()
}
export function queuePostFlushCb(job: SchedulerJob) {
  if (job.active === false) return
  postQueue.add(job)
  queueFlush()
}
function flushJobs() {
  const runs = new Map<SchedulerJob, number>()
  const errors: unknown[] = []
  const invoke = (job: SchedulerJob) => {
    if (job.active === false) return
    const count = runs.get(job) || 0
    if (count >= 100) { errors.push(new Error('mini scheduler 检测到递归更新')); return }
    runs.set(job, count + 1)
    try { job() } catch (error) { errors.push(error) }
  }
  try {
    do {
      for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
        const job = queue[flushIndex]!
        queued.delete(job)
        invoke(job)
      }
      flushIndex = -1
      queue.length = 0
      const posts = [...postQueue].sort(compare)
      postQueue.clear()
      for (const job of posts) invoke(job)
      // post 中再排入任务,也属于当前刷新 Promise。
    } while (queue.length || postQueue.size)
  } finally {
    flushIndex = -1
    queue.length = 0
    queued.clear()
    postQueue.clear()
    currentFlushPromise = null
  }
  if (errors.length) throw new AggregateError(errors, 'mini scheduler 任务失败')
}
export function nextTick(): Promise<void>
export function nextTick<T>(fn: () => T): Promise<T>
export function nextTick<T>(fn?: () => T): Promise<void | T> {
  const promise = currentFlushPromise || resolvedPromise
  return fn ? promise.then(fn) : promise
}

本例任务是同步函数,不等待它们发起的异步业务请求。任务用函数身份去重,因此不能每次都把同一个工作包装成新函数再入队。任务抛错时其余任务仍会执行,finally 重置队列;这个 mini 会让等待的 nextTick 拒绝,调用方需要捕获。递归阈值只是防止演示失控,不是 Vue 公共 API 契约。

3.1 集成到响应式系统 ​

直接使用 L32 已有的 effect,不再用一个缺少 cleanup/stop 的新版本覆盖它。稳定的 job 还要在执行时检查 effect 是否已停止,避免卸载后已排队任务继续更新:

typescript
// 演示片段:renderFn 是调用方的渲染函数
import { effect, type EffectRunner } from './mini-reactivity'
import { queueJob, type SchedulerJob } from './scheduler'
let runner: EffectRunner<void>
const job: SchedulerJob = () => { if (runner.effect.active) runner() }
runner = effect(renderFn, { scheduler: () => queueJob(job) })
// 需要停止时调用 runner.effect.stop();已入队 job 会检查 active。

3.2 验证批量更新 ​

下面是可运行的版本,包含初始渲染、同步修改、下一次刷新,以及停止后的队列行为:

typescript
// labs/mini-reactivity/scheduler-demo.ts
import assert from 'node:assert/strict'
import { reactive, effect, type EffectRunner } from './mini-reactivity'
import { queueJob, nextTick, type SchedulerJob } from './scheduler'
async function main() {
  const state = reactive({ count: 0 })
  const seen: number[] = []
  let runner: EffectRunner<void>
  const job: SchedulerJob = () => { if (runner.effect.active) runner() }
  runner = effect(() => { seen.push(state.count) }, { scheduler: () => queueJob(job) })
  state.count = 1
  state.count = 2
  state.count = 3
  assert.deepEqual(seen, [0])
  await nextTick()
  assert.deepEqual(seen, [0, 3])
  state.count = 4
  runner.effect.stop()
  await nextTick()
  assert.deepEqual(seen, [0, 3])
  console.log('批量更新与停止验证通过')
}
main().catch(error => { console.error(error); process.exitCode = 1 })

执行 npx tsx scheduler-demo.ts,并用 npx tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext mini-reactivity.ts scheduler.ts scheduler-demo.ts 检查类型。


4. 优先级与 pre / post ​

本节 mini 通过 id 排序;同 id 的 pre job 放在普通 job 之前。传入的小 id 代表较早创建的组件,只能影响未执行的任务,不能把任务插回已经执行完的位置。可以单独验证:

typescript
// 在 async 函数内,使用 scheduler.ts 的三个导出
const logs: string[] = []
const parent = Object.assign(() => { logs.push('parent') }, { id: 1 })
const childPre = Object.assign(() => { logs.push('child pre') }, { id: 2, pre: true })
const child = Object.assign(() => { logs.push('child') }, { id: 2 })
queueJob(child)
queueJob(childPre)
queueJob(parent)
queuePostFlushCb(() => { logs.push('post') })
await nextTick()
// logs: ['parent', 'child pre', 'child', 'post']

Vue 3.5.43 的主 queue 同样通过 job id 与 PRE 标记组织顺序,另有 post 回调队列;不是“所有 pre 放在独立数组中,全部执行后才更新任何组件”。组件更新过程中还会处理该组件相关的 pre 回调等细节。本例不实现完整组件关系、Suspense、错误上下文和所有内部 flags。3.5.43 scheduler.ts

这是单个父子关系中的时序示意,不是整个应用固定的全局四步。默认 watcher 的公共规则是父组件更新之后、所属组件 DOM 更新之前;post 在所属组件 DOM 更新之后。watch 回调时机


5. nextTick ​

第 3 节已经实现 nextTick:有刷新时返回 currentFlushPromise,否则使用已 resolved 的 Promise。不能只写 Promise.resolve() 就说等待了 Vue 更新;正确目标是当前排队的那次刷新。真实 Vue 的 nextTick 也遵循这个核心关系。nextTick

5.1 使用场景 ​

typescript
// 在已挂载组件的 async 事件处理器内,count 初值为 0,counter 指向显示它的元素。
// 第 6 节给出完整组件;这里复用 count 和 counter,不另建无关的 ref。

count.value = 100

// ❌ 此时 DOM 还没更新
console.log(counter.value?.textContent)  // '0'

// ✅ nextTick 后 DOM 已更新
await nextTick()
console.log(counter.value?.textContent)  // '100'

5.2 在 Composition API 中 ​

vue
<!-- 独立 Vue 组件示例,不替换 mini 实验的入口 -->
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const items = ref<number[]>([])
const listRef = ref<HTMLElement | null>(null)
async function addItem() {
  items.value.push(items.value.length + 1)
  await nextTick()
  listRef.value?.scrollTo({ top: listRef.value.scrollHeight })
}
</script>
<template>
  <button @click="addItem">添加</button>
  <ul ref="listRef" style="height: 100px; overflow: auto">
    <li v-for="item in items" :key="item">条目 {{ item }}</li>
  </ul>
</template>

5.3 常见场景 ​

场景为什么需要 nextTick
操作 DOM 尺寸读取 Vue 已提交的 DOM;字体/图片异步加载仍可能改变尺寸
滚动到底部新元素加入后列表高度变化
focus 自动聚焦v-if 从 false 变 true 后 DOM 才存在
第三方库初始化需要 DOM 存在后才能挂载

6. watch 的 flush 选项 ​

  • 默认 pre:所属组件 DOM 更新前,适合不依赖新 DOM 的回调。
  • post:所属组件 DOM 更新后,适合读该组件更新后的 DOM。
  • sync:每次触发同步执行,不进行默认的批处理;适合确有同步要求的简单场景,频繁数组变更时要谨慎。

首次执行还受 watch / watchEffect、immediate 等影响,不能只看 flush。例如默认 watchEffect 首次会立即执行,不是必须等微任务;watch 默认不立即调用回调。

实验目录目前只有 Vite,没有用于 SFC 的 Vue 插件。现在要在浏览器加载 Vue 的 esm-bundler 构建,先新增配置,显式提供它需要的编译标记;普通 create-vue 项目的 Vue 插件会自动提供默认值。Vue 编译标记

typescript
// labs/mini-reactivity/vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
  define: {
    __VUE_OPTIONS_API__: 'true',
    __VUE_PROD_DEVTOOLS__: 'false',
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
  },
})

以下是完整的真实 Vue 实验(不使用 mini scheduler)。保存文件后,把实验 index.html 的 script src 临时改为 /flush-demo.ts,用 L33 的 Vite 命令打开:

typescript
// labs/mini-reactivity/flush-demo.ts
import { createApp, defineComponent, h, ref, watch, watchPostEffect, nextTick } from 'vue'
const App = defineComponent({
  setup() {
    const count = ref(0)
    const counter = ref<HTMLElement | null>(null)
    watch(count, () => console.log('pre DOM:', counter.value?.textContent))
    watch(count, () => console.log('post DOM:', counter.value?.textContent), { flush: 'post' })
    watchPostEffect(() => {
      // 要读取响应式源;只读普通 DOM textContent 不会订阅 count。
      console.log('postEffect state/DOM:', count.value, counter.value?.textContent)
    })
    async function change() {
      count.value++
      count.value++
      console.log('sync DOM:', counter.value?.textContent)
      await nextTick()
      console.log('nextTick DOM:', counter.value?.textContent)
    }
    return () => h('section', [
      h('p', { ref: counter }, String(count.value)),
      h('button', { onClick: change }, '修改两次'),
    ])
  },
})
createApp(App).mount('#app')

首次挂载后点击一次:sync/pre 看到旧文本 0,post 与等待本次更新的 nextTick 看到 2。post watcher 与 watchPostEffect 都属于 post 阶段,不依赖它们彼此的排列顺序。


7. 事件循环中的位置 ​

Vue 通常通过 Promise 微任务刷新组件更新队列。浏览器不必在每个任务后都绘制,DOM 更新也不等于已绘制到屏幕;nextTick 不是“事件循环最后一个微任务”。先改状态,再 await nextTick,才能等待这次排队的更新;同步读取 DOM/布局、sync watcher 或分散在不同任务里的修改有不同表现。


8. 调试技巧 ​

在第 6 节 change 中可以用 performance.now() 包住“修改状态 → await nextTick”这段,测得的是代码与 Vue 刷新的等待时间,不包含完整绘制、图片解码等所有视觉成本。真正定位性能问题仍要看浏览器 Performance 记录,不能把这个差值直接称为用户可见更新耗时。

任务异常、刷新中继续入队、post 再入队与组件在刷新前停止,都是调度器需要验证的边界。第 3 节 mini 保留这些基本保护,但没有复刻 Vue 的全部任务标记与组件关联逻辑。


9. 本节总结 ​

检查清单 ​

  • [ ] 能说明同一轮同步修改的去重范围,不把 DOM 写入等同于浏览器绘制
  • [ ] 能手写 queueJob + Set 去重 + Promise.resolve() 微任务
  • [ ] 能解释当前 Vue 的主队列/PRE标记/post队列与所属组件的时序
  • [ ] 理解 job id 的排序作用与新任务插入边界
  • [ ] 能解释 nextTick 的作用和使用场景
  • [ ] 理解 watch 的 flush: 'pre' | 'post' | 'sync' 区别
  • [ ] 理解 Vue 更新在事件循环微任务阶段完成

Git 提交 ​

bash
git add .
git commit -m "L35: 调度器 + nextTick + 批量更新原理"

🔬 深度专题 ​

📖 D02 · Vue 3 响应式调度器 + nextTick — 为什么修改数据后 DOM 不立即更新?

🔗 → 下一节 ​

L36 将把 reactive + effect + scheduler + vdom 串联起来,讲述一个组件从 <script setup> 到真实 DOM 的完整渲染流程。