vue3 源码学习(四)从异步更新到 Scheduler 调度器

0 阅读3分钟

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 异步更新的核心思想:

  1. 数据变化 ≠ 立即执行副作用
  2. 副作用先入队
  3. 当前同步代码跑完后,在微任务里统一执行
  4. 同一个 effect 只执行一次

但问题也随之而来


二、上篇实现的问题

我们的简易版有几个明显短板:

1. 没有「任务类型」

Vue 里不是所有任务都是「更新组件」:

  • 组件渲染更新(main job)
  • watch 回调(pre / post)
  • onBeforeUpdate / onUpdated
  • nextTick 回调
  • 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 是一个 Job
  • watch 的回调是一个 Job
  • onUpdated 是一个 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 一次