vue3 源码学习(四)从异步更新到 Scheduler 调度器
一、回顾上篇:我们实现了什么
上篇文章里,我们用一个 Set + 微任务,解决了「同步多次修改触发多次 effect」的问题:
let effectQueue = new Set()
let isFlushing = false
function queueEffect(effect) {
effectQueue.add(effect)
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(() => {
effectQueue.forEach(effect => effect())
effectQueue.clear()
isFlushing = false
})
}
}
这已经具备了 Vue 异步更新的核心思想:
- 数据变化 ≠ 立即执行副作用
- 副作用先入队
- 当前同步代码跑完后,在微任务里统一执行
- 同一个 effect 只执行一次
但问题也随之而来
二、上篇实现的问题
我们的简易版有几个明显短板:
1. 没有「任务类型」
Vue 里不是所有任务都是「更新组件」:
- 组件渲染更新(main job)
watch回调(pre / post)onBeforeUpdate/onUpdatednextTick回调computed脏检查
它们执行顺序不能一样。
2. 没有优先级
组件树里:
App
└─ List
└─ Item
如果 List 和 Item 都依赖同一个状态,谁先更新?
Vue 的规则是:
父组件先于子组件更新
否则子组件可能拿到旧的 props,导致二次渲染。
3. effect 和「组件更新」混为一谈
我们现在的模型是:
数据变化 → effect 执行
但 Vue 真实模型是:
数据变化 → trigger
→ effect.scheduler()
→ scheduler 决定:入队 / 立即执行 / 不执行
effect 本身不负责“什么时候执行”,scheduler 才负责。
三、Vue3 的核心抽象:Job(任务)
Vue3 源码里,scheduler.ts 不关心你是不是 effect。
它只认一个东西:
type SchedulerJob = (() => void) & {
id?: number
pre?: boolean
active?: boolean
allowRecurse?: boolean
}
也就是说:
任何“可以被调度执行的函数”,都叫 Job
比如:
instance.update是一个 Jobwatch的回调是一个 JobonUpdated是一个 Job
四、Scheduler 的三大队列
Vue3 并不是只有一个队列,而是三个:
| 队列 | 时机 | 典型任务 |
|---|---|---|
| pre queue | 组件更新前 | watch(flush: 'pre') |
| queue | 组件更新 | instance.update |
| post queue | 组件更新后 | onUpdated、nextTick、watch(post) |
执行顺序:
pre jobs
↓
组件渲染 / patch DOM
↓
post jobs
对应生命周期:
watch(pre)
↓
render
↓
onBeforeUpdate
↓
DOM 更新
↓
onUpdated
watch(post)
五、手写一个「迷你 Scheduler」
1. 队列结构
const queue = []
const pendingPreFlushCbs = []
const pendingPostFlushCbs = []
let isFlushing = false
let isFlushPending = false
const resolvedPromise = Promise.resolve()
let currentFlushPromise = null
2. queueJob:组件更新入队
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}
这里用 includes 去重,而不是 Set,是因为:
- Job 可能需要按
id排序 - 刷新过程中还可能动态插入新 Job
3. queueFlush:只注册一次微任务
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
关键点:
同一轮事件循环里,flushJobs 只会被安排一次
4. flushJobs:真正执行任务
function flushJobs() {
isFlushPending = false
isFlushing = true
// 1. pre
flushPreFlushCbs()
// 2. 主队列按 id 排序(父组件先更新)
queue.sort((a, b) => a.id - b.id)
for (let i = 0; i < queue.length; i++) {
queue[i]()
}
// 3. 清空主队列
queue.length = 0
// 4. post
flushPostFlushCbs()
isFlushing = false
currentFlushPromise = null
}
六、为什么 Job 要有 id?
看这段:
queue.sort((a, b) => a.id - b.id)
Vue 中:
- 每个组件实例有自增
uid - 父组件先创建 → uid 更小
- 渲染 effect 的 job.id = 组件 uid
所以排序后:
父组件 update
子组件 update
(√) 保证子组件拿到的是最新 props
(×) 避免「子组件先更新 → props 还是旧的 → 再更新一次」
七、effect 和 scheduler 的关系(重点)
我们之前是:
effect(() => {
render()
})
Vue 真实写法类似:
effect(componentRender, {
scheduler(job) {
queueJob(job)
}
})
也就是:
trigger
↓
effect.notify()
↓
scheduler()
↓
queueJob / 立即执行 / 不执行
effect 负责“收集依赖”
scheduler 负责“什么时候跑”
这也是为什么:
- 普通
effect:同步执行 - 组件 render effect:异步批量执行
watch:可以 pre / post / sync
八、nextTick 的真正原理
我们上篇写的是:
function nextTick(cb) {
Promise.resolve().then(cb)
}
这能跑,但不完全对。
Vue3 的 nextTick 是:
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p
}
为什么这样写?
因为:
state.name = 'Vue'
nextTick(() => {
// DOM 已更新
})
执行顺序是:
同步代码
↓
queueFlush → flushJobs(更新 DOM)
↓
nextTick 回调
nextTick 的本质不是“下一个微任务”,而是:
等当前这一轮 Scheduler flush 完成
九、用一句话总结 Scheduler
Vue3 的 Scheduler,就是把「响应式变化」和「DOM 更新」解耦的中间层。
没有 Scheduler:
set → render → set → render → set → render
有 Scheduler:
set set set
↓
flushJobs
↓
render 一次