调度器 | Vue 的调度器是如何运行的?

124 阅读5分钟

快速浏览

Vue 的调度器(Scheduler)主要负责管理和执行异步任务队列。

  1. 核心数据结构:
const queue: SchedulerJob[] = [] // 主任务队列
const pendingPostFlushCbs: SchedulerJob[] = [] // 后置任务队列
export enum SchedulerJobFlags {
  QUEUED = 1 << 0,    // 值为 1,表示任务已经被加入队列
  PRE = 1 << 1,       // 值为 2,表示这是一个前置任务
  ALLOW_RECURSE = 1 << 2,  // 值为 4,允许任务递归触发自身
  DISPOSED = 1 << 3,   // 值为 8,表示任务已被销毁
}
  1. 主要方法:
  • nextTick:
export function nextTick(fn) {
    // 返回一个 Promise,用于在下一个微任务执行回调
    const p = currentFlushPromise || resolvedPromise
    return fn ? p.then(fn) : p
}
  • queueJob:
export function queueJob(job: SchedulerJob) {
    // 将任务添加到主队列中
    // 使用二分查找确保任务按 id 排序
    // 设置任务的 QUEUED 标志
    // 触发 queueFlush
  • queuePostFlushCb:
export function queuePostFlushCb(cb: SchedulerJobs): void {
    // 用于处理需要在 DOM 更新后执行的回调函数
    if (!isArray(cb)) {
      // 单个回调的处理
      if (activePostFlushCbs && cb.id === -1) {
        // 如果当前正在执行后置刷新回调,且传入的回调 id 为 -1
        // 则将其插入到当前正在执行的位置之后
        activePostFlushCbs.splice(postFlushIndex + 1, 0, cb)
      } else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) {
        // 如果回调未被队列化,则添加到待处理队列
        pendingPostFlushCbs.push(cb)
        cb.flags! |= SchedulerJobFlags.QUEUED
      }
    } else {
      // 如果是数组(组件生命周期钩子),直接添加到队列
      pendingPostFlushCbs.push(...cb)
    }
}
  • flushPreFlushCbs:
export function flushPreFlushCbs(
  instance?: ComponentInternalInstance,  // 组件实例
  seen?: CountMap,                      // 用于开发环境下检测递归更新
  i: number = flushIndex + 1            // 开始处理的索引位置,跳过当前的任务
): void {
    // 主要用于执行预刷新(pre-flush)回调函数,这是 Vue 调度系统的一个重要部分
    // 注意,在 3.5 之前的版本,预刷新回调函数只有 watch api 能触发。
    if (__DEV__) {
        seen = seen || new Map()
    }
    // 遍历队列中的回调函数
    for (; i < queue.length; i++) {
        const cb = queue[i]
        // 检查是否是预刷新回调(PRE flag)
        if (cb && cb.flags! & SchedulerJobFlags.PRE) {
            // 如果指定了组件实例,则跳过不属于该实例的回调
            if (instance && cb.id !== instance.uid) {
                continue
            }

            // 从队列中移除当前回调
            queue.splice(i, 1)
            i--

            // 执行回调前的标志位处理
            if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) {
                cb.flags! &= ~SchedulerJobFlags.QUEUED
            }

            // 执行回调
            cb()

            // 执行后的标志位处理
            if (!(cb.flags! & SchedulerJobFlags.ALLOW_RECURSE)) {
                cb.flags! &= ~SchedulerJobFlags.QUEUED
            }
        }
    }
}

  • queueJobFlushCb:
export function queuePostFlushCb(cb: SchedulerJob) {
    // 添加后置回调任务
    // 支持单个任务或任务数组
}
  • flushJobs:
function flushJobs(seen?: CountMap) {
    // 核心任务执行函数
    // 1. 执行主队列中的任务
    // 2. 执行后置任务
    // 3. 处理递归更新警察
    // 4. 错误处理
}
  1. 重要特性:
  • 任务去重:通过 flags 标记避免重复添加任务
  • 任务排序:使用二分查找确保父组件在子组件之前更新【当父组件更新事件导致子组件被卸载,那么子组件的更新将被忽略】
  • 递归限制:通过 RECURSION_LIMIT 限制递归更新次数
  • 错误处理:使用 callWithErrorHandling 包装任务执行
  1. 调度器工作流程:

    1. 组件更新时通过 queueJob 添加任务
    2. 任务进入队列并触发 queueFlush
    3. 在下一个微任务队列中执行 flushJobs
    4. 按顺序执行队列中的任务
    5. 执行后置任务
    6. 处理新产生的任务直到队列清空

工作机制

  1. 核心数据结构:
const queue: SchedulerJob[] = [] // 主任务队列
const pendingPostFlushCbs: SchedulerJob[] = [] // 后置任务队列
  1. 主要工作流程:
  • 任务入队
// 通过 queueJob 将任务加入主队列
export function queueJob(job: SchedulerJob) {
    // 确保任务不重复入队
    if(!(job.flags! & SchedulerJobFlags.QUEUED) {
    if (
      !lastJob ||
      // fast path when the job id is larger than the tail
      (!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob))
    ) {
        queue.push(job)
    } else {
        // 使用二分查找确定插入位置,保证父组件更新在子组件之前
        queue.splice(findInsertionIndex(jobId), 0, job)
    }
        // 触发刷新
        queueFlush()
    }
}
  • 刷新机制
function queueFlush() {
    // 使用 Promise 确保异步执行
    if (!currentFlushPromise) {
        currentFlushPromise = resolvedPromise.then(flushJobs)
    }
}
  1. 执行顺序:
    1. 预刷新回调(Pre Flush):
      • 通过 flushPreFlushCbs 执行
      • 主要用于组件更新前的准备工作
    2. 主队列任务:
      • 通过 flushJobs 执行
      • 按照组件层级顺序处理更新
    3. 后置刷新回调(Post Flush):
      • 通过 flushPostFlushCbs 执行
      • 用于组件更新后的清理工作
  2. 重要特性:
    • 递归控制:
    const RECURSION_LIMIT = 100
    // 通过 checkRecusiveUpdates 防止无限递归
    
    • 任务去重:
    // 后置回调去重
    const deduped = [...new Set(pendingPostFlushCbs)]
    
    • 异步调度:
    export function next(fn?) {
        // 返回 Promise,确保任务在下一个微任务队列执行
        return fn ? p.then(fn) : p
    }
    
  3. 核心价值:
    • 性能优化:合并多次更新,避免重复渲染
    • 执行顺序:确保父组件先于子组件更新
    • 异步处理:避免阻塞主线程
    • 安全性:防止递归溢出,处理错误边界

watch 与调度器的关系

  1. 任务调度:

    • watch 创建的副作用会被包装成一个 job
    • 当数据变化时,这个 job 会被添加到调度器的队列中
    • 调度器负责统一管理和执行这些任务
  2. 常规调度流程:

// 1. 触发数据变化
// 2. 调度器将 job 加入队列
queueJob(job)

// 3. 在下一个微任务中执行
nextTick(() => {
    flushJobs()
}
  1. Pre-Queue 的使用场景:

3.1 组件更新前的 watch:

// 通过设置 flush: 'pre' 创建前置监听器
watch(source, callback, {
    flush: 'pre' // 在组件更新前执行
}

3.2 特点

  • 在组件更新前执行
  • 可以在 DOM 更新前访问旧的 DOM 状态
  • 适合需要在组件更新前进行数据准备的场景

3.3 实现机制:

// 通过设置 SchedulerJobFlags.PRE 标识
if (flush == 'pre') {
 job.flags! |= SchedulerJobFlags.PRE
}
  1. Post-Queue 的使用场景

4.1 组件更新后的 watch:

// 通过设置 flush: 'post' 创建后置监听器
watch(source, callback, {
    flush: 'post' // 在组件更新后执行
}

4.2 特点:

  • 在组件更新完成后执行
  • 可以访问更新后的 DOM
  • 适合需要基于更新后的 DOM 进行操作的场景

4.3 实现机制:

// 使用 queuePostFlushCb 添加到后置队列
if (flush === 'post') {
  queuePostFlushCb(job)
}
  1. 调度顺序:

完整的调度顺序如下:

pre-queue 任务
组件更新
normal-queue 任务
post-queue 任务

6. 具体实现实例

// 前置监听器
watch(
  () => state.value,
  () => {
    // 在组件更新前执行
    console.log('pre watcher triggered')
  },
  { flush: 'pre' }
)

// 后置监听器
watch(
  () => state.value,
  () => {
    // 在组件更新后执行
    console.log('post watcher triggered')
  },
  { flush: 'post' }
)

// 同步监听器(默认)
watch(
  () => state.value,
  () => {
    // 同步执行,但会被放入微任务队列
    console.log('sync watcher triggered')
  }
)
  1. 调度器中的处理
// 处理前置队列
export function flushPreFlushCbs() {
  for (let i = 0; i < queue.length; i++) {
    const cb = queue[i]
    if (cb && cb.flags! & SchedulerJobFlags.PRE) {
      queue.splice(i, 1)
      i--
      cb()
    }
  }
}

// 处理后置队列
export function flushPostFlushCbs() {
  if (pendingPostFlushCbs.length) {
    const deduped = [...new Set(pendingPostFlushCbs)]
    pendingPostFlushCbs.length = 0
    
    for (let i = 0; i < deduped.length; i++) {
      deduped[i]()
    }
  }
}

TODO 响应式 API 与调度器的关系...