L35 · 调度器原理:nextTick 与批量更新
🎯 本节目标:理解 Vue 3 的异步更新队列和 nextTick 实现
📦 本节产出:理解同一轮同步修改如何合并更新 + 手写 mini scheduler
🔗 前置钩子:L32 的 effect scheduler + L34 编译优化
🔗 后续钩子:L36 将讲完整的组件渲染流程1. 为什么组件更新需要合并
// 示意:页面已存在 #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 等待本次刷新及期间加入的任务。
// 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 是否已停止,避免卸载后已排队任务继续更新:
// 演示片段: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 验证批量更新
下面是可运行的版本,包含初始渲染、同步修改、下一次刷新,以及停止后的队列行为:
// 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 代表较早创建的组件,只能影响未执行的任务,不能把任务插回已经执行完的位置。可以单独验证:
// 在 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 使用场景
// 在已挂载组件的 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 组件示例,不替换 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 编译标记
// 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 命令打开:
// 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 提交
git add .
git commit -m "L35: 调度器 + nextTick + 批量更新原理"🔬 深度专题
📖 D02 · Vue 3 响应式调度器 + nextTick — 为什么修改数据后 DOM 不立即更新?
🔗 → 下一节
L36 将把 reactive + effect + scheduler + vdom 串联起来,讲述一个组件从 <script setup> 到真实 DOM 的完整渲染流程。