Job 递归
递归这个特性,是 vue 调度中比较复杂的情况。如果暂时理解不了的,可以先继续往下看,不必过于扣细节。
Job 递归,就是 Job 在更新组件 DOM 的过程中,依赖的响应式变量发生变化,又调用 queueJob
把自身的 Job 加入到队列中。
为什么会需要递归?
先做个类比,应该就大概明白了:
你刚拖好地,你儿子就又把地板踩脏了,你只有重新再拖一遍。
如果你一直拖,儿子一直踩,就是无限递归了。。。这时候就应该把儿子打一顿。。。
在组件 DOM 更新(instance.update)的过程中,可能会导致自身依赖的响应式变量改变,从而调用 queueJob,将自身 Job 加入到队列。
由于响应式数据被改变(因为脏了),需要整个组件重新更新(所以需要重新拖地)
下图就是一个组件 DOM 更新过程中,导致响应式变量变化的例子:
父组件刚更新完,子组件由于属性更新,立即触发 watch,emit 事件,修改了父组件的 loading 响应式变量,导致父组件需要重新更新。
(watch 一般情况下,是加入到 Pre 队列等待执行,但在组件 DOM 更新时,watch也是加入队列,但会立即执行并清空 Pre 队列,暂时先记住有这个小特性即可)
Job 的结构是怎样的?
Job 的数据结构如下:
export interface SchedulerJob extends Function { id?: number // 用于对队列中的 job 进行排序,id 小的先执行 active?: boolean computed?: boolean allowRecurse?: boolean // 表示 effect 是否允许递归触发本身 ownerInstance?: ComponentInternalInstance // 仅仅用在开发环境,用于递归超出次数时,报错用的 }
job 本身是一个函数,并且带有有一些属性。
- id,表示优先级,用于实现队列插队,id 小的先执行
- active:表示 Job 是否有效,失效的 Job 不执行。如组件卸载会导致 Job 失效
- allowRecurse:是否允许递归
其他属性,我们可以先不关注,因为跟调度机制的核心逻辑无关。
队列的结构是怎样的?
queue 队列的数据结构如下:
typescript
复制代码
constqueue: SchedulerJob[] = []
队列的执行:
// 按优先级排序 queue.sort((a, b) => getId(a) - getId(b)) try { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex] if (job && job.active !== false) { // 执行 Job 函数,并带有 Vue 内部的错误处理,用于格式化错误信息,给用户更好的提示 callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) } } } finally { // 清空 queue 队列 flushIndex = 0 queue.length = 0 }
在之前的图示讲解中,为了更好的理解队列,会把 Job 的执行,画成取出队列并执行。
而在真正写代码中,队列的执行,是不会把 Job 从 queue 中取出的,而是遍历所有的 Job 并执行,在最后清空整个 queue。
加入队列
queueJob
下面是 queue 队列的 Job,加入队列的实现:
export function queueJob(job: SchedulerJob) { if ( (!queue.length || // 去重判断 !queue.includes( job, // isFlushing 表示正在执行队列 // flushIndex 当前正在执行的 Job 的 index // queue.includes 函数的第二个参数,是表示从该索引开始查找 // 整个表达式意思:如果允许递归,则当前正在执行的 Job,不加入去重判断 isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex )) ) { if (job.id == null) { // 没有 id 的加入到队列末尾 queue.push(job) } else { // 在指定位置加入 job // findInsertionIndex 是使用二分查找,找出合适的插入位置 queue.splice(findInsertionIndex(job.id), 0, job) } queueFlush() // 作用会在后面说 } }
这里有几个特性:
- 去重
- 处理递归,如果允许递归,则正在运行的 job,不加入去重判断
- 优先级实现,按 id 从小到大,在队列合适的位置插入 Job;如果没有 id,则放到最后
queueCb
Pre 队列和 Post 队列的实现也大致相同,只不过是没有优先级机制(Post 队列的优先级在执行时处理):
function queueCb( cb: SchedulerJobs, activeQueue: SchedulerJob[] | null, pendingQueue: SchedulerJob[], index: number ) { if (!isArray(cb)) { if ( !activeQueue || // 去重判断 !activeQueue.includes(cb, cb.allowRecurse ? index + 1 : index) ) { pendingQueue.push(cb) } } else { // 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 duplicate check here to improve perf // 翻译:如果 cb 是一个数组,它只能是在一个 job 内触发的组件生命周期 hook(而且这些 cb 已经去重过了,可以跳过去重判断) pendingQueue.push(...cb) } queueFlush() } export function queuePreFlushCb(cb: SchedulerJob) { queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex) } export function queuePostFlushCb(cb: SchedulerJobs) { queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex) }
小结
总的来说,加入队列函数,核心逻辑就都是如下:
function queueJob(){ queue.push(job) queueFlush() // 作用会在后面说 }
在这个基础上,另外再加上一些去重判断、和优先级而已。
为什么组件异步队列 queue 跟 Pre 队列、Post 队列的入队方式还不一样呢?
因为一些细节上的处理不一致
- queue 队列有优先级
- 而 Pre 队列、Post 队列的入参,可能是数组
但其实我们也不需要过分关心这些细节,因为我们学习源码,其实是为了学习它的优良设计,我们把设计学到就好了,在现实的项目中,我们几乎不会遇到一模一样的场景,因此掌握整体设计,比抠细节更重要
那么 queueFlush 有什么作用呢?
queueFlush 的作用,就好像是你第一个到饭堂打饭,阿姨在旁边坐着,你得提醒阿姨该给你打饭了。
队列其实并不是一直都在执行的,当列队为空之后,就会停止,等到又有新的 Job 进来的时候,队列才会开始执行
queueFlush 在这里的作用,就是告诉队列可以开始执行了。
我们来看看 queueFlush 的实现:
let isFlushing = false // 标记队列是否正在执行 let isFlushPending = false // 标记队列是否等待执行 function queueFlush() { // 如果不是正在执行队列 / 等待执行队列 if (!isFlushing && !isFlushPending) { // 用于标记为等待执行队列 isFlushPending = true // 在下一个微任务执行队列 currentFlushPromise = resolvedPromise.then(flushJobs) } }
执行队列的方法,是 flushJob。
queueFlush 是队列执行时机的实现 —— flushJob 会在下一个微任务时执行。
为什么执行时机为下一个微任务?为什么不能是 setTimeout(flushJob, 0)
我们目的,是延迟执行 queueJob,等所有组件数据都更新完,再执行组件 DOM 更新(instance.update)。
要达到这一目的:我们只需要等在下一个浏览器任务,执行 queueJob 即可
因为,响应式数据的更新,都在当前的浏览器任务中。当 queueJob 作为微任务执行时,就表明上一个任务一定已经完成了。
而在浏览器中,微任务比宏任务有更高的优先级,因此 queueJob 使用微任务。
浏览器事件循环示意图如下:
每次循环,浏览器只会取一个宏任务执行,而微任务则是执行全部,在微任务执行 queueJob,能在最快时间执行队列,并且接下来浏览器就会执行渲染页面,更新UI。
否则,如果 queueJob 使用宏任务,极端情况下,可能会有多个宏任务在 queueJob 之前,而每次事件循环,只会取一个宏任务,则 queueJob 的执行时机会在非常的后,这对用户体验来说是有一定的伤害的
至此,我们已经把下图蓝色部分都解析完了:
剩下的是红色部分,即函数 flushJob 部分的实现了:
队列的执行 flushJob
function flushJobs() { // 等待状态设置为 false isFlushPending = false // 标记队列为正在执行状态 isFlushing = true // 执行 Pre 队列 flushPreFlushCbs() // 根据 job id 进行排序,从小到大 queue.sort((a, b) => getId(a) - getId(b)) // 用于检测是否是无限递归,最多 100 层递归,否则就报错,只会开发模式下检查 const check = __DEV__ ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job) : NOOP try { // 循环组件异步更新队列,执行 job for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex] // 仅在 active 时才调用 job if (job && job.active !== false) { // 检查无限递归 if (__DEV__ && check(job)) { continue } // 调用 job,带有错误处理 callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) } } } finally { // 收尾工作,重置这些用于标记的变量 flushIndex = 0 // 将队列执行的 index 重置 queue.length = 0 // 清空队列 // 执行 Post 队列 flushPostFlushCbs() isFlushing = false currentFlushPromise = null // 如果还有 Job,继续执行队列 // Post 队列运行过程中,可能又会将 Job 加入进来,会在下一轮 flushJob 执行 if ( queue.length || pendingPreFlushCbs.length || pendingPostFlushCbs.length ) { flushJobs() } } }
flushJob 主要执行以下内容:
- 执行 Pre 队列
- 执行queue 队列
- 执行 Post 队列
- 循环重新执行所有队列,直到所有队列都为空
执行 queue 队列
queue 队列执行对应的是这一部分:
try { // 循环组件异步更新队列,执行 job for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex] // 仅在 active 时才调用 job if (job && job.active !== false) { // 检查无限递归 if (__DEV__ && check(job)) { continue } // 调用 job,带有错误处理 callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) } } } finally { // 收尾工作,重置这些用于标记的变量 flushIndex = 0 // 将队列执行的 index 重置 queue.length = 0 // 清空队列 } }
循环遍历 queue,运行 Job,直到 queue 为空
queue 队列执行期间,可能会有新的 Job 入队,同样会被执行。
执行 Pre 队列
export function flushPreFlushCbs() { // 有 Job 才执行 if (pendingPreFlushCbs.length) { // 执行前去重,并赋值到 activePreFlushCbs activePreFlushCbs = [...new Set(pendingPreFlushCbs)] // pendingPreFlushCbs 清空 pendingPreFlushCbs.length = 0 // 循环执行 Job for ( preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex++ ) { // 开发模式下,校验无限递归的情况 if ( __DEV__ && checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex]) ) { continue } // 执行 Job activePreFlushCbs[preFlushIndex]() } // 收尾工作 activePreFlushCbs = null preFlushIndex = 0 // 可能递归,再次执行 flushPreFlushCbs,如果队列为空就停止 flushPreFlushCbs() } }
主要流程如下:
- Job 最开始是在 pending 队列中的
- flushPreFlushCbs 执行时,将 pending 队列中的 Job 去重,并改为 active 队列
- 循环执行 active 队列的 Job
- 重复 flushPreFlushCbs,直到队列为空
执行 Post 队列
export function flushPostFlushCbs(seen?: CountMap) { // 队列为空则结束 if (pendingPostFlushCbs.length) { // 去重 const deduped = [...new Set(pendingPostFlushCbs)] pendingPostFlushCbs.length = 0 // #1947 already has active queue, nested flushPostFlushCbs call // 特殊情况,发生了递归,在执行前 activePostFlushCbs 可能已经有值了,该情况可不必过多关注 if (activePostFlushCbs) { activePostFlushCbs.push(...deduped) return } activePostFlushCbs = deduped if (__DEV__) { seen = seen || new Map() } // 优先级排序 activePostFlushCbs.sort((a, b) => getId(a) - getId(b)) // 循环执行 Job for ( postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++ ) { // 在开发模式下,检查递归次数,最多 100 次递归 if ( __DEV__ && checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex]) ) { continue } // 执行 Job activePostFlushCbs[postFlushIndex]() } // 收尾工作 activePostFlushCbs = null postFlushIndex = 0 } }
主要流程如下:
- Job 最开始是在 pending 队列中的
- flushPostFlushCbs 执行时,将 pending 队列中的 Job 去重,然后跟 active 队列合并
- 循环执行 active 队列的 Job
为什么在队列最后没有像 Pre 队列那样,再次执行 flushPostFlushCbs?
Post 队列的 Job 执行时,可能会将 Job 继续加入到队列(Pre 队列,组件异步更新队列,Post 队列都可能)
新加入的 Job,会在下一轮 flushJob 中执行:
// postFlushCb 可能又会将 Job 加入进来,如果还有 Job,继续执行 if ( queue.length || pendingPreFlushCbs.length || pendingPostFlushCbs.length ) { // 执行下一轮队列任务 flushJobs() }
最后
之前写了两篇关于 vue 队列的文章,但是总感觉没能很好的表达出想要的意思。
恰逢最近公司要进行晋级答辩,听了同事的预答辩,越发觉得,个人的表达能力,跟技术能力同样的重要,如何将一件事情表达清楚(在有限的时间内,让别人知道,你做了什么厉害的事情),也是一个很重要得能力。
因此,我决定在这两篇文章的基础上,再次修改,整个过程,包括前两篇文章的编写,前前后后写了有一个半月,写了又改改了有写,补充了很多的图片和细节,希望能更好的帮助大家理解。
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。