上文我们分析了 effect 的实现,effect 创建的更新函数或副作用函数通过调度系统来调度执行,本文我们就来分析调度系统的具体实现。
一、示例引入
让我们从一个简单的组件更新示例开始:
vue
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script setup>
import { ref } from "vue";
const count = ref(0);
const increment = () => {
count.value++;
count.value++;
count.value++;
};
</script>
<style lang="scss" scoped>
</style>
在这个例子中,虽然我们在 increment 函数中连续修改了三次 count 的值,但组件只会更新一次。这就是 Vue 调度系统的功劳。
二、调度系统的核心设计
在深入具体实现之前,我们先来了解 Vue 调度系统的整体设计。本质上,它是一个精心设计的任务队列管理器。 这个设计的关键点:
-
任务分类:
- 异步任务:可以延迟到下一个微任务执行的更新
- 后置任务:需要在所有更新完成后执行的任务
-
去重机制:
- 相同的任务只会被加入队列一次
- 通过任务 ID 判断是否重复
-
执行时机:
- 利用微任务实现异步更新
- 确保在一个事件循环内的所有状态更新都被收集
2.1 核心数据结构
为了实现上述设计,Vue 定义了一系列核心数据结构:
js
// 任务标志位枚举
export enum SchedulerJobFlags {
QUEUED = 1 << 0, // 任务已入队
PRE = 1 << 1, // 前置任务
ALLOW_RECURSE = 1 << 2, // 允许递归执行
DISPOSED = 1 << 3, // 任务已废弃
}
// 调度器任务类型
export interface SchedulerJob extends Function {
id?: number; // 任务ID
flags?: SchedulerJobFlags; // 任务标志位
i?: ComponentInternalInstance; // 关联的组件实例
}
// 调度器任务或任务数组
export type SchedulerJobs = SchedulerJob | SchedulerJob[];
// 核心数据结构
const queue: SchedulerJob[] = []; // 主任务队列
const pendingPostFlushCbs: SchedulerJob[] = []; // 待处理的后置任务队列
let activePostFlushCbs: SchedulerJob[] | null = null; // 当前活跃的后置任务队列
let flushIndex = -1; // 当前处理的任务索引
let postFlushIndex = 0; // 后置任务处理索引
// 核心函数类型
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>>;
export function queueJob(job: SchedulerJob): void;
export function queuePostFlushCb(cb: SchedulerJobs): void;
export function flushPreFlushCbs(
instance?: ComponentInternalInstance,
seen?: CountMap,
i?: number
): void;
export function flushPostFlushCbs(seen?: CountMap): void;
type CountMap = Map<SchedulerJob, number>;
这些是调度器中最核心的类型定义,主要用于:
- 任务队列管理(queue 和 pendingPostFlushCbs)
- 任务状态控制(SchedulerJobFlags)
- 任务调度执行(各种 flush 函数)
- 异步调度能力(nextTick)
三、核心实现分析
有了对整体设计的理解,我们现在可以按照调用链来分析具体实现。Vue 调度系统的执行流程是一个完整的链条:
- 首先,通过
queueJob
函数将任务添加到队列中 - 然后,
queueJob
调用queueFlush
函数来触发队列的刷新 queueFlush
通过 Promise 将flushJobs
放入微任务队列flushJobs
执行队列中的任务,并在最后调用flushPostFlushCbs
- 最后,
flushPostFlushCbs
处理所有的后置任务
让我们详细分析每个环节:
1. 调度器的入口
当响应式系统触发更新时,更新函数会被包装成任务提交给调度系统。这个过程从 queueJob 函数开始:
js
export function queueJob(job: SchedulerJob): void {
// 检查任务是否已经在队列中
// 通过位运算检查任务的 QUEUED 标志位
if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
// 获取任务的唯一标识ID
const jobId = getId(job);
// 获取队列中的最后一个任务
const lastJob = queue[queue.length - 1];
if (
!lastJob ||
// 快速路径:当任务ID大于队尾任务ID时
// 并且当前任务不是PRE(前置)任务时
(!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob))
) {
// 直接将任务推入队列尾部
queue.push(job);
} else {
// 需要根据jobId找到合适的插入位置
// 保证任务按照id升序排列
queue.splice(findInsertionIndex(jobId), 0, job);
}
// 标记任务已入队
job.flags! |= SchedulerJobFlags.QUEUED;
// 触发队列刷新
queueFlush();
}
}
queueJob 函数的设计非常精巧,它主要完成以下几个关键功能:
- 任务去重
- 任务排序
- 批量处理
- 嵌套更新控制
getId 函数实现
在 queueJob 中,通过 getId 函数获取任务的优先级标识:
js
const getId = (job: SchedulerJob): number =>
job.id == null
? job.flags! & SchedulerJobFlags.PRE
? -1
: Infinity
: job.id;
这个设计很精妙:
- 前置任务(PRE)返回 -1,确保最先执行
- 没有 ID 的普通任务返回 Infinity,确保最后执行
- 有 ID 的任务按 ID 排序
findInsertionIndex 函数实现
当需要插入新任务时,使用二分查找来确定任务在队列中的插入位置:
js
function findInsertionIndex(id: number) {
let start = flushIndex + 1;
let end = queue.length;
while (start < end) {
const middle = (start + end) >>> 1;
const middleJob = queue[middle];
const middleJobId = getId(middleJob);
if (
middleJobId < id ||
(middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE)
) {
start = middle + 1;
} else {
end = middle;
}
}
return start;
}
这个实现确保了:
- 组件更新按照父到子的顺序执行
- 可以跳过已卸载组件的更新
- 前置任务(如 watcher)在组件更新之前执行
2. 队列刷新的触发
在 queueJob 的最后,会调用 queueFlush 函数来触发队列的刷新。这个函数虽然简短,但是承担着将同步任务转换为异步任务的重要职责:
js
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs);
}
}
这个简短但关键的函数实现了以下几个重要功能:
- 将同步任务转换为异步任务
- 确保在一个事件循环内的所有状态更新都被收集
3. 任务队列的执行
当微任务队列开始执行时,之前由 queueFlush 设置的 Promise 回调会调用 flushJobs 函数。这个函数负责实际执行队列中的所有任务:
js
function flushJobs(seen?: CountMap) {
try {
// 遍历任务队列
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
// 检查任务是否有效且未被废弃
if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) {
// 如果任务允许递归执行,先清除 QUEUED 标记
if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
job.flags! &= ~SchedulerJobFlags.QUEUED;
}
// 使用错误处理包装器执行任务
// 根据任务类型设置不同的错误代码
callWithErrorHandling(
job,
job.i,
job.i ? ErrorCodes.COMPONENT_UPDATE : ErrorCodes.SCHEDULER
);
// 对于不允许递归的任务,执行后清除 QUEUED 标记
if (!(job.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
job.flags! &= ~SchedulerJobFlags.QUEUED;
}
}
}
} finally {
// 重置任务队列状态
flushIndex = -1;
queue.length = 0;
// 执行后置任务队列
flushPostFlushCbs(seen);
// 清除当前执行Promise
currentFlushPromise = null;
// 如果在执行过程中有新的任务入队,递归执行
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen);
}
}
}
这个函数实现了以下核心功能:
- 遍历任务队列
- 检查任务是否有效且未被废弃
- 执行任务
- 处理错误
- 重置任务队列状态
- 执行后置任务队列
- 清除当前执行 Promise
- 递归执行
checkRecursiveUpdates 函数实现
在 flushJobs 中,使用 checkRecursiveUpdates 函数防止无限递归更新:
js
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) {
const count = seen.get(fn) || 0;
if (count > RECURSION_LIMIT) {
const instance = fn.i;
const componentName = instance && getComponentName(instance.type);
handleError(
`Maximum recursive updates exceeded${
componentName ? ` in component <${componentName}>` : ``
}. ` +
`This means you have a reactive effect that is mutating its own ` +
`dependencies and thus recursively triggering itself.`,
null,
ErrorCodes.APP_ERROR_HANDLER
);
return true;
}
seen.set(fn, count + 1);
return false;
}
这个函数的作用是:
- 跟踪每个任务的执行次数
- 当超过递归限制时抛出错误
- 提供清晰的错误信息帮助开发者定位问题
4. 后置任务队列处理
在 flushJobs 执行完主队列的任务后,会调用 flushPostFlushCbs 来处理后置任务队列。这些任务通常是需要在主任务队列执行完成后才执行的回调:
js
export function flushPostFlushCbs(seen?: CountMap): void {
// 如果有待处理的后置任务
if (pendingPostFlushCbs.length) {
// 对任务进行去重和排序
const deduped = [...new Set(pendingPostFlushCbs)].sort(
(a, b) => getId(a) - getId(b)
);
pendingPostFlushCbs.length = 0;
// 如果已经有活跃的后置任务队列
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped);
return;
}
activePostFlushCbs = deduped;
// 遍历执行后置任务
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
const cb = activePostFlushCbs[postFlushIndex];
// 执行任务前清除 QUEUED 标记
if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
cb.flags! &= ~SchedulerJobFlags.QUEUED;
}
// 执行未被废弃的任务
if (!(cb.flags! & SchedulerJobFlags.DISPOSED)) cb();
// 执行后清除 QUEUED 标记
cb.flags! &= ~SchedulerJobFlags.QUEUED;
}
}
}
这个函数实现了以下核心功能:
- 遍历后置任务队列
- 去重
- 排序
- 执行任务
- 清除 QUEUED 标记
5. nextTick 实现
最后,Vue 提供了 nextTick 这个重要的公共 API,用于在下一个微任务中执行回调:
js
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(this ? fn.bind(this) : fn) : p;
}
这个函数的设计很巧妙:
- 优先使用当前的 flush Promise
- 如果没有,则使用一个已 resolved 的 Promise
- 支持传入回调函数,并保持 this 上下文
6. 调度优化策略
- 任务去重:
js
if (!queue.length || !queue.includes(job)) {
queue.push(job);
}
- 任务排序:
js
queue.sort((a, b) => getId(a) - getId(b));
- 父组件总是在子组件之前更新
- 确保更新的顺序性和可预测性
- 批量处理:
js
startBatch();
try {
// 批量操作
} finally {
endBatch();
}
- 嵌套更新控制:
js
if (
this.flags & EffectFlags.RUNNING &&
!(this.flags & EffectFlags.ALLOW_RECURSE)
) {
return;
}
四、实际应用示例
让我们看看调度系统如何处理不同场景:
1. 连续的状态更新
js
const count = ref(0);
function update() {
count.value++; // 触发更新 1
count.value++; // 触发更新 2
count.value++; // 触发更新 3
}
实际执行过程:
- 三次修改都触发 trigger
- trigger 创建更新任务
- 任务被加入队列并去重
- 在下一个微任务中只执行一次更新
2. 父子组件的更新
js
<!-- Parent.vue -->
<template>
<Child :data="data" />
</template>
<!-- Child.vue -->
<template>
<div>{{ data }}</div>
</template>
更新流程:
- 父组件数据变化触发更新
- 子组件因 props 变化触发更新
- 调度系统对任务排序
- 确保父组件先于子组件更新
五、总结
通过对 Vue 调度系统的分析,我们看到了它如何通过巧妙的设计实现高效的更新处理:
-
性能优化:
- 批量处理更新
- 去除重复更新
- 优化更新顺序
-
可预测性:
- 确保更新顺序
- 维护父子组件关系
- 处理计算属性依赖
-
灵活性:
- 支持同步/异步任务
- 支持优先级控制
- 提供扩展机制
这个设计让 Vue 能够在保持响应式系统简洁的同时,实现高效的更新处理。而调度系统的实现也为 Vue 的其他高级特性提供了基础。在接下来的章节中,我们将分析 watch 和 computed 这两个重要特性是如何基于这个调度系统构建的。