Vue3 源码阅读笔记(六)—— nextTick 与调度器

简述

尽管我们可以从逻辑上简单的将 Vue 的实现拆分为:响应式系统和渲染器。因为现代前端框架所关注的问题就是如何同步 stateUI

但在实际工程中,UI 的渲染并不是一次性的。根据数据的变化,它是会随之不断变化的。从理论上来说,我们可以对数据的变化应收尽收。也就是说,只要 state 发生了变化,我们就去更新 UI。这种罗永浩式不管不顾的做法尽管也不是不能用,但却会大大降低渲染更新的速度。所以我们就需要一种机制,来控制渲染。

任何逻辑只要足够通用、足够复杂,就有价值抽象出来单独研究。

另外的,调度器与调度策略是一个很复杂的问题。在操作系统设计中,你可能看到过很多精妙的设计与研究。这里我们并不打算过于深入这个主题,而是恰到好处的阐述,来完成我们的工程目标。

Event Loop

关于这个主题,在浏览器与 Event Loop 这篇文章中已经详细阐述过了,我们这里稍微阐述一下如何借由这个概念实现调度器的核心。

关于浏览器的设计,你可以参考这幅图:

JavaScript 运行时

简单来说,JavaScript 是一种单线程语言。浏览器为了实现异步任务,设计了 Event Loop 这个机制。在执行主执行栈的任务时,异步任务会被放入 Task Queue 中。当然,异步任务根据具体情况,会分别加入 Macro Task QueueMicro Task Queue 中。待主执行栈的任务清空后,就会依次执行 Task Queue 中的任务。

由此,调度器的实现就呼之欲出了。
我们只要实现一种机制,将需要异步执行的任务塞入 Task Queue 中就可以了。简单来说,就是借助 PromisesetTimeout 等。尽管他们二者一个属于 Micro Task,一个属于 Macro Task。但在这个主题下,他们从逻辑上讲是一致的。

nextTick

在这里我们引述一下官方文档

Defer the callback to be executed after the next DOM update cycle. Use it immediately after you’ve changed some data to wait for the DOM update.

借由前面的分析,你应该很容易想到 nextTick 函数的实现方式。源码如下:

1
2
3
4
5
6
7
export function nextTick(
this: ComponentPublicInstance | void,
fn?: () => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}

这其中涉及到了一些 Vue 调度器本身的设计,我们将在稍候分析。为了让你更容易理解,我们可以把代码改成这样:

1
2
3
4
const p = Promise.resolve()
export function nextTick(fn?: () => void): Promise<void> {
return fn ? p.then(fn): p
}

说白了,就是借助 Promise.resolve().then()() => void 这个任务推入 Task Queue 来执行。
UI 的渲染将会在主执行栈执行,待渲染完毕,就会运行 Task Queue 中的任务,我们也就可以拿到更新后的 DOM 了。

调度器的设计

上一部分的 nextTickVue 暴露出来的 api,所以重点说了一下。不过光知道这个还不足以理解 Vue 中渲染调度器的整体设计。下面我们就结合源码讨论一下这部分的设计。

数据结构

搞清楚系统操作的数据结构是理解系统的第一步,所以我们先来讨论一下调度器操作的数据结构。

首先是被调度的任务。Vue 将任务做了简单的封装:

1
2
3
4
5
export interface SchedulerJob {
(): void
id?: number
allowRecurse?: boolean
}

(): voidTypeScript 的一个简写语法,指代的是一个函数类型。id 的作用是使得任务保持唯一性。allowRecurse 作用是指示任务是否可以递归的调用自身。

接下来是需要调度的任务队列,是一个数组:

1
const queue: SchedulerJob[] = []

另外的,对于异步任务的回调函数,调度器也做了处理。首先看一下回调函数的数据结构:

1
2
export type SchedulerCb = Function & { id?: number }
export type SchedulerCbs = SchedulerCb | SchedulerCb[]

接着是两类四种回调函数的数据结构:

1
2
3
4
5
6
7
8
9
// 异步任务队列中任务执行前的回调函数队列
const pendingPreFlushCbs: SchedulerCb[] = []
let activePreFlushCbs: SchedulerCb[] | null = null
let preFlushIndex = 0

// 异步任务队列中任务执行完成后的回调函数队列
const pendingPostFlushCbs: SchedulerCb[] = []
let activePostFlushCbs: SchedulerCb[] | null = null
let postFlushIndex = 0

综上所述,整个调度器的核心就是 queue 这种数据结构。我们通过维护一个队列,从而实现对渲染的控制。同时在入队出队的时候,我们都可以精确的控制代码的行为,从而实现一些逻辑。比如任务的去重。
例如,考虑如下的例子:

1
2
3
4
5
6
7
8
9
10
import { reactive, watch } from 'vue' 

const state = reactive({ count: 0 })
watch(() => state.count, (count, prevCount) => {
console.log(count)
})

state.count++
state.count++
state.count++

如果你自己折腾过 Vuewatch,那么你会知道,这段代码仅仅会输出一次内容,也就是最终计算的结果:3。

任务入队

queueJob 入队异步任务

整个系统的核心就是这个函数,它会将异步任务添加进队列中。下面看一下简化后的源码:

1
2
3
4
5
6
export function queueJob(job: SchedulerJob) {
if (!queue.includes(job)) {
queue.push(job)
queueFlush()
}
}

在确保无重复后,将任务推入队列。

当然了,上面简化的代码只能说明本质。如果想要了解调度器的设计,还是需要看完整源码,提供了一些额外的信息。

1
2
3
4
5
6
7
if ((!queue.length || !queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex))
&& job !== currentPreFlushParentJob) {
queue.push(job)
queueFlush()
}

简单来说,在默认情况下,搜索的起始位置为当前任务。也就是说,不允许递归调用。
job.allowRecurse 的值为 true 时,将搜索起始位置加一,无法搜索到自身,也就是允许递归调用了。

queuePreFlushCb / queuePostFlushCb 处理回调

那么,回调任务怎么处理呢?这里我们有两组函数来分别处理异步任务调用前和回调和异步任务调用完成后的回调。

1
2
3
4
5
6
7
export function queuePreFlushCb(cb: SchedulerCb) {
queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

export function queuePostFlushCb(cb: SchedulerCbs) {
queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}

可以看到,这两个函数实际上是对 queueCb 的封装。它们之间的区别仅有传递进去的参数的不同。下面我们来看一下 queueCb 这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function queueCb(
cb: SchedulerCbs,
activeQueue: SchedulerCb[] | null,
pendingQueue: SchedulerCb[],
index: number
) {
if (!isArray(cb)) {
if (
!activeQueue ||
!activeQueue.includes(
cb,
(cb as SchedulerJob).allowRecurse ? index + 1 : index
)
) {
pendingQueue.push(cb)
}
} else {
pendingQueue.push(...cb)
}

queueFlush()
}

入队的逻辑和异步任务的处理基本上是一致的。一方面做了去重,另一方面依照配置处理了递归的逻辑。
另外的,如果回调是一个数组,它会是组件的生命周期钩子函数。这组函数仅可被异步任务调用,且已经完成去重了。所以这里直接将数组拉平为一维,推入 pendingQueue 中。这部分是 Vue 自身的设计。
你也可以参考一下源码中的原有注释:

if cb is an array, it is a component lifecycle hook which can only be triggered by a job, which is already deduped in the main queue, so we can skip deplicate check here to improve pref

任务处理

queueFlush 推入微任务队列

入队完成后,我们纠结着需要开始处理异步任务了。我们先来看两个全局变量,它们控制着刷新逻辑:

1
2
let isFlushing = false
let isFlushPending = false

在这里,如果没有正在等待或正在执行的任务,我们就会将 flushJobs 塞入引擎的微任务队列:

1
2
3
4
5
6
7
8
9
const resolvedPromise: Promise<any> = Promise.resolve()
let currentFlushPromise: Promise<void> | null = null

function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}

通过这样的设计,确保了你可以在一个 tick 内可以多次添加任务。同时引擎在执行完主调用栈的函数后,一定会调用一次微任务队列中的 flushJobs

flushJobs 处理异步任务

当引擎处理完主函数栈的函数时,就会去处理 Task Queue 中的内容。我们之前通过这一句代码,将 flushJobs 推入了 Task Queue

1
2
3
4
5
const resolvedPromise: Promise<any> = Promise.resolve()
function queueFlush() {
// ...
resolvedPromise.then(flushJobs)
}

这样,在适当的时机,就会调用 flushJobs

因为这个函数内容比较多,所以我们采用删减源码的方式,一步步的分析其实现逻辑。
首先看一下回调的处理时机:

1
2
3
4
5
6
7
8
9
10
type CountMap = Map<SchedulerJob | SchedulerCb, number>
function flushJobs(seen?: CountMap) {
// ...

flushPreFlushCbs(seen)

// 处理异步任务

flushPostFlushCbs(seen)
}

事实上就是通过这两个函数,分别执行回调函数队列的。不过这部分逻辑我们留到下一个章节再说。

另外的,在实际处理异步任务队列前,我们还需要对任务队列做一次排序。看一下源码:

1
2
3
4
5
const queue: SchedulerJob[] = []
const getId = (job: SchedulerJob | SchedulerCb) =>
job.id == null ? Infinity : job.id

queue.sort((a, b) => getId(a) - getId(b))

这里会将 queue 中的内容按照其 id 值升序排列。这么做的原因,源码中的注释已经说得很清楚了:

Sort queue before flush.
This ensures that:

  1. Components are updated from parent to child. (because parent is always created before the child so its render effect will have smaller priority number)
  2. If a component is unmounted during a parent component’s update, its update can be skipped.

总结一下,主要是确保了两点:

  • 由父组件至子组件的更新
  • 如果在父组件更新的时候子组件被卸载了,那么就可以跳过子组件的更新了

说完了细节部分,接下来我们来看一下完整版的代码。其中一些类型在前面的代码中已经写过了,此处不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
if (__DEV__) {
seen = seen || new Map()
}

flushPreFlushCbs(seen)
queue.sort((a, b) => getId(a) - getId(b))

try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
if (__DEV__) {
checkRecursiveUpdates(seen!, job)
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0

flushPostFlushCbs(seen)

isFlushing = false
currentFlushPromise = null

if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}

代码看起来比较长,但逻辑其实很简单,浓缩一下:

1
2
3
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}

遍历队列,并执行这些任务。

另外的,你应该会注意到这句代码:

1
2
3
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}

有些异步任务在执行的时候也会添加新的异步任务进去,那么我们就将它们也执行完。

处理回调任务

关于回调任务,根据上面的解释,存在两种处理逻辑。分别是用于处理异步任务前时的回调,和异步任务处理完成后的回调。下面我们分别来讨论一下。

flushPreFlushCbs

概括的来说,处理回调队列的思路和处理异步任务队列的思路是一致的。遍历队列,依次执行函数。同时递归的处理回调本身的递归。
因为代码虽然长,但逻辑不复杂,所以直接来看一下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const pendingPreFlushCbs: SchedulerCb[] = []
let activePreFlushCbs: SchedulerCb[] | null = null
let currentPreFlushParentJob: SchedulerJob | null = null

export function flushPreFlushCbs(
seen?: CountMap,
parentJob: SchedulerJob | null = null
) {
if (pendingPreFlushCbs.length) {
currentPreFlushParentJob = parentJob
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0

for (
preFlushIndex = 0;
preFlushIndex < activePreFlushCbs.length;
preFlushIndex++
) {
activePreFlushCbs[preFlushIndex]()
}
activePreFlushCbs = null
preFlushIndex = 0
currentPreFlushParentJob = null

flushPreFlushCbs(seen, parentJob)
}
}

逻辑很清楚,就是遍历 activePreFlushCbs 队列,依次执行函数。
注意最后递归调用了 flushPreFlushCbs 函数,用来处理递归。在递归的过程中,可能会改变队列,所以我们在正式处理前,拷贝了一份队列的副本:

1
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]

flushPostFlushCbs

其实整体上的处理逻辑和前者是一致的。其中修复了 #1947 这个 issue 的问题。如果你感兴趣,可以去读一下原 issue。这里为了展示核心逻辑与前者比较,就省略了这部分逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function flushPostFlushCbs(seen?: CountMap) {
if (pendingPostFlushCbs.length) {
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0

activePostFlushCbs = deduped

activePostFlushCBs.sort((a, b) => getId(a) - getId(b))

for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
activePostFlushCbs[postFlushIndex]()
}
activePostFlushCbs = null
postFlushIndex = 0
}
}

同样的,这里在处理前也拷贝了队列的副本。就是为了处理新添加的回调。

另外的,在 flushJob 函数调用 flushPostFlushCbs 函数后,还将 isFlushing 重置为了 false。这是为了处理新添加的异步任务。如果有的话,flushJob 会继续递归,直到处理完所有的异步任务。

1
2
flushPostFlushCbs(seen)
isFlushing = false

总结

至此,就是 Vue3 中调度器的所有内容了。总的来说,整体思想涉及了两个部分:

  • 借助引擎的 Micro Task Queue 处理调度器存储的任务
  • 处理异步任务与回调,对于新添加的异步任务也递归的处理完成。这与引擎处理 Task Queue 的逻辑是一致的
Author: ShroXd
Link: http://www.bebopser.com/2021/01/22/vue3source6/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.