Vue.js异步更新与nextTick机制深度解析(下篇)
调度队列这一段:queueWatcher 怎么靠一张 has 表去重、队列为什么按 watcher id 升序跑(父组件先于子组件、用户 watcher 先于渲染 watcher)、无限更新循环是怎么被数出来的,以及 Vue 3 的 pre / post 两段队列。
beforeUpdate 是父先子后,updated 反过来
先看一段能直接跑的,页面上准备一个 <div id="app"></div>:
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 beforeUpdatechild beforeUpdatechild updatedparent updatedbeforeUpdate 从父到子,updated 从子到父。这不是钩子系统的什么对称设计,
两个钩子是从调度器的两个不同位置发出来的:beforeUpdate 在队列的循环体里、每个 watcher
跑之前发,队列按父在前排好序;updated 在整个队列跑完之后统一补发,而补发那个函数
是倒着遍历的。一句 while (i--) 就是全部原因。
这篇拆的就是发出这四个钩子的那个文件,src/core/observer/scheduler.js。上篇停在
queueWatcher 门口 —— 渲染 watcher 排进队列,队列交给 nextTick。现在进门。
queueWatcher 靠一张 has 表挡住重复的 watcher
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 置成 false,
queueWatcher 当场调 flushSchedulerQueue(),整套异步机制被短路成同步的。
这是给测试用的开关 —— 单测里改完数据想立刻断言 DOM,不想每句都跟一个 await nextTick()。
生产构建里这段代码被整个摇掉,线上没有这个后门。
排序有三个理由,全写在注释里
队列在冲刷前排一次序,就一行:
// 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。
循环不缓存长度,因为跑着跑着还会有新的进来
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.before 是 beforeUpdate 的出口。渲染 watcher 在 mountComponent
(src/core/instance/lifecycle.js)里创建时带了这个选项:
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 排在整个队列跑完之后
循环结束之后的收尾,顺序本身就是信息:
// 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,两个数组当场被清空,
不先留副本就没得发了。而重置放在发钩子之前,是为了让钩子里触发的新更新能干干净净地开一轮新的
—— waiting 和 flushing 这时已经回到 false,has 也是一张空表。
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._watcher,
updated 说的是「这个组件重渲染完了」,不是「这个组件上有 watcher 跑过」。
activatedChildren 收的是这一轮 patch 里被激活的 keep-alive 组件,queueActivatedComponent
上的注释说明了为什么要攒着:这个队列要等整棵树都 patch 完再处理。activated 排在 updated
前面发。
到这里一轮调度就完了。nextTick 的 callbacks 里,flushSchedulerQueue 这一项跑完,
排在它后面的用户回调才轮到 —— 上篇末尾那个 [A, flushSchedulerQueue, B] 的数组,
中间那一项展开来就是这一节和上一节的全部内容。
Vue 3 把一条队列拆成 pre 和 post 两段
Vue 3 的调度器在 packages/runtime-core/src/scheduler.ts(下面按 3.5.13 写)。
骨架还认得出来,但每一处都换了实现:
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 那次重构(把 pre、active、allowRecurse 这些散落的布尔字段合并成一个
SchedulerJobFlags 位域)顺带把它换掉了。
排序从「冲刷前排一次」变成了「插入时就排好」。 Vue 2 每轮 queue.sort(...);
Vue 3.5 的 flushJobs 里已经没有 sort 了,queueJob 拿 findInsertionIndex 做二分查找
把 job 插到正确位置。开头那个 jobId >= getId(lastJob) 是快路径:新 job 的 id 不小于队尾的,
直接 push,连二分都省了 —— 父到子的自然顺序下这条路命中率很高。
nextTick 挂的是当前这次冲刷的 Promise。 currentFlushPromise 是
resolvedPromise.then(flushJobs) 的返回值,也就是「冲刷完成」这个时刻。有冲刷在排队时,
nextTick(fn) 接在它后面;没有的话退回一个已经 resolve 的 Promise。Vue 2 靠回调在数组里的
下标决定先后,Vue 3 靠 Promise 链 —— 拿到的结论是一样的:改完数据再 nextTick,
回调看得见新 DOM。
真正的新东西是 pre 和 post 两段。Vue 3 的一轮冲刷分成两截:主队列 queue 跑完,
flushJobs 的 finally 里接着跑 flushPostFlushCbs(),清空 pendingPostFlushCbs。
watch 和 watchEffect 的 flush 选项落在哪一截 —— 两者在 apiWatch.ts 里走的是同一个
doWatch,所以这张表对它们都成立:
flush |
排到哪 | 跑的时机 |
|---|---|---|
'pre'(默认) |
queueJob,job 带 PRE 标记,id 取组件的 uid |
组件更新之前 |
'post' |
queuePostRenderEffect → queuePostFlushCb |
这一轮组件全更新完之后 |
'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 组件系统架构深度解析。