主题
实现 nextTick 与异步调度
本节对标 Vue 3 源码
@vue/runtime-core中的scheduler.ts
为什么需要异步调度?
考虑以下场景:
ts
const count = ref(0)
count.value = 1
count.value = 2
count.value = 3如果每次赋值都同步触发组件重新渲染,那么 DOM 会更新 3 次。但实际上只需要在最后更新一次(值为 3)即可。
Vue 3 的解决方案:将组件更新 effect 放入异步队列,在当前同步代码执行完毕后(微任务阶段),统一执行一次更新。
同步代码 微任务阶段
count.value = 1 → queueJob ─┐
count.value = 2 → queueJob │(去重,同一 job 只入队一次)
count.value = 3 → queueJob ─┘
→ flushJobs → componentUpdateFn(只执行一次)对标源码位置:packages/runtime-core/src/scheduler.ts
nextTick 的实现
nextTick 是整个调度系统的基石——利用 Promise.resolve() 将回调推入微任务队列:
ts
// packages/runtime-core/src/scheduler.ts
// 预创建一个已 resolve 的 Promise,作为微任务调度的基础,避免重复创建 Promise 实例
const resolvedPromise = Promise.resolve()
// 记录当前正在执行的 flush 任务对应的 Promise
// 如果有正在 flush 的任务,nextTick 会等待该轮 flush 完成后再执行回调
let currentFlushPromise: Promise<void> | null = null
// nextTick:将回调推入微任务队列,确保在当前 flush 完成后执行
// 如果没有传入 fn,则返回 Promise 供 await 使用
export function nextTick(fn?: () => void): Promise<void> {
// 优先使用 currentFlushPromise,保证回调在当前 flush 完成后才执行
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p // 有回调则链式调用,否则直接返回 Promise
}设计分析
resolvedPromise:预创建的已 resolve 的 Promise,避免重复创建currentFlushPromise:如果当前有正在 flush 的任务,nextTick会等待该轮 flush 完成后再执行。这确保了nextTick回调中能获取到最新的 DOM 状态- 返回 Promise:支持
await nextTick()的用法
为什么是微任务而不是宏任务?
同步代码 → 微任务(Promise.then / MutationObserver)→ 渲染 → 宏任务(setTimeout)使用微任务可以在浏览器渲染之前完成所有状态更新和 DOM 操作,避免用户看到中间状态的闪烁。Vue 2 曾在微任务和宏任务之间反复切换,Vue 3 最终统一使用 Promise.resolve().then() 微任务。
调度队列设计
核心数据结构
ts
// packages/runtime-core/src/scheduler.ts
// 调度任务接口,扩展了 Function 类型,添加调度相关的元信息
export interface SchedulerJob extends Function {
id?: number // 任务 ID,用于排序(父组件 id < 子组件 id)
active?: boolean // 是否激活,false 时跳过执行
computed?: boolean // 是否为计算属性的 effect
allowRecurse?: boolean // 是否允许递归(job 执行期间再次入队自身)
ownerInstance?: ComponentInternalInstance // 所属的组件实例
}
const queue: SchedulerJob[] = [] // 主队列:存放组件更新 effect
const pendingPostFlushCbs: SchedulerJob[] = [] // 后置队列:存放 mounted/updated 等 hooks
let isFlushing = false // 标记是否正在执行队列中的任务
let isFlushPending = false // 标记是否已注册微任务等待 flush
let flushIndex = 0 // 当前正在执行的主队列任务索引Vue 3 的调度系统有三类队列:
Pre Flush Queue → Main Queue (queue) → Post Flush Queue
(queuePreFlushCb) (queueJob) (queuePostFlushCb)
watchers (pre) 组件更新 effect mounted/updated hooks
watchers (post)queueJob —— 主队列入队
ts
// 将任务加入主队列,核心逻辑是去重——同一个 job 不会重复入队
export function queueJob(job: SchedulerJob) {
// 去重:同一个 job 不会重复入队
// 使用 includes 检查时要考虑 flushIndex,避免跳过正在处理中的 job
// 如果 allowRecurse 为 true,从 flushIndex + 1 开始检查,允许当前 job 再次入队
if (
!queue.length ||
!queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)
) {
if (job.id == null) {
queue.push(job) // 没有 id 的 job 直接推入队列末尾
} else {
// 有 id 的 job 按 id 插入到正确位置(保持排序)
// 这样可以确保父组件(小 id)在子组件(大 id)之前执行
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush() // 触发异步 flush(如果尚未触发)
}
}去重机制是异步调度的关键:
ts
const count = ref(0)
// 假设组件的更新 effect 是 job A
count.value = 1 // trigger → scheduler → queueJob(A) → queue: [A]
count.value = 2 // trigger → scheduler → queueJob(A) → queue: [A](A 已在队列中,跳过)
count.value = 3 // trigger → scheduler → queueJob(A) → queue: [A](A 已在队列中,跳过)
// 微任务阶段
// flushJobs → 执行 A → 组件只更新一次,count.value 已经是 3queueFlush —— 触发异步 flush
ts
// 触发异步 flush:通过 Promise.then 将 flushJobs 注册为微任务
function queueFlush() {
// 双重标志位保护:只有在既没有正在 flush、也没有等待 flush 时才注册微任务
// 这样即使 queueJob 被调用多次,也只会注册一次微任务
if (!isFlushing && !isFlushPending) {
isFlushPending = true // 标记已注册等待 flush
// 将 flushJobs 放入微任务队列,等同步代码全部执行完后再统一 flush
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}通过 isFlushPending 标志位确保只注册一次微任务,即使 queueJob 被调用多次。
flushJobs —— 执行队列
ts
// 执行队列中的所有任务——调度系统的核心执行函数
function flushJobs() {
isFlushPending = false // 微任务已开始执行,清除等待标志
isFlushing = true // 标记正在 flush,防止重复触发
// 排序:按 job.id 升序
// 组件的 id 在创建时递增分配,父组件 id < 子组件 id
// 这保证了父组件先于子组件更新
queue.sort(comparator)
try {
// 遍历执行主队列中的所有任务
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
// 只执行存在且处于激活状态的 job
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) // 带错误处理地执行 job
}
}
} finally {
// 无论是否出错,都要清理状态
flushIndex = 0 // 重置索引
queue.length = 0 // 清空主队列
// 执行 post flush 回调(如 mounted、updated 等生命周期 hooks)
flushPostFlushCbs()
isFlushing = false // 标记 flush 完成
currentFlushPromise = null // 清除当前 flush 的 Promise 引用
// 如果在 flush 过程中有新的 job 被加入(如 hook 中触发了新的状态变更),递归 flush
if (queue.length || pendingPostFlushCbs.length) {
flushJobs()
}
}
}
// 比较器:按 job.id 升序排列,确保父组件先于子组件更新
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
return diff
}
// 获取 job 的 id,没有 id 的 job 排在最后(Infinity)
const getId = (job: SchedulerJob): number =>
job.id == null ? Infinity : job.id排序的重要性
Parent (id: 1)
└── Child (id: 2)
└── GrandChild (id: 3)如果 Child 和 Parent 都需要更新,排序保证 Parent 先更新。这很重要,因为:
- 父组件更新可能导致子组件被卸载(条件渲染),此时子组件的更新就是多余的
- 父组件的 props 变化需要先传递给子组件,子组件再基于新 props 更新
Pre Flush 队列
Pre flush 回调在主队列执行前运行,主要用于 watch 的 flush: 'pre' 模式(默认模式):
ts
const pendingPreFlushCbs: SchedulerJob[] = [] // 待执行的 pre flush 回调列表
let activePreFlushCbs: SchedulerJob[] | null = null // 正在执行中的 pre flush 回调快照
let preFlushIndex = 0 // 当前执行的 pre flush 回调索引
// 将回调加入 pre flush 队列(用于 watch 的 flush: 'pre' 模式)
export function queuePreFlushCb(cb: SchedulerJob) {
// 去重:同一个回调不会重复入队
if (!pendingPreFlushCbs.includes(cb)) {
pendingPreFlushCbs.push(cb)
queueFlush() // 确保已注册微任务
}
}
// 执行所有 pre flush 回调
function flushPreFlushCbs() {
if (pendingPreFlushCbs.length) {
// 通过 Set 去重后生成快照,避免在执行过程中数组被修改导致问题
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0 // 清空待执行列表
// 遍历执行所有 pre flush 回调
for (preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++) {
activePreFlushCbs[preFlushIndex]()
}
activePreFlushCbs = null // 清除快照引用
preFlushIndex = 0
// 递归调用:pre flush 中可能产生新的 pre flush cb(如 watcher 回调中又触发了其他 watcher)
flushPreFlushCbs()
}
}Post Flush 队列
Post flush 回调在主队列执行后运行,用于生命周期 hooks(mounted、updated)和 watch 的 flush: 'post' 模式:
ts
const pendingPostFlushCbs: SchedulerJob[] = [] // 待执行的 post flush 回调列表
let activePostFlushCbs: SchedulerJob[] | null = null // 正在执行中的 post flush 回调快照
let postFlushIndex = 0 // 当前执行的 post flush 回调索引
// 将回调加入 post flush 队列(用于生命周期 hooks 和 watch 的 flush: 'post' 模式)
export function queuePostFlushCb(cb: SchedulerJob | SchedulerJob[]) {
if (!Array.isArray(cb)) {
// 单个回调:检查是否已在活跃队列中,避免重复执行
if (
!activePostFlushCbs ||
!activePostFlushCbs.includes(cb, cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex)
) {
pendingPostFlushCbs.push(cb)
}
} else {
// 如果传入数组(如 invokeArrayFns 的 hooks 数组),直接展开推入
// 数组中的 hook 已经在组件级别去重,这里不需要再检查
pendingPostFlushCbs.push(...cb)
}
queueFlush() // 确保已注册微任务
}
// 执行所有 post flush 回调
function flushPostFlushCbs() {
if (pendingPostFlushCbs.length) {
// 通过 Set 去重,避免同一个回调在一轮 flush 中被执行多次
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0 // 清空待执行列表
// 如果已经有活跃的 post flush 队列在执行,将新的回调追加到其中
// 这处理了 post flush 回调中又产生新的 post flush 回调的场景
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped)
return
}
activePostFlushCbs = deduped
// 按 id 排序,保证父组件的 hooks 先于子组件执行
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
// 遍历执行所有 post flush 回调
for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
activePostFlushCbs[postFlushIndex]()
}
activePostFlushCbs = null // 清除快照引用
postFlushIndex = 0
}
}完整的 flush 流程
ts
// 完整的 flushJobs 流程——按 pre → main → post 三级队列依次执行
function flushJobs() {
isFlushPending = false // 清除等待标志
isFlushing = true // 标记正在执行
// 1. 执行 pre flush 回调(watchers with flush: 'pre')
// 在主队列之前执行,确保 watcher 的回调能在组件更新前运行
flushPreFlushCbs()
// 2. 排序主队列
// 按 id 升序排列,保证父组件(小 id)先于子组件(大 id)更新
queue.sort(comparator)
// 3. 执行主队列(组件更新)
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0 // 重置索引
queue.length = 0 // 清空主队列
// 4. 执行 post flush 回调(lifecycle hooks, watchers with flush: 'post')
// 此时所有组件的 DOM 更新已完成,可以安全访问最新的 DOM
flushPostFlushCbs()
isFlushing = false // 标记 flush 完成
currentFlushPromise = null // 清除 Promise 引用
// 5. 如果过程中产生了新任务(如 hook 中触发了新的状态变更),再次 flush
// 递归调用确保所有任务都被处理完毕
if (queue.length || pendingPostFlushCbs.length) {
flushJobs()
}
}
}执行时序图
同步代码阶段:
count.value = 1 → queueJob(componentUpdate)
count.value = 2 → queueJob(componentUpdate) [去重,不入队]
list.value.push(item) → queueJob(componentUpdate) [去重,不入队]
↓ 同步代码执行完毕
微任务阶段 (Promise.then):
flushJobs()
│
├── flushPreFlushCbs() ← watch(source, cb) 默认 flush: 'pre'
│
├── queue.sort() ← 父 → 子排序
│
├── for job of queue: ← 组件 componentUpdateFn
│ ├── beforeUpdate hooks(同步)
│ ├── render()
│ ├── patch()
│ └── queuePostFlushCb(updated hooks)
│
├── flushPostFlushCbs() ← mounted/updated hooks
│ ← watch(source, cb, { flush: 'post' })
│
└── 如果有新任务 → 递归 flushJobs()
↓ 微任务完成
浏览器渲染(painting)调度器如何与组件更新连接
在 setupRenderEffect 中,组件的更新 effect 使用 scheduler 来触发异步更新:
ts
// packages/runtime-core/src/renderer.ts
function setupRenderEffect(instance, initialVNode, container, anchor) {
// 定义组件的更新函数,包含挂载和更新两个分支
const componentUpdateFn = () => {
if (!instance.isMounted) {
// mount 逻辑...
} else {
// update 逻辑...
}
}
// 创建响应式副作用
// 第二个参数是 scheduler(调度器),当响应式数据变化时不会直接执行 effect
// 而是调用 scheduler 将更新任务入队,实现异步批量更新
const effect = new ReactiveEffect(
componentUpdateFn,
// scheduler: 响应式数据变化时不直接执行 effect,而是入队
() => queueJob(update),
)
const update: SchedulerJob = (instance.update = () => effect.run())
update.id = instance.uid // 设置 job 的 id 为组件的 uid,用于排序(父→子)
update() // 首次调用,触发挂载流程
}关键点:ReactiveEffect 的第二个参数是 scheduler。当响应式数据变化触发 trigger 时,如果 effect 有 scheduler,就调用 scheduler 而不是直接调用 effect.run()。
ts
// packages/reactivity/src/effect.ts
// 触发单个 effect:根据是否有 scheduler 决定执行方式
function triggerEffect(effect: ReactiveEffect) {
if (effect.scheduler) {
// 如果 effect 有调度器,调用调度器而非直接执行
// 对于组件更新 effect,scheduler 就是 () => queueJob(update)
effect.scheduler() // → queueJob(update)
} else {
// 没有调度器的 effect 直接同步执行(如普通的 watchEffect)
effect.run()
}
}简化实现
ts
// scheduler.ts
const queue: Function[] = [] // 主队列:存放组件更新任务
const postFlushCbs: Function[] = [] // 后置队列:存放生命周期 hooks 等回调
const resolvedPromise = Promise.resolve() // 预创建的已 resolve 的 Promise
let currentFlushPromise: Promise<void> | null = null // 当前 flush 任务的 Promise
let isFlushing = false // 是否正在执行队列
let isFlushPending = false // 是否已注册微任务等待执行
// nextTick:返回一个在当前 flush 完成后 resolve 的 Promise
export function nextTick(fn?: () => void): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p
}
// 将任务加入主队列,自动去重
export function queueJob(job: any) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush() // 确保触发异步 flush
}
}
// 将回调加入后置队列(支持单个或数组)
export function queuePostFlushCb(cb: Function | Function[]) {
if (Array.isArray(cb)) {
postFlushCbs.push(...cb) // 数组直接展开推入
} else {
postFlushCbs.push(cb)
}
queueFlush()
}
// 触发异步 flush:利用 Promise.then 注册微任务
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs) // 将 flushJobs 放入微任务队列
}
}
// 核心执行函数:排序后依次执行所有任务
function flushJobs() {
isFlushPending = false
isFlushing = true
// 按 id 排序,确保父组件先于子组件更新;无 id 的任务排在最后
queue.sort((a, b) => (a.id ?? Infinity) - (b.id ?? Infinity))
try {
// 遍历执行主队列中的每个任务
for (let i = 0; i < queue.length; i++) {
const job = queue[i]
if (job) {
job() // 执行组件的 componentUpdateFn
}
}
} finally {
queue.length = 0 // 清空主队列
flushPostFlushCbs() // 执行后置回调(mounted/updated hooks)
isFlushing = false
currentFlushPromise = null
// 如果 flush 过程中又产生了新的任务,递归执行
if (queue.length || postFlushCbs.length) {
flushJobs()
}
}
}
// 执行后置队列中的所有回调
function flushPostFlushCbs() {
if (postFlushCbs.length) {
// 通过 Set 去重,避免同一回调被重复执行
const cbs = [...new Set(postFlushCbs)]
postFlushCbs.length = 0 // 清空待执行列表
for (let i = 0; i < cbs.length; i++) {
cbs[i]() // 依次执行每个回调
}
}
}对比 React 调度器(Scheduler)
| 维度 | Vue 3 Scheduler | React Scheduler |
|---|---|---|
| 调度粒度 | 组件级(每个组件一个 job) | Fiber 级(每个 Fiber 一个 work unit) |
| 优先级 | 无优先级,按父→子顺序 | 5 个优先级(Immediate / UserBlocking / Normal / Low / Idle) |
| 时间切片 | 无(同步执行所有 jobs) | 有(5ms 时间切片,可中断) |
| 异步机制 | Promise.resolve().then() 微任务 | MessageChannel 宏任务 |
| 可中断 | 不可中断(一旦开始 flush,同步执行完) | 可中断(通过 shouldYield 检查) |
| 批量更新 | 自动(同一微任务内的变更自动合并) | React 18+ 自动批量(createRoot) |
| 并发模式 | 无 | 有(Concurrent Mode,可中断渲染) |
| 复杂度 | ~200 行代码 | ~600 行代码 |
核心差异:同步 vs 可中断
Vue 3:
queueJob → flushJobs → 同步执行所有组件更新 → 完成
(简单可预测,但大量组件更新时可能阻塞主线程)
React:
scheduleCallback(priority, work) → workLoop → 执行一个 fiber
→ shouldYield()? → 是 → 让出主线程 → requestIdleCallback/MessageChannel → 继续
→ 否 → 执行下一个 fiber
(复杂但不阻塞主线程,保持页面响应性)为什么 Vue 3 不需要时间切片?
Vue 3 的响应式系统可以精确知道哪些组件需要更新(依赖收集),不像 React 需要从根节点开始 reconcile。因此 Vue 3 的更新范围通常更小,同步执行也不会造成明显的卡顿。
测试用例
ts
import { describe, it, expect, vi } from 'vitest'
import { ref, nextTick, h, render } from '../src'
describe('scheduler', () => {
// 测试:nextTick 的回调在同步代码执行完毕后才执行
it('nextTick', async () => {
const calls: number[] = []
calls.push(1) // 同步:先推入 1
const p = nextTick(() => {
calls.push(3) // 微任务:最后推入 3
})
calls.push(2) // 同步:再推入 2
await p
// 断言执行顺序:同步代码(1, 2)先于微任务(3)
expect(calls).toEqual([1, 2, 3])
})
// 测试:nextTick 支持 await 用法
it('nextTick with await', async () => {
let value = 0
value = 1
await nextTick() // 等待微任务完成
expect(value).toBe(1)
})
// 测试:多次修改响应式数据只触发一次渲染(批量更新)
it('should batch multiple state changes', async () => {
const renderSpy = vi.fn() // 用于追踪渲染函数被调用的次数
const count = ref(0)
const Comp = {
setup() {
return () => {
renderSpy() // 每次渲染都会调用
return h('div', null, String(count.value))
}
},
}
const root = document.createElement('div')
render(h(Comp), root)
// 断言:首次挂载渲染了一次
expect(renderSpy).toHaveBeenCalledTimes(1)
// 连续修改 3 次——由于异步调度 + 去重,不会立即触发渲染
count.value++
count.value++
count.value++
// 断言:同步代码中修改后,渲染函数还没有被再次调用
expect(renderSpy).toHaveBeenCalledTimes(1)
await nextTick() // 等待异步调度完成
// 断言:3 次修改只触发了 1 次额外渲染(总共 2 次)
expect(renderSpy).toHaveBeenCalledTimes(2)
// 断言:DOM 显示最终值 3
expect(root.innerHTML).toBe('<div>3</div>')
})
// 测试:父组件在子组件之前更新(按 id 排序)
it('should update parent before child', async () => {
const calls: string[] = [] // 记录渲染顺序
const Child = {
props: ['msg'],
setup(props: any) {
return () => {
calls.push('child render') // 记录子组件渲染
return h('span', null, props.msg)
}
},
}
const msg = ref('hello')
const Parent = {
setup() {
return () => {
calls.push('parent render') // 记录父组件渲染
return h('div', null, [h(Child, { msg: msg.value })])
}
},
}
const root = document.createElement('div')
render(h(Parent), root)
calls.length = 0 // 清空首次渲染的记录
msg.value = 'world' // 修改数据触发更新
await nextTick()
// 断言:父组件先于子组件渲染,体现了调度器的排序机制
expect(calls[0]).toBe('parent render')
})
// 测试:同一个 job 不会被重复执行(去重机制)
it('should deduplicate jobs', async () => {
const spy = vi.fn()
const count = ref(0)
const Comp = {
setup() {
return () => {
spy() // 追踪渲染次数
return h('div', null, String(count.value))
}
},
}
const root = document.createElement('div')
render(h(Comp), root)
expect(spy).toHaveBeenCalledTimes(1) // 首次渲染
// 修改 100 次——queueJob 去重后只保留一个 job
for (let i = 0; i < 100; i++) {
count.value = i
}
await nextTick()
// 断言:100 次修改只触发了 1 次额外渲染(总共 2 次)
expect(spy).toHaveBeenCalledTimes(2)
})
// 测试:nextTick 回调中可以获取到更新后的 DOM
it('nextTick should see updated DOM', async () => {
const count = ref(0)
const Comp = {
setup() {
return () => h('div', null, String(count.value))
},
}
const root = document.createElement('div')
render(h(Comp), root)
expect(root.innerHTML).toBe('<div>0</div>')
count.value = 42 // 修改数据
await nextTick() // 等待 DOM 更新完成
// 断言:nextTick 后 DOM 已经是最新的值
expect(root.innerHTML).toBe('<div>42</div>')
})
// 测试:post flush 回调在主队列(render)执行后才运行
it('queuePostFlushCb should run after main queue', async () => {
const calls: string[] = []
const count = ref(0)
const Comp = {
setup() {
return () => {
calls.push('render') // 记录渲染时机
return h('div', null, String(count.value))
}
},
}
const root = document.createElement('div')
render(h(Comp), root)
calls.length = 0 // 清空首次渲染记录
count.value++ // 触发更新
await nextTick()
// 断言:render 在 post flush 之前执行
expect(calls[0]).toBe('render')
})
})本节小结
- nextTick —— 基于
Promise.resolve().then()的微任务调度,确保回调在 DOM 更新后执行 - queueJob —— 主队列入队,自动去重,同一组件的更新 effect 只执行一次
- flushJobs —— 按
job.id排序后依次执行,保证父→子的更新顺序 - 三级队列 —— pre flush(watchers)→ main queue(组件更新)→ post flush(生命周期 hooks)
- 与 React 的差异 —— Vue 3 使用微任务同步执行,简单可预测;React 使用宏任务 + 时间切片,支持可中断的并发渲染
下一节实现 Teleport 与 KeepAlive 内置组件。