深度剖析 Vue3 的調度系統

前言

什麼是調度?

調度這一概念最開始應該來自於操作系統。

由於計算機資源的有限性,必須按照一定的原則,選擇任務來佔用資源。

操作系統引入調度,目的是解決計算機資源的分配問題,因爲任務是源源不斷的,但 CPU 不能同時執行所有的任務。如:對部分優先級高的任務(如:用戶交互需要立即反饋),需要先佔用資源 / 運行,這就是一個優先級的調度。

Vue 的調度是什麼?有什麼不同?

Vue 的調度,行爲上也是按照一定的原則,選擇任務來佔用資源 / 執行。但同樣的行爲,目的卻是不一樣的。

因爲,Vue 並不需要解決計算機資源分配的問題(操作系統解決)。Vue 利用調度算法,保證 Vue 組件渲染過程的正確性以及 API 的執行順序的正確性(不好理解的話可以先看下文)

在 Vue3 的 API 設計中,存在着各種的異步回調 API 設計,如:組件的生命週期,watch API 的回調函數等。這些回調函數,並不是立即執行的,它們都作爲任務(Job),需要按照一定的規則 / 順序去執行

部分規則如下:

Vue 的 API 設計中就制定了這份規則,在什麼時候應該執行什麼任務,而這個規則在代碼中的實現,就是調度算法

學習 Vue 調度的目的

Vue 不是調度算法發明者,相反,Vue 是調度算法的使用者和受益者。這些設計,都是基於先人的探索沉澱,再結合自身需求改造出來的。

前端技術的更新迭代速度非常快,但是這些優秀的設計,卻是不變的,這也就是我們學習這些優秀設計的目的,能夠做到,以不變應萬變。

調度算法基本介紹

調度算法有兩個基本數據結構:隊列(queue),任務(Job)

調度算法有很多種,它們都有不同的目的,但它們的基本數據結構都相同,不同點在於入隊和出隊的方式

下面是兩種常見的調度算法

調度算法裏面一點關於 Vue 的東西都沒有,如何跟 Vue 扯上關係?

調度算法是對整個調度過程的抽象,算法無需關心任務(Job)的內容是什麼,它作爲 Vue3 的一種基礎設施,起到了解耦的作用(如果暫時還理解不了這句話,下一小節還有解釋)

調度算法只調度執行的順序,不負責具體的執行

那麼 Vue 是如何利用調度算法,來實現自身 API 的正確調度的呢?我們在文章後面會詳細描述

Vue3 調度算法的使用

Vue3 的調度算法,與上面提到的算法,大致相同,只是適配了 Vue 的一些細節

Vue 有 3 個隊列,分別爲:

3 個隊列的部分特性對比(大概看看即可,後面會詳細介紹):

x2mXRg

整個調度過程中,只有入隊過程,是由我們自己控制,整個隊列的執行(如何出隊),都由隊列自身控制

因此:調度算法對外暴露的 API,也只有入隊 API:

下面是用法:

const job1 = () => {
    // 假設這裏是父組件的 DOM 更新邏輯
    console.log('父組件 DOM 更新 job 1')
}
job1.id = 1  // 設置優先級,Vue 規定是 id 越小,優先級越高

const job2 = () => {
    // 假設這裏是子組件的 DOM 更新邏輯
    console.log('子組件 DOM 更新 job 2')
}
job2.id = 2  // 設置優先級

// 加入 queue 隊列
// job 2 先加入,但是會在 job 1 之後執行,因爲 id 小的,優先級更高
queueJob(job2)
queueJob(job1)

// 加入 Post 隊列
queuePostFlushCb(() => {
    // 假設這裏是 updated 生命週期
    console.log('執行 updated 生命週期 1')
})
// 加入 Post 隊列
queuePostFlushCb(() => {
    // 假設這裏是 updated 生命週期
    console.log('執行 updated 生命週期 2')
})

// 加入 Pre 隊列
queuePreFlushCb(() => {
    // 假設這裏是 watch 的回調函數
    console.log('執行 watch 的回調函數 1')
})
// 加入 Pre 隊列
queuePreFlushCb(() => {
    // 假設這裏是 watch 的回調函數
    console.log('執行 watch 的回調函數 2')
})
console.log('所有響應式數據更新完畢')

打印結果如下:

// 所有響應式數據更新完畢
// 執行 watch 的回調函數 1
// 執行 watch 的回調函數 2
// 父組件 DOM 更新 job 1
// 子組件 DOM 更新 job 2
// 執行 updated 生命週期 1
// 執行 updated 生命週期 2

隊列使用上非常的簡單,只要往對應的隊列,傳入 job 函數即可。隊列會在當前瀏覽器任務的所有 js 代碼執行完成後, 纔開始依次執行 Pre 隊列、queue 列、Post 隊列

調度算法是對整個調度過程的抽象

這裏我們應該能更好的理解這句話,隊列只是根據其自身的隊列性質(先進先出 or 優先級),選擇一個 Job 執行,隊列不關心 Job 的內容是什麼。

這樣的設計,可以極大的減少 Vue API 和 隊列間耦合,隊列不知道 Vue API 的存在,即使 Vue 未來新增新的異步回調的 API,也不需要修改隊列。

在上述例子中:我們大概可以看出,Vue3 是如何使用調度 API,去控制各種類型的異步回調的執行時機的。對於不同的異步回調 API,會根據 API 設計的執行時機,使用不同的隊列

如:

本文不會過多的介紹 Job 的具體內容的實現(不同的 API,Job 的內容都是不一樣的),而是專注於調度機制的內部實現,接下來我們的深入瞭解 Vue 的調度機制內部。

名詞約定

我們從一個例子中,理解用到的各種名詞:

<template>
  <div>{{count}}</div>
  <button @click='add'>Add</button>
</template>
<script setup lang='ts'>
import { ref } from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1  // template 依賴 count,修改後會觸 queueJob(instance.update)
}
</script>

響應式數據更新

指模板依賴的 ref、reactive、組件 data 等響應式數據的變化

這裏指點擊按鈕觸發的 click 回調中,響應式數據 count.value 被修改

組件 DOM 更新

實際上是調用 instance.update 函數,該函數會對比組件 data 更新前的 VNode組件 data 更新後的 VNode,對比之間的差異,修改差異部分的 DOM。該過程叫 patch,比較 vnode 的方法叫 diff 算法(因爲這裏沒有篇幅展開,因此大概看看記住  instance.update 的特點即可)

調度細節

用一個表格總結 3 個調度過程中的一些細節

ksptc9

接下來我們一個個細節進行解析:

任務去重

每次修改響應式變量(即修改相應的響應式數據),都會將組件 DOM 更新 Job 加入隊列。

// 當組件依賴的響應式變量被修改時,會立即調用 queueJob
queueJob(instance.update)

那當我們同時修改多次,同一個組件依賴的響應式變量時,會多次調用 queueJob。

下面是一個簡單的例子:

<template>
  <div>{{count}}</div>
  <button @click='add'>Add</button>
</template>
<script setup lang='ts'>
import { ref } from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1  // template 依賴 count,修改後會觸 queueJob(instance.update)
  count.value = count.value + 2  // template 依賴 count,修改後會觸 queueJob(instance.update)
}
</script>

count.value 前後兩次被修改,會觸發兩次 queueJob

爲了防止多次重複地執行更新,需要在入隊的時候,對 Job 進行去重(僞代碼):

export function queueJob(job: SchedulerJob) {
  // 去重判斷
  if (!queue.includes(job)) {
   // 入隊
    queue.push(job)
  }
}

其他隊列的入隊函數也有類似的去重邏輯。

優先級機制

只有 queue 隊列和 Post 隊列,是有優先級機制的,job.id 越小,越先執行

爲什麼需要優先級隊列?

queue 隊列和 Post 隊列使用優先級的原因各不相同。

我們來逐一分析:

queue 隊列的優先級機制

queue 隊列的 Job,是執行組件的 DOM 更新。在 Vue 中,組件並不都是相互獨立的,它們之前存在父子關係

必須先更新父組件,才能更新子組件,因爲父組件可能會傳參給子組件(作爲子組件的屬性)

下圖展示的是,父組件和子組件及其屬性更新先後順序:

父組件 DOM 更新前,纔會修改子組件的 props,因此,必須要先執行父組件 DOM 更新,子組件的 props 纔是正確的值。

因此:父組件優先級 > 子組件優先級

如何保證父組件優先級更高?即如何保證父組件的 Job.id 更小?

我們上一小節說過,組件 DOM 更新,會深度遞歸更新子組件。組件創建的過程也一樣,也會深度遞歸創建子組件。

下面是一個組件樹示意圖,其創建順序如下:

深度創建組件,即按樹的深度遍歷的順序創建組件。深度遍歷,一定是先遍歷父節點,再遍歷子節點

因此,從圖中也能看出,父組件的序號,一定會比子組件的序號小,使用序號作爲 Job.id 即可保證父組件優先級一定大於子組件

這裏我們可以感受一下深度遍歷在處理依賴順序時的巧妙作用,前輩們總結出來的算法,竟有如此的妙用。

我們學習源碼,學習算法,就是學習這些設計。

當我們以後在項目中,遇到依賴誰先執行的問題,會想起深度遍歷這個算法。

要實現 queue 隊列 Job 的優先級,我們只需要實現插隊功能即可:(僞代碼):

export function queueJob(job: SchedulerJob) {
  // 去重判斷
  if ( !queue.includes(job) ) {
    // 沒有 id 放最後
    if (job.id == null) {
      queue.push(job)
    } else {
      // 二分查找 job.id,計算出需要插入的位置
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
  }
}

Post 隊列的優先級機制

先回顧一下我們常常使用到的 Post 隊列的 Job,都有哪些:

這些用戶設定的回調之間,並沒有依賴關係

那爲什麼 Post 隊列還需要優先級呢?

因爲有一種內部的 Job,要提前執行,它的作用是,更新模板引用

因爲用戶編寫的回調函數中,可能會使用到模板引用,因此必須要在用戶編寫的回調函數執行前,把模板引用的值更新

看如下代碼:

<template>
  <button @click='add' >count: {{ count }}</button>
  <div v-if="count % 2" :ref="divRef">count 爲偶數</div>
  <div v-else :ref="divRef">count 爲奇數</div>
</template>
<script setup lang='ts'>
import {onUpdated, ref} from 'vue'

const count = ref(0)

function add() {
  count.value = count.value + 1
}
const divRef = ref<HTMLElement>()

onUpdated(() => {
  console.log('onUpdated', divRef.value?.innerHTML)
})
</script>

響應式變量 count 爲奇數或偶數時,divRef.value 指向的 DOM 節點是不一樣的。

必須要在用戶寫的 updated 生命週期執行前,先更新 divRef,否則就會取到錯誤的值。

因此,更新模板引用的 Job,job.id = -1,會先執行

而其他用戶設定的 job,沒有設置 job.id,會加入到隊列末尾,在最後執行。

失效任務

當組件被卸載(unmounted)時,其對應的 Job 會失效,因爲不需要再更新該組件了。失效的任務,在取出隊列時,不會被執行。

只有 queue 隊列的 Job,會失效。

下面是一個失效案例的示意圖:

image-20220121123048216

  1. 點擊按鈕,count.value 改變

  2. count 響應式變量改變,會立即 queueJob 將子組件 Job 加入隊列

  3. emit 事件,父組件 hasChild.value 改變

  4. hasChild 響應式變量改變,會立即 queueJob 將父組件 Job 加入隊列

  5. 父組件有更高優先級,先執行。

  6. 更新父組件 DOM,子組件由於 v-if,被卸載

  7. 子組件卸載時,將其 Job 失效,Job.active = false

要實現失效任務不執行,非常簡單,參考如下實現(僞代碼):

for(const job of queue){
    if(job.active !== false){
        job()
    }
}

刪除任務

組件 DOM 更新(instance.update),是深度更新,會遞歸的對所有子組件執行 instance.update

因此,在父組件深度更新完成之後,不需要再重複更新子組件,更新前,需要將組件的 Job 從隊列中刪除

下圖是任務刪除的示意圖:

在一個組件 DOM 更新時,會先把該組件的 Job,從隊列中刪除。因爲即將更新該組件,就不需要再排隊執行了。

要實現刪除 Job,非常簡單:

export function invalidateJob(job) {
  // 找到 job 的索引
  const i = queue.indexOf(job)
  // 刪除 Job
  queue.splice(i, 1)
}

// 在 instance.udpate 中刪除當前組件的 Job
const job = instance.update = function(){
    invalidateJob(job)
    
    // 組件 DOM 更新
}

刪除和失效,都是不執行該 Job,它們有什麼使用上的區別?

gr8orK

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 本身是一個函數,並且帶有有一些屬性。

其他屬性,我們可以先不關注,因爲跟調度機制的核心邏輯無關。

隊列的結構是怎樣的?

queue 隊列的數據結構如下:

const queue: 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()   // 作用會在後面說
  }
}

這裏有幾個特性:

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 隊列的入隊方式還不一樣呢?

因爲一些細節上的處理不一致

但其實我們也不需要過分關心這些細節,因爲我們學習源碼,其實是爲了學習它的優良設計,我們把設計學到就好了,在現實的項目中,我們幾乎不會遇到一模一樣的場景,因此掌握整體設計,比摳細節更重要

那麼 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 主要執行以下內容:

  1. 執行 Pre 隊列

  2. 執行 queue 隊列

  3. 執行 Post 隊列

  4. 循環重新執行所有隊列,直到所有隊列都爲空

執行 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()
  }
}

主要流程如下:

  1. Job 最開始是在 pending 隊列中的

  2. flushPreFlushCbs 執行時,將 pending 隊列中的 Job 去重,並改爲 active 隊列

  3. 循環執行 active 隊列的 Job

  4. 重複 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
  }
}

主要流程如下:

  1. Job 最開始是在 pending 隊列中的

  2. flushPostFlushCbs 執行時,將 pending 隊列中的 Job 去重,然後跟 active 隊列合併

  3. 循環執行 active 隊列的 Job

爲什麼在隊列最後沒有像 Pre 隊列那樣,再次執行 flushPostFlushCbs?

Post 隊列的 Job 執行時,可能會將 Job 繼續加入到隊列(Pre 隊列,組件異步更新隊列,Post 隊列都可能)

新加入的 Job,會在下一輪 flushJob 中執行:

// postFlushCb 可能又會將 Job 加入進來,如果還有 Job,繼續執行
if (
  queue.length ||
  pendingPreFlushCbs.length ||
  pendingPostFlushCbs.length
) {
  // 執行下一輪隊列任務
  flushJobs()
}

最後

之前寫了兩篇關於 vue 隊列的文章,但是總感覺沒能很好的表達出想要的意思。

恰逢最近公司要進行晉級答辯,聽了同事的預答辯,越發覺得,個人的表達能力,跟技術能力同樣的重要,如何將一件事情表達清楚(在有限的時間內,讓別人知道,你做了什麼厲害的事情),也是一個很重要得能力。

因此,我決定在這兩篇文章的基礎上,再次修改,整個過程,包括前兩篇文章的編寫,前前後後寫了有一個半月,寫了又改改了有寫,補充了很多的圖片和細節,希望能更好的幫助大家理解。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Ax_J2waXyrd7PBHbWSnAcA