探索 React 的內在 —— postMessage & Scheduler

postMessage & Scheduler

寫在前面

  • 本文包含了必定量的源碼講解,其中筆者寫入了一些內容來替代官方註釋(就是寫了差很少等於沒寫那種),若讀者更青睞於原始的代碼,

可移步官方倉庫,結合起來閱讀。也正由於這個緣由,橫屏或 PC 的閱讀體驗也許會更佳(代碼可能須要左右滑動)javascript

  • 本文沒有顯式的涉及 React Fiber Reconciler 和 Algebraic Effects(代數效應)的內容,但其實它們是息息相關的,能夠理解爲本文的內容就是實現前二者的基石。

有興趣的讀者可移步《Fiber & Algebraic Effects》作一些前置閱讀。html

開始

在去年 2019 年 9 月 27 日的 release 中,React 在 Scheduler 中開啓了新的調度任務方案試驗:java

  • 舊方案:經過 requestAnimationFrame(如下統稱 cAF,相關的 requestIdleCallback 則簡稱 rIC)使任務調度與幀對齊
  • 新方案:經過高頻(短間隔)的調用 postMessage 來調度任務

Emm x1... 忽然有了好多問題
那麼本文就來探索一下,在此次「小小的」 release 中都發生了什麼
node

契機

經過對此次 release 的 commit-message 的查看,咱們總結出如下幾點:react

  1. 因爲 rAF 仰仗顯示器的刷新頻率,所以使用 rAF 須要看 vsync cycle(指硬件設備的頻率)的臉色
  2. 那麼爲了在每幀執行儘量多的任務,採用了 5ms 間隔的消息事件 來發起調度,也就是 postMessage 的方式
  3. 這個方案的主要風險是:更加頻繁的調度任務會加重主線程與其餘瀏覽器任務的資源爭奪
  4. 相較於 rAF 和 setTimeout,瀏覽器在後臺標籤下對消息事件進行了什麼程度的節流還須要進一步肯定,該試驗是假設它與定時器有相同的優先級

簡單來講,就是放棄了由 rAF 和 rIC 兩個 API 構成的幀對齊策略,轉而人爲的控制調度頻率,提高任務處理速度,優化 React 運行時的性能
git

postMessage


那麼,postMessage 又是什麼呢?是指 iframe 通訊機制中的 postMessage 嗎?
github

不對,也對

Emm x2... 好吧,有點謎語了,那解謎吧
api

不對

說不對呢,是由於 postMessage 自己是使用的 MessageChannel 這個接口建立的對象發起的數組

Channel Message API 的 MessageChannel 接口容許咱們建立一個新的消息通道,並經過該通道的兩個 MessagePort 進行通訊

這個通道一樣適用於 Web Worker —— 因此,它挺有用的...
咱們看看它究竟是怎樣通訊的:瀏覽器

const ch = new MessageChannel()

ch.port1.onmessage = function(msgEvent) {
  console.log('port1 got ' + msgEvent.data)
  ch.port1.postMessage('Ok, r.i.p Floyd')
}

ch.port2.onmessage = function(msgEvent) {
  console.log(msgEvent.data)
}

ch.port2.postMessage('port2!')

// 輸出:
// port1 got port2!
// Ok, r.i.p Floyd.

很簡單,沒什麼特別的...
Emm x3...
啊... 日常不多直接用它,它的兼容性怎麼樣呢?
image.png


唔!儘管是 10,但 IE 居然也能夠全綠!

也對

害,兼容性這麼好,其實就是由於現代瀏覽器中 iframe 與父文檔之間的通訊,就是使用的這個消息通道,你甚至能夠:

// 假設 <iframe id="childFrame" src="XXX" />

const ch = new MessageChannel()
const childFrame = document.querySelector('#childFrame')

ch.port2.onmessage = function(msgEvent) {
  console.log(msgEvent.data)
  console.log('There\'s no father exists ever')
}

childFrame.contentWindow.postMessage('Father I can\'t breathe!', '*', [ch.port2])

// 輸出:
// Father I can't breathe
// There's no father exists ever

好了,咱們已經知道這個 postMessage 是個什麼東西了,那接着看看它是怎麼運做的吧

作事

在談到 postMessage 的運做方式以前,先提一下 Scheduler

Scheduler

Scheduler 是 React 團隊開發的一個用於事務調度的包,內置於 React 項目中。其團隊的願景是孵化完成後,使這個包獨立於 React,成爲一個能有更普遍使用的工具
咱們接下來要探索的相關內容,都是在這個包的範疇以內

找到 MessageChannel

在 Scheduler 的源碼中,經過搜索 postMessage 字眼,咱們很容易的就將目光定位到了 SchedulerHostConfig.default.js 文件,咱們截取部份內容:

在完整源碼中,有一個 if-else 分支來實現了兩套不一樣的 API。對於非 DOM 或是沒有 MessageChannel 的 JavaScript 環境(如 JavaScriptCore),如下內容是採用 setTimeout 實現的,有興趣的同窗能夠去看一下,至關簡單的一段 Hack,本文不做贅述,僅專一於 else 分支下的源碼。
以上也是爲何這個文件會叫 xxxConfig 的緣由,它確實是帶有配置性的邏輯的
const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // Yield after `yieldInterval` ms, regardless of where we are in the vsync
      // cycle. This means there's always time remaining at the beginning of
      // the message event.
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          port.postMessage(null);
        }
      } catch (error) {
        // If a scheduler task throws, exit the current browser task so the
        // error can be observed.
        port.postMessage(null);
        throw error;
      }
    } else {
      isMessageLoopRunning = false;
    }
    // Yielding to the browser will give it a chance to paint, so we can
    // reset this.
    needsPaint = false;
  };

  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;

  requestHostCallback = function(callback) {
    scheduledHostCallback = callback;
    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      port.postMessage(null);
    }
  };

這行代碼的邏輯其實很簡單:

  1. 定義一個名爲 channel 的 MessageChannel,並定義一個 port 變量指向其 port2 端口
  2. 將預先定義好的 performWorkUntilDeadline 方法做爲 channel 的 port1 端口的消息事件處理函數
  3. 在 requestHostCallback 中調用前面定義的 port 變量 —— 也就是 channel 的 port2 端口 —— 上的 postMessage 方法發送消息
  4. performWorkUntilDeadline 方法開始運做

好了,咱們如今就來剖析一下這一小段代碼中的各個元素

requestHostCallback(如下簡稱 rHC)

還記得 rAF 和 rIC 嗎?他們前任調度機制的核心 API,那麼既然 rHC 和他們長這麼像,必定就是如今值班那位咯
確實,咱們直接進入代碼身體內部嚐嚐:

requestHostCallback = function(callback) {
    // 將傳入的 callback 賦值給 scheduledHostCallback
    // 類比 `requestAnimationFrame(() => { /* doSomething */ })` 這樣的使用方法,
    // 咱們能夠推斷 scheduledHostCallback 就是當前要執行的任務(scheduled嘛)
    scheduledHostCallback = callback;
  
      // isMessageLoopRunning 標誌當前消息循環是否開啓
    // 消息循環幹嗎用的呢?就是不斷的檢查有沒有新的消息——即新的任務——嘛
    if (!isMessageLoopRunning) {
      // 若是當前消息循環是關閉的,則 rHC 有權力打開它
      isMessageLoopRunning = true;
      // 打開之後,channel 的 port2 端口將受到消息,也就是開始 performWorkUntilDeadline 了
      port.postMessage(null);
    } // else 會發生什麼?
  };

好了,咱們如今知道,rHC 的做用就是:

  • 準備好當前要執行的任務(scheduledHostCallback)
  • 開啓消息循環調度
  • 調用 performWorkUntilDeadline

performWorkUntilDeadline

如今看來,rHC 是搞事的,performWorkUntilDealine 就是作事的咯
確實,咱們又直接進入代碼身體內部嚐嚐:

const performWorkUntilDeadline = () => {
      // [A]:先檢查當前的 scheduledHostCallback 是否存在
    // 換句話說就是當前有沒有事須要作
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // 啊,截止時間!
      // 看來就是截止到 yieldInterval 以後,是多少呢?
      // 按前文的內容,應該是 5ms 吧,咱們以後再驗證
      deadline = currentTime + yieldInterval;
      // 唔,新鮮的截止時間,換句話說就是還有多少時間唄
      // 有了顯示的剩餘時間定義,不管咱們處於 vsync cycle 的什麼節點,在收到消息(任務)的時候都有時間了
      const hasTimeRemaining = true; // timeRemaining 這個字眼讓人想起了 rIC
      try {
        // 嗯,看來這個 scheduledHostCallback 中不簡單,稍後研究它
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
            // 若是完成了最後一個任務,就關閉消息循環,並清洗掉 scheduledHostCallback 的引用
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // [C]:若是還有任務要作,就用 port 繼續向 channel 的 port2 端口發消息
          // 顯然,這是一個相似於遞歸的操做
          // 那麼,若是沒有任務了,顯然不會走到這兒,爲何還要判斷 scheduledHostCallback 呢?日後看
          port.postMessage(null);
        }
      } catch (error) {
        // 若是當前的任務執行除了故障,則進入下一個任務,並拋出錯誤
        port.postMessage(null);
        throw error;
      }
    } else {
      // [B]:沒事兒作了,那麼就不用循環的檢查消息了唄
      isMessageLoopRunning = false;
    }
    // Yielding to the browser will give it a chance to paint, so we can
    // reset this.
    needsPaint = false;
  };

如今就明朗許多了,咱們用一個示意圖進行表示:
how_postMessage_work.png
兩個虛線箭頭表示引用關係,那麼根據代碼中的分析如今能夠知道,全部的任務調度,都是由 port —— 也就是 channel 的 port2 端口 —— 經過調用 postMessage 方法發起的,而這個任務是否要被執行,彷佛與 yieldInterval 和 hasTimeRemaning 有關,來看看它們:

  • yieldInterval: 在完整源碼中,有這兩麼兩處:
// 直接定義爲 5ms,根本沒商量的
const yieldInterval = 5

// 可是
// 這個方法實際上是 Scheduler 包提供給開發者的公共 API,
// 容許開發者根據不一樣的設備刷新率設置調度間隔
// 其實就是因地制宜的考慮

forceFrameRate = function(fps) {
      // 最高到 125 fps
    // 個人(僞裝有)144hz 電競屏有被冒犯到
    if (fps < 0 || fps > 125) {
      // Using console['error'] to evade Babel and ESLint
      console['error'](
        'forceFrameRate takes a positive int between 0 and 125, ' +
          'forcing framerates higher than 125 fps is not unsupported',
      );
      return;
    }
    if (fps > 0) {
      yieldInterval = Math.floor(1000 / fps);
    } else {
      // 顯然,若是沒傳或者傳了個負的,就重置爲 5ms,提高了一些魯棒性
      // reset the framerate
      yieldInterval = 5;
    }
  };
  • hasTimeRemaning:參考 rIC 一般的使用方式:
function doWorks() {
  // todo
}

function doMoreWorks() {
     // todo more 
}

function todo() {
      requestIdleCallback(() => {
      // 作事嘛,最重要的就是還有沒有時間
           if (e.timeRemaining()) {
        doMoreWorks()
      }
   })
   doWorks()
}

Emm x4... 上圖中還有兩處標紅的疑問:

  • what happened?: 其實這個地方呢,就是爲 performWorkUntilDeadline 提供新的 scheduledHostCallback。這樣一來,performWorkUntilDeadline 就「一直有事作」,直到再也不有任務經過 rHC 註冊進來
  • But How?: 接下來,咱們就來解答這個問題的答案,一切都要從 Scheduler 提及

Scheduler

啊哈,此次咱們給 Scheduler 了一個更大的標題來代表它的主角身份 🐶...
咱們此次直接從入口開始,一步一步地迴歸到 But How? 這個問題上去

又寫在前面

  • 根據 Scheduler 的 README 文件可知,其當前的 API 尚非最終方案,所以其入口文件 Scheduler.js 所暴露出來的接口都帶上了 unstable_ 前綴,爲使篇幅簡單,如下對接口名稱的描述都省去該前綴
  • 源碼中還包含了一些 profiling 相關的邏輯,它們主要是用於輔助調試和審計,與運做方式沒有太大的關係,所以下文會忽略這些內容,專一於核心邏輯的闡釋

scheduleCallback —— 把任務交給 Scheduler

咱們旅程的起點就從這個接口開始,它是開啓 Scheduler 魔法的鑰匙🔑~
該接口用於將一個回調函數——也就是咱們要執行的任務——按給定的優先級額外設置註冊進 Scheduler 的任務隊列中,並啓動任務調度:

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime(); // [A]:getCurrentTime 是怎樣獲取當前時間的?

  var startTime; // 給定回調函數一個開始時間,並根據 options 中定義的 delay 來延遲
  // 給定回調函數一個定時器,並根據 options 中的 timeout 定義來肯定是直接使用自定義的仍是用 timeoutForPriorityLevel 方法來產出定時時間
  // [B]:那麼 timeoutForPriorityLevel 是怎麼作的呢?
  var timeout;
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
    timeout =
      typeof options.timeout === 'number'
        ? options.timeout
        : timeoutForPriorityLevel(priorityLevel); // [C] 這個 priorityLevel 哪來的?
  } else {
    timeout = timeoutForPriorityLevel(priorityLevel);
    startTime = currentTime;
  }
  
  // 定義一個過時時間,以後還會遇到它
  var expirationTime = startTime + timeout;

  // 啊,從這裏咱們能夠看到,在 Scheduler 中一個 task 到底長什麼樣了
  var newTask = {
    id: taskIdCounter++, // Scheduler.js 中全局定義了一個 taskIdCounter 做爲 taskId 的生產器
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,  // [D]:前面的都見過了,這個 sortIndex 是排序用的嗎?
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }

  if (startTime > currentTime) {
    // 還記得 options 中的 delay 屬性嗎,這就給予了該任務開始時間大於當前時間的可能
    // 唔,前面定義 sortIndex 又出現了,在這種狀況下被賦值爲了 startTime,
    newTask.sortIndex = startTime;
    // [E]:這裏出現了一個定時器隊列(timerQueue)
    // 若是開始時間大於當前時間,就將它 push 進這個定時器隊列
    // 顯然,對於要未來執行的任務,勢必得將它放在一個「待激活」的隊列中
    push(timerQueue, newTask);
    // 這裏的邏輯稍後討論,先進入 else 分支
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // All tasks are delayed, and this is the task with the earliest delay.
      if (isHostTimeoutScheduled) {
        // Cancel an existing timeout.
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // Schedule a timeout.
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // expirationTime 做爲了 sortIndex 的值,從邏輯上基本能夠確認 sortIndex 就是用於排序了
    newTask.sortIndex = expirationTime;
    // [F]: 這裏又出現了 push 方法,此次是將任務 push 進任務隊列(taskQueue),看來定時器隊列和任務隊列是同構的咯?
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // 從邏輯上看,這裏就是判斷當前是否正處於流程,即 performWorkUntilDeadline 是否正處於一個遞歸的執行狀態中中,若是不在的話,就開啓這個調度
    // [G]:Emm x5... 那這個 flushWork 是幹什麼的呢?
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

  return newTask;
}

ok,咱們如今來分解一下上述註釋中標記了 [X] 的幾個問題,使函數做用更加立體一點:

  • A: getCurrentTime 是如何獲取當前時間的呢?

    • 解:在以前提到的 schedulerHostConfig.default.js 文件中,根據 performance 對象及 performance.now 方法是否存在,區分了是用 Date.now 仍是用 performance.now 來獲取當前時間,緣由是後者比前者更加精確切絕對,詳情可參考這裏
  • B C: 咱們直接來看看 Scheduler.js 中 timeoutForPriorityLevel 方法的相關內容便知:
// ...other code
var maxSigned31BitInt = 1073741823;

/**
 * 如下幾個變量是全局定義的,至關於系統常量(環境變量)
 */
// 當即執行
// 顯然,若是不定義 deley,根據 [B] 註釋處緊接的邏輯,expirationTime 就等於 currentTime - 1 了
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// 再日後就必定會進入 else 分支,並 push 到任務隊列當即進入 performWorkUntilDealine
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// 最低的優先級看起來是永遠不會被 timeout 到的,稍後看看它會在何時執行
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

// ...other code

// 能夠看到,priorityLevel 顯然也是被系統常量化了的
function timeoutForPriorityLevel(priorityLevel) {
  switch (priorityLevel) {
    case ImmediatePriority:
      return IMMEDIATE_PRIORITY_TIMEOUT;
    case UserBlockingPriority:
      return USER_BLOCKING_PRIORITY_TIMEOUT;
    case IdlePriority:
      return IDLE_PRIORITY_TIMEOUT;
    case LowPriority:
      return LOW_PRIORITY_TIMEOUT;
    case NormalPriority:
    default:
      return NORMAL_PRIORITY_TIMEOUT;
  }
}

// ...other code

其中 priorityLevel 定義在 schedulerPriorities.js 中,很是直觀:

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

// 啊哈,未來可能用 symbols 來實現,
// 那樣的話,大小的對比是否是又得抽象一個規則出來呢?
// TODO: Use symbols?
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

看來,任務執行的時機就是由 當前時間(currentTime)+延時(delay)+優先級定時(XXX_PRIORITY_TIMEOUT) 來決定,而定時時長的增量則由 shedulerPriorities.js 中的各個值來決定

  • C D E: 這三個點是很是相關的,所以直接放在一塊兒

    • sortIndex: 即排序索引,根據前面的內容和 [B] 的闡釋,咱們能夠知道,該屬性的值要麼是 startTime,要麼是 expirationTime,顯然都是越小越早嘛——所以,用這個值來排序,勢必也就將任務的優先級排出來了
    • timerQueue 和 taskQueue:害,sortIndex 確定是用於在這兩個同構隊列中排序了嘛。_看到這裏,熟悉數據結構的同窗應該已經猜到,這兩個隊列的數據結構可能就是處理優先級事務的標準方案——最小優先隊列。_

果真,咱們溯源到 push 方法是在一個叫 schedulerMinHeap.js 的文件中,而最小優先隊列就是基於最小堆(min-heap)來實現的。咱們待會兒看看 push 到底對這個隊列作了什麼。

  • F: flushWork!聽這個名字就很通暢對不對這個名字已經很好的告訴了咱們,它就是要將當前全部的任務一一處理掉!它是怎麼作的呢?留個懸念,先跳出 scheduleCallback

最小堆

最小堆本質上是一棵徹底二叉樹,經排序後,其全部非終端節點的元素值都不大於其左節點和右節點,即以下:
min-heap.png

原理

Sheduler 採用了數組對這個最小堆進行實現,如今咱們簡單的來解析一下它的工做原理

PUSH

咱們向上面這個最小堆中 push 進一個值爲 5 的元素,其工做流程以下所示:
min-heap-push.png
能夠看到,在 push 的過程當中,調用 siftUp 方法將值爲 5 的元素排到了咱們想要的位置,成了右邊這棵樹。相關代碼以下:

type Heap = Array<Node>;
type Node = {|
  id: number,
  sortIndex: number,
|};

export function push(heap: Heap, node: Node): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

function siftUp(heap, node, i) {
  let index = i;
  while (true) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (parent !== undefined && compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}

function compare(a, b) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

能夠看到,siftUp 中對於父節點位置的計算還使用了移位操做符>>>1 等價於除以 2 再去尾)進行優化,以提高計算效率

POP

那麼,咱們要從其中取出一個元素來用(在 Scheduler 中即調度一個任務出來執行),工做流程以下所示:
min-heap-pop.png
當咱們取出第一個元素——即值最小,優先級最高——後,樹失去了頂端,勢必須要從新組織其枝葉結構,而 siftDown 方法就是用於從新梳理剩餘的元素,使其仍然保持爲一個最小堆,相關代碼以下:

export function pop(heap: Heap): Node | null {
  const first = heap[0];
  if (first !== undefined) {
    const last = heap.pop();
    if (last !== first) {
      heap[0] = last;
      siftDown(heap, last, 0);
    }
    return first;
  } else {
    return null;
  }
}

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  while (index < length) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (left !== undefined && compare(left, node) < 0) {
      if (right !== undefined && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (right !== undefined && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

Emm x5... 和 PUSH 部分的代碼合併一下,就是一個最小堆的標準實現了
剩下地,SchedulerMinHeap.js 源碼中還提供了一個 peek(看一下) 方法,用於查看頂端元素:

export function peek(heap: Heap): Node | null {
  const first = heap[0];
  return first === undefined ? null : first;
}

其做用顯然就是取第一個元素出來 peek peek 咯~ 咱們立刻就會遇到它

flushWork

如今,咱們來看看 Scheduler 是如何將任務都 flush 掉的:

function flushWork(hasTimeRemaining, initialTime) {
  if (enableProfiling) {
    markSchedulerUnsuspended(initialTime);
  }

  // [A]:爲何要重置這些狀態呢?
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    // We scheduled a timeout but it's no longer needed. Cancel it.
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  // [B]:從邏輯上看,在任務自己沒有拋出錯誤的狀況下,flushWork 就是返回 workLoop 的結果,那麼 workLoop 作了些什麼呢?
  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    if (enableProfiling) {
      try {
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          markTaskErrored(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // 特地留下了這條官方註釋,它告訴咱們在生產環境下,flushWork 不會去 catch workLoop 中拋出的錯誤的,
           // 由於在開發模式下或調試過程當中,這種錯誤通常會形成白頁並給予開發者一個提示,顯然這個功能不能影響到用戶
      // No catch in prod codepath.
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    // 若是任務執行出錯,則終結當前的調度工做
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}

如今來分析一下這段代碼中的 ABC~

  • A: 爲何要重置這些狀態呢?

因爲 rHC 並不必定當即執行傳入的回調函數,因此 isHostCallbackScheduled 狀態可能會維持一段時間;等到 flushWork 開始處理任務時,則須要釋放該狀態以支持其餘的任務被 schedule 進來;isHostTimeoutScheduled 也是一樣的道理,關於這是個什麼 timeout,咱們很快就會遇到

  • B: workLoop,Emm x6... 快要到這段旅程的終點了。就像連載小說的填坑同樣,這個方法將會解答不少問題

workLoop

顧名思義,該方法必定會包含一個用於處理任務的循環,那麼這個循環裏都發生了什麼呢?

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  // [A]:這個方法是幹嗎的?
  advanceTimers(currentTime);
  // 將任務隊列最頂端的任務 peek 一下
  currentTask = peek(taskQueue);
  // 只要 currentTask 存在,這個 loop 就會繼續下去
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // dealine 到了,可是當前任務還沒有過時,所以讓它在下次調度週期內再執行
      // [B]:shouldYieldToHost 是怎麼作判斷的呢?
      break;
    }
    const callback = currentTask.callback;
    if (callback !== null) {
      // callback 不爲 null,則說明當前任務是可用的
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      // 判斷當前任務是否過時
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      markTaskRun(currentTask, currentTime);
      // [C]:continuationCallback?這是什麼意思?讓任務繼續執行?
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
          // 看來,若是 continuationCallback 成立,則用它來取代當前的 callback
        currentTask.callback = continuationCallback;
        markTaskYield(currentTask, currentTime);
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        // 若是 continuationCallback 不成立,就會 pop 掉當前任務,
        // 邏輯上則應該是斷定當前任務已經完成
        // Emm x7... 那麼 schedule 進來的任務,實際上應該是要遵循這個規則的
        // [D]:咱們待會兒再強調一下這個問題
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      // advanceTimers 又來了...
      advanceTimers(currentTime);
    } else {
      // 若是當前的任務已經不可用,則將它 pop 掉
      pop(taskQueue);
    }
    // 再次從 taskQueue 中 peek 一個任務出來
    // 注意,若是前面的 continuationCallback 成立,taskQueue 則不會發生 pop 行爲,
    // 所以 peek 出的任務依然是當前的任務,只是 callback 已是 continuationCallback 了
    currentTask = peek(taskQueue);
  }
  // Bingo!這不就是檢查還有沒有更多的任務嗎?
  // 終於迴歸到 performWorkUntilDealine 中的 hasMoreWork 邏輯上了!
  if (currentTask !== null) {
    return true;
  } else {
    // [E]:誒,這兒好像不太單純,幹了點兒啥呢?
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

咱們終於解答了前面的 But How 問題
如今,咱們解析一下上述代碼中的 ABC,看看這個循環是怎麼運做起來的

  • A:上述代碼兩次出現了 advanceTimers,它到底是用來幹嗎的呢?上代碼一看便知:
function advanceTimers(currentTime) {
  // 其實下面的官方註解已經很明確了,就是把 timerQueue 中排隊的任務根據須要轉移到 taskQueue 中去
  // Check for tasks that are no longer delayed and add them to the queue.
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // Timer was cancelled.
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // Remaining timers are pending.
      return;
    }
    timer = peek(timerQueue);
  }
}

其實這段代碼至關的簡單,就是根據 startTimecurrentTime 來判斷某個 timer 是否到了該執行的時間,而後將它轉移到 taskQueue 中,大體能夠總結爲如下示意:
advanceTimers.png
所以,workLoop 中第一次調用它的做用就是將當前須要執行的任務從新梳理一下;
那麼第二次調用則是因爲 while 語句中的任務執行完後,已經消耗掉必定時間,再次進入 while 的時候固然也須要從新梳理 taskQueue 了

  • B:shouldYieldToHosthasTimeRemaning 一塊兒斷定了是否還有時間來執行任務,若是沒有的話,break 出 while 循環,由此 保持了一個以 5ms 爲週期的循環調度 ——啊,又解決一個疑問;其中 shouldYieldToHost 的源碼有點兒料的,能夠看看:
if (
    enableIsInputPending &&
    navigator !== undefined &&
    navigator.scheduling !== undefined &&
    navigator.scheduling.isInputPending !== undefined
  ) {
    const scheduling = navigator.scheduling;
    shouldYieldToHost = function() {
      const currentTime = getCurrentTime();
      if (currentTime >= deadline) {
        // There's no time left. We may want to yield control of the main
        // thread, so the browser can perform high priority tasks. The main ones
        // are painting and user input. If there's a pending paint or a pending
        // input, then we should yield. But if there's neither, then we can
        // yield less often while remaining responsive. We'll eventually yield
        // regardless, since there could be a pending paint that wasn't
        // accompanied by a call to `requestPaint`, or other main thread tasks
        // like network events.
        // 譯:沒空了。咱們可能須要將主線程的控制權暫時交出去,所以瀏覽器可以執行高優先級的任務。
        // 所謂的高優先級的任務主要是」繪製「及」用戶輸入」。若是當前有執行中的繪製或者輸入,那麼
        // 咱們就應該讓出資源來讓它們優先的執行;若是沒有,咱們則可讓出更少的資源來保持響應。
        // 可是,畢竟存在非 `requestPaint` 發起的繪製狀態更新,及其餘的主線程任務——如網絡請求等事件,
        // 咱們最終也會在某個臨界點必定地讓出資源來
        if (needsPaint || scheduling.isInputPending()) {
          // There is either a pending paint or a pending input.
          return true;
        }
        // There's no pending input. Only yield if we've reached the max
        // yield interval.
        return currentTime >= maxYieldInterval;
      } else {
        // There's still time left in the frame.
        return false;
      }
    };

    requestPaint = function() {
      needsPaint = true;
    };
  } else {
    // `isInputPending` is not available. Since we have no way of knowing if
    // there's pending input, always yield at the end of the frame.
    shouldYieldToHost = function() {
      return getCurrentTime() >= deadline;
    };

    // Since we yield every frame regardless, `requestPaint` has no effect.
    requestPaint = function() {};
  }

能夠看到,對於支持 navigator.scheduling 屬性的環境,React 有更進一步的考慮,也就是 瀏覽器繪製 和 用戶輸入 要優先進行,這其實就是 React 設計理念中的 Scheduling 部分所闡釋的內涵
固然了,因爲這個屬性並不是廣泛支持,所以也 else 分支裏的定義則是單純的判斷是否超過了 deadline
考慮到 API 的健壯性,requestPaint 也根據狀況有了不一樣的定義

  • C: 咱們仔細看看 continuationCallback 的賦值—— continuationCallback = callback(didUserCallbackTimeout) ,它將任務是否已通過期的狀態傳給了任務自己,若是該任務支持根據過時狀態有不一樣的行爲——例如在過時狀態下,將當前的執行結果緩存起來,等到下次調度未過時的時候再複用緩存的結果繼續執行後面的邏輯,那麼則返回新的處理方式並賦值到 continuationCallback 上。這就是 React 中的 Fiber Reconciler 實現聯繫最緊密的地方了;而 callback 自己若並無對過時狀態進行處理,則返回的東西從邏輯上來說,須要控制爲非函數類型的值,也就是使得 typeof continuationCallback === 'function' 判斷爲假。也正由於 callback 不必定會對過時狀態有特別待遇,因此它的執行時間可能會大大超出預料,就更須要在以後再執行一次 advanceTimers 了。
  • D: 前面說到了,咱們傳入的 callback 必定要遵循與 continuationCallback 相關邏輯一致的規則。因爲 Scheduler 如今還沒有正式的獨立於 React 作推廣,因此也沒有相關文檔來顯式的作講解,所以咱們在直接使用 Scheduler 的時候必定要注意這點
  • E: 其實這裏就是將 timer 中剩下的任務再進行一次梳理,咱們看看 requestHostTimeouthandleTimeout 都作了什麼就知道了:

如今,看 requestHostTimeout 個名字就知道他必定來自於 SchedulerHostConfig.default.js 這個文件🙂:

// 很簡單,就是在下一輪瀏覽器 eventloop 的定時器階段執行回調,若是傳入了具體時間則另說  
requestHostTimeout = function(callback, ms) {
    taskTimeoutID = setTimeout(() => {
      callback(getCurrentTime());
    }, ms);
  };

// 相關的 cancel 方法則是直接 clear 掉定時器並重置 taskTimoutID
cancelHostTimeout = function() {
  clearTimeout(taskTimeoutID);
  taskTimeoutID = -1;
};

再看 handleTimeout,它的定義就在 Scheduler.js 中:

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  // 這裏再次從新梳理了 task
  advanceTimers(currentTime);

  // 若是這時候 isHostCallbackScheduled 再次被設爲 true
  // 說明有新的任務註冊了進來
  // 從邏輯上來看,這些任務將再次被滯後
  if (!isHostCallbackScheduled) {
    // flush 新進入 taskQueue 的任務
    if (peek(taskQueue) !== null) {
      // 若是本方法中的 advanceTimer 有對 taskQueue push 進任務
      // 則直接開始 flush 它們
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    } else {
      // 若是 taskQueue 仍然爲空,就開始遞歸的調用該方法
      // 直到清理掉 timerQueue 中全部的任務
      // (我想,對於交互頻繁的應用,這個遞歸應該不太會有中止的機會)
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        // startTime - currentTime,不就是 XXX_PRIORITY_TIMEOUT 的值嘛!
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

能夠歸納爲是 workLoop 的善後工做...
如今,咱們能夠總結出一個大體的 workLoop 示意圖了:
workLoop.png
Emm x7... 拉得挺長,其實也沒多少內容
至此,Scheduler 的核心運做方式就剖開了
而源碼中還有一些其餘的方法,有些是用於 cancel 掉當前的調度循環(即遞歸過程),有些是提供給開發者使用的工具接口,有興趣的同窗能夠戳這裏進行進一步地瞭解

總結

因爲貼入了大量的源碼,所以本文篇幅也比較長,但其實總得來講就是解釋了兩個問題

postMessage 如何運做?

主要就是經過 performWorkUntilDeadline 這個方法來實現一個遞歸的消息 發送-接收-處理 流程,來實現任務的處理

任務如何被處理?

一切都圍繞着兩個最小優先隊列進行:

  • taskQueue
  • timerQueue

任務被按照必定的優先級規則進行預設,而這些預設的主要目的就是確認執行時機(timeoutForPriorityLevel)。
沒當開始處理一系列任務的時候(flushWork),會產生一個 while 循環(workLoop)來不斷地對隊列中的內容進行處理,這期間還會逐步的將被遞延任務從 timerQueue 中梳理(advanceTimers)到 taskQueue 中,使得任務能按預設的優先級有序的執行。甚至,對於更高階的任務回調實現,還能夠將任務「分段進行」(continuationCallback)。
而穿插在這整個過程當中的一個原則是全部的任務都儘可能不佔用與用戶感知最密切的瀏覽器任務(needsPainiting & isInputPending),固然,這一點能作得多極致也與瀏覽器的實現(navigator.scheduling)有關

總覽

如今,咱們將前面的示意圖都整合起來,並加上兩個隊列的示意,能夠獲得一張大大的運做原理總覽:
scheduler.png啊,真的很大... 其實主要是空白多...總的來講,相比舊的實現(rIC 和 rAF),postMessage 的方式更加獨立,對設備自己的運做流程有了更少的依賴,這不只提高了任務處理的效率,也減小了因不可控因素致使應用出錯的風險,是至關不錯的嘗試。儘管它沒有顯式地對各個 React 應用產生影響,甚至也無須開發者對它有深入的理解,但也許咱們知道了它的運做原理,也就增添了代碼優化及排錯查誤的思路。然而,前面也提到了,這個實現的一些東西目前也正處於試驗階段,所以咱們若是要直接使用 Scheduler 來實現一些東西,也是須要慎重考慮的。Emm x8... 是否是能夠用它來作一些彈幕應用的渲染管理呢——畢竟飛機禮物的通知比純文字的吹水優先級要高吧,貴的禮物要比……哎,有點討打了,拜託忘記破折號後的內容。有興趣的同窗能夠實踐一下,也是幫助 Scheduler 的試驗了~最後,若是有什麼本文理解有誤的地方,還望指出🙏

相關文章
相關標籤/搜索