Vue.js异步更新与nextTick机制深度解析(下篇)

调度队列这一段:queueWatcher 怎么靠一张 has 表去重、队列为什么按 watcher id 升序跑(父组件先于子组件、用户 watcher 先于渲染 watcher)、无限更新循环是怎么被数出来的,以及 Vue 3 的 pre / post 两段队列。

位置
第 09 篇 / 共 12 篇
预计
12 分钟

beforeUpdate 是父先子后,updated 反过来

先看一段能直接跑的,页面上准备一个 <div id="app"></div>

浏览器控制台 · 完整版 Vue 2
const Child = {
props: ['n'],
template: '<p>{{ n }}</p>',
beforeUpdate() { console.log('child beforeUpdate'); },
updated() { console.log('child updated'); }
};
const vm = new Vue({
el: '#app',
components: { Child },
template: '<div><child :n="n" /></div>',
data: { n: 0 },
beforeUpdate() { console.log('parent beforeUpdate'); },
updated() { console.log('parent updated'); }
});
vm.n = 1;

打印出来是这个顺序:

改一次数据,四个钩子的顺序
parent beforeUpdate
child beforeUpdate
child updated
parent updated

beforeUpdate 从父到子,updated 从子到父。这不是钩子系统的什么对称设计, 两个钩子是从调度器的两个不同位置发出来的:beforeUpdate 在队列的循环体里、每个 watcher 跑之前发,队列按父在前排好序;updated 在整个队列跑完之后统一补发,而补发那个函数 是倒着遍历的。一句 while (i--) 就是全部原因。

这篇拆的就是发出这四个钩子的那个文件,src/core/observer/scheduler.js。上篇停在 queueWatcher 门口 —— 渲染 watcher 排进队列,队列交给 nextTick。现在进门。

queueWatcher 靠一张 has 表挡住重复的 watcher

Vue 2.6.14 · queueWatcher
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}

has 是一张以 watcher id 为键的表,整个函数体包在 if (has[id] == null) 里面。 连着改三次同一个数据,setter 触发三次 dep.notify()queueWatcher 被调三次, 后两次撞在这个判断上直接返回 —— 上篇那个「三次赋值只渲染一次」到这里才算落实。 去重用的是对象查表而不是 queue.includes(watcher),队列长了也是一次哈希查找。

waiting 保证一轮只调一次 nextTick(flushSchedulerQueue)。注意它和上篇 next-tick.js 里那个 pending 是两个不同的布尔量,在两个不同的文件里,管着两件不同的事: waiting 问的是「这一轮的清队安排过了吗」,pending 问的是「这一批回调的 timerFunc 发出去了吗」。名字容易串,串了之后看代码会觉得有一个是多余的。

中间那条 else 分支管的是冲刷过程中新来的 watcher。队列跑到一半时,某个 watcher 的 run() 又改了数据,新的 watcher 得插进当前这条队列里,而且要插在正确的位置上: 从队尾往前找,找到第一个 id 不比它大的位置插进去。while (i > index && ...) 里的 index 是当前正在执行的下标,循环最多退到这里 —— 万一新 watcher 的 id 比已经跑过的那些还小, 它就落在 index + 1,也就是下一个被执行的位置。源码注释写得很干脆:已经跑过它的 id 了, 那就让它紧接着跑。

那条 !config.async 分支只在开发构建里存在。把 Vue.config.async 置成 falsequeueWatcher 当场调 flushSchedulerQueue(),整套异步机制被短路成同步的。 这是给测试用的开关 —— 单测里改完数据想立刻断言 DOM,不想每句都跟一个 await nextTick()。 生产构建里这段代码被整个摇掉,线上没有这个后门。

排序有三个理由,全写在注释里

队列在冲刷前排一次序,就一行:

Vue 2.6.14 · flushSchedulerQueue 里的排序
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child)
// 2. A component's user watchers are run before its render watcher (because
// user watchers are created before the render watcher)
// 3. If a component is destroyed during a parent component's watcher run,
// its watchers can be skipped.
queue.sort((a, b) => a.id - b.id)

三条理由都建立在同一件事上:watcher 的 id 就是它的创建顺序src/core/observer/watcher.js 的构造函数里只有一行 this.id = ++uid // uid for batching, 一个全局自增计数器,谁先被 new 出来谁的号小。

于是父组件的 id 一定比子组件小 —— 父组件先创建,patch 到子组件时才 new 子实例。 按 id 升序跑,就是从父到子跑。这个顺序是有意义的:父组件重渲染时可能把子组件整个换掉或者销毁掉, 先跑父的,子的那次更新就白做了。第三条说的正是这个 —— 子组件在父组件的 watcher 里被销毁了, 它的 watcher 可以被跳过。

同一个组件内部,用户 watcher 的 id 比渲染 watcher 小,因为 initState 里处理 watch 选项的时候实例还没挂载,渲染 watcher 要等到 $mount 才创建。所以 watch 的回调先跑、 渲染后跑 —— 你在 watch 里改的数据,能赶上同一轮的这次渲染,不必多等一个 tick。

循环不缓存长度,因为跑着跑着还会有新的进来

Vue 2.6.14 · flushSchedulerQueue 的主体
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
// 省略:上一节那行 queue.sort
// do not cache length because more watchers might be pushed
// as we run existing watchers
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
// 省略:收尾,下一节
}

flushing = true 一置上,前面 queueWatcher 那条插队分支就生效了。循环条件里每次重新读 queue.length,正是为了接住这些跑着跑着插进来的 watcher。

watcher.beforebeforeUpdate 的出口。渲染 watcher 在 mountComponentsrc/core/instance/lifecycle.js)里创建时带了这个选项:

Vue 2.6.14 · 渲染 watcher 的 before 选项
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)

那个 vm._isMounted 的判断解释了一件常被问到的事:首次渲染不会触发 beforeUpdate。 首次渲染时这个 watcher 是被 new 出来当场求值的,根本没走调度队列;就算走了, _isMounted 那时也还是 false

has[id] = null 放在 watcher.run() 之前,不是之后 —— 这一行是无限循环检测的基础。 既然标记已经清掉了,这个 watcher 在自己的 run() 里再次触发同一个依赖时, 就能重新进队;进队时 has[id] 被重新置成 true,回到循环里那个 if (has[id] != null) 就成立了。检测的判据不是「值有没有变」,而是「它跑完之后有没有立刻把自己又排进来」。

MAX_UPDATE_COUNT 是 100,判断写的是 circular[id] > MAX_UPDATE_COUNT, 严格大于,所以真正报警是在第 101 次。报出来的是那句 You may have an infinite update loop, 后面跟哪半句取决于 watcher.user:用户 watcher 会把表达式原样贴出来 (in watcher with expression "..."),渲染 watcher 则只能说 in a component render function.。 最容易撞上它的写法是在 updated 钩子里改自己依赖的数据,或者在 watch 的回调里改被 watch 的那个值本身。

这段检测和报警整个包在 process.env.NODE_ENV !== 'production' 里面。生产构建不数、不警告, 真写出死循环就是页面卡死,没有任何提示 —— 这条警告只在开发时救你。

updated 和 activated 排在整个队列跑完之后

循环结束之后的收尾,顺序本身就是信息:

Vue 2.6.14 · flushSchedulerQueue 的收尾
// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)

先复制,再重置,最后才发钩子。这个顺序是必须的:resetSchedulerState 会把 index = queue.length = activatedChildren.length = 0,两个数组当场被清空, 不先留副本就没得发了。而重置放在发钩子之前,是为了让钩子里触发的新更新能干干净净地开一轮新的 —— waitingflushing 这时已经回到 falsehas 也是一张空表。

callUpdatedHooks 就是开头那个顺序的来源:

Vue 2.6.14 · callUpdatedHooks
function callUpdatedHooks (queue) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'updated')
}
}
}

倒着走,所以 updated 是从子到父。vm._watcher === watcher 这个判断把用户 watcher 挡在了外面 —— 一个实例上可能有好几个 watcher,只有渲染 watcher 才等于 vm._watcherupdated 说的是「这个组件重渲染完了」,不是「这个组件上有 watcher 跑过」。

activatedChildren 收的是这一轮 patch 里被激活的 keep-alive 组件,queueActivatedComponent 上的注释说明了为什么要攒着:这个队列要等整棵树都 patch 完再处理。activated 排在 updated 前面发。

到这里一轮调度就完了。nextTickcallbacks 里,flushSchedulerQueue 这一项跑完, 排在它后面的用户回调才轮到 —— 上篇末尾那个 [A, flushSchedulerQueue, B] 的数组, 中间那一项展开来就是这一节和上一节的全部内容。

Vue 3 把一条队列拆成 pre 和 post 两段

Vue 3 的调度器在 packages/runtime-core/src/scheduler.ts(下面按 3.5.13 写)。 骨架还认得出来,但每一处都换了实现:

Vue 3.5.13 · queueJob / queueFlush / nextTick
export function queueJob(job: SchedulerJob): void {
if (!(job.flags! & SchedulerJobFlags.QUEUED)) {
const jobId = getId(job)
const lastJob = queue[queue.length - 1]
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)
}
job.flags! |= SchedulerJobFlags.QUEUED
queueFlush()
}
}
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
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
}

三处对照着看很清楚。

去重从查表变成了查一个二进制位。 Vue 2 用 has[id],Vue 3 用 job.flags 上的 QUEUED 位,一次按位与。3.3 那会儿这里还是 queue.includes(job),一次线性扫描; 3.5.0 那次重构(把 preactiveallowRecurse 这些散落的布尔字段合并成一个 SchedulerJobFlags 位域)顺带把它换掉了。

排序从「冲刷前排一次」变成了「插入时就排好」。 Vue 2 每轮 queue.sort(...); Vue 3.5 的 flushJobs 里已经没有 sort 了,queueJobfindInsertionIndex 做二分查找 把 job 插到正确位置。开头那个 jobId >= getId(lastJob) 是快路径:新 job 的 id 不小于队尾的, 直接 push,连二分都省了 —— 父到子的自然顺序下这条路命中率很高。

nextTick 挂的是当前这次冲刷的 Promise。 currentFlushPromiseresolvedPromise.then(flushJobs) 的返回值,也就是「冲刷完成」这个时刻。有冲刷在排队时, nextTick(fn) 接在它后面;没有的话退回一个已经 resolve 的 Promise。Vue 2 靠回调在数组里的 下标决定先后,Vue 3 靠 Promise 链 —— 拿到的结论是一样的:改完数据再 nextTick, 回调看得见新 DOM。

真正的新东西是 pre 和 post 两段。Vue 3 的一轮冲刷分成两截:主队列 queue 跑完, flushJobsfinally 里接着跑 flushPostFlushCbs(),清空 pendingPostFlushCbswatchwatchEffectflush 选项落在哪一截 —— 两者在 apiWatch.ts 里走的是同一个 doWatch,所以这张表对它们都成立:

flush 排到哪 跑的时机
'pre'(默认) queueJob,job 带 PRE 标记,id 取组件的 uid 组件更新之前
'post' queuePostRenderEffectqueuePostFlushCb 这一轮组件全更新完之后
'sync' 哪都不排 依赖一变就地执行

'pre' 的 job 和它所属组件的更新 job 共用同一个 id(都是 instance.uid), 靠 findInsertionIndex 里那个「id 相同时 PRE 排前面」的判定挤到组件更新的前一位。 这么设计有个直接的好处:父组件的更新如果把这个子组件卸载了,紧跟其后的 pre watcher 会 跟着被跳过,不会对着一个已经没了的组件跑回调。Vue 3.2.38 把原本独立的 pre 队列合并进主队列, 起因正是这个 bug。

'sync' 那一行值得单说,因为它是用「不写」表达的 —— apiWatch.ts 里只在 'post' 和 非 'sync' 两种情况下给 scheduler 赋值,'sync' 走完两个分支什么都没设上, 于是底层的 watch 依赖一变就同步执行,调度器全程没参与。

无限循环的检测两边都有,措辞换了。Vue 3 的 RECURSION_LIMIT 同样是 100, 报的是 Maximum recursive updates exceeded,后面跟一句解释:有一个响应式副作用在修改自己的依赖, 因此不断触发自己。3.5 还把这条从 warn() 改成了走 handleError, 于是它能被 app.config.errorHandler 接住,不再只是控制台里的一条日志。

什么时候该跳出这套调度

批量更新的代价,说到底是同步代码里读不到新 DOM。绝大多数时候这不是问题, await this.$nextTick() 一句就够。真需要退出调度的场合只有几个,而且每个都有明确的代价:

  • 同步 watcher(Vue 2 的 { sync: true }、Vue 3 的 flush: 'sync')。 依赖一变就地执行,不去重、不排序、不批量。改十次数据就跑十次回调, 在一个循环里改数组等于把回调跑了一整轮。只在「必须立刻拿到值、而且改动次数确定很少」时用。
  • 测试里关掉异步(Vue 2 开发构建的 Vue.config.async = false)。省掉每句断言前的 await,但它同时改掉了父子更新的时序,测出来的顺序不代表线上的顺序。
  • $forceUpdate()。它不绕过队列 —— 看一眼实现就知道,它调的是 vm._watcher.update(),照样落进 queueWatcher。想拿它来「立刻重渲染」是误会。

反过来,最常见的误用是根本不必要地跳出调度:在循环里每轮 await this.$nextTick()。 这等于亲手把批量拆成了 N 次单独的渲染 —— 队列的去重在每轮之间被 resetSchedulerState 清空了一次,一次都没能生效。把数据一次改完,最后只等一次,是这套机制本来就替你做好的事。

调度器决定了哪些组件在这一轮重渲染,至于重渲染时一个组件实例是怎么被造出来、 props 怎么灌进去、mounted 又是从哪发出来的,那是组件系统那一层的事。

这篇是 Vue.js 内部机制深度解析的第 9 篇。前一篇是 Vue.js 异步更新与 nextTick 机制深度解析(上篇),后一篇是 Vue.js 组件系统架构深度解析