徹底搞懂 React 18 併發機制的原理

React 18 最主要的特性就是併發了,很多 api 都是基於併發特性實現的。

那爲什麼 React 要實現併發?什麼是併發?又是怎麼實現的呢?

這篇文章我們就一起來探究一下。

首先,我們過一遍 React 渲染的流程:

React 渲染流程

React 是通過 JSX 描述頁面的,JSX 編譯成 render function(也就是 React.createElement 等),執行之後產生 vdom。

vdom 是指 React Element 的對象樹:

之後這個 vdom 會轉換爲 fiber 結構:

vdom 是通過 children 關聯子節點,而 fiber 通過 child、sibling、return 關聯了父節點、子節點、兄弟節點。

從 vdom 轉 fiber 的過程叫做 reconcile,這個過程還會創建用到的 dom 節點,並且打上增刪改的標記。

這個 reconcile 的過程叫做 render 階段。

之後 commit 階段會根據標記來增刪改 dom。

commit 階段也分爲了 3 個小階段,before mutation、mutation、layout。

mutation 階段會增刪改 dom,before mutation 是在 dom 操作之前,layout 是在 dom 操作之後。

所以 ref 的更新是在 layout 階段。useEffect 和 useLayoutEffect 的回調也都是在 layout 階段執行的,只不過 useLayoutEffect 的回調是同步執行,而 useEffect 的回調是異步執行。

綜上,React 整體的渲染流程就是 render(reconcile 的過程) + commit(執行增刪改 dom 和 effect、生命週期函數的執行、ref 的更新等)。

當你 setState 之後,就會觸發一次渲染的流程,也就是上面的 render + commit。

當然,除了 setState 之外,入口處的 ReactDOM.render 還有函數組件裏的 useState 也都能觸發渲染。

那麼問題來了,如果同時有多個 setState 觸發的渲染,怎麼處理呢?

同步 vs 併發

每次 setState 都會進行上面的那個 render + commit 的渲染流程,多次那就順序處理不就行了?

這樣是能滿足功能的,也就是同步模式。

但是有個問題,比如用戶在 input 輸入內容的時候,會通過 setState 設置到狀態裏,會觸發重新渲染。

這時候如果還有一個列表也會根據 input 輸入的值來處理顯示的數據,也會 setState 修改自己的狀態。

這兩個 setState 會一起發生,那麼同步模式下也就會按照順序依次執行。

但如果這個渲染流程中處理的 fiber 節點比較多,渲染一次就比較慢,這時候用戶輸入的內容可能就不能及時的渲染出來,用戶就會感覺卡,體驗不好。

怎麼解決這個問題呢?

能不能指定這倆 setState 的重要程度不一樣,用戶輸入的 setState 的更新重要程度更高,如果有這種更新就把別的先暫停,執行這次更新,執行完之後再繼續處理。

React 18 裏確實實現了這樣一套併發的機制,這裏的重要程度就是優先級,也就是基於優先級的可打斷的渲染流程。

React 會把 vdom 樹轉成 fiber 鏈表,因爲 vdom 裏只有 children,沒有 parent、sibling 信息,而 fiber 節點裏有,這樣就算打斷了也可以找到下一個節點繼續處理。fiber 結構就是爲實現併發而準備的。

按照 child、sibling、sibling、return、sibling、return 之類的遍歷順序,可以把整個 vdom 樹變成線性的鏈表結構,一個循環就可以處理完。

循環處理每個 fiber 節點的時候,有個指針記錄着當前的 fiber 節點,叫做 workInProgress。

這個循環叫做 workLoop:

當然,上面這個是同步模式下的循環。

那併發模式下呢?

首先,併發和並行不一樣,並行是同一時刻多件事情同時進行,而併發是隻要一段時間內同時發生多件事情就行。

併發是通過交替執行來實現的,也就是這樣:

上面是兩個 setState 引起的兩個渲染流程,先處理上面那次渲染的 1、2、3 的 fiber 節點,然後處理下面那次渲染的 1、2、3、4、5、6 的 fiber 節點,之後繼續處理上面那次渲染的 4、5、6 的 fiber 節點。

這就是併發。

也就是在循環裏多了個打斷和恢復的機制,所以代碼是這樣的:

每處理一個 fiber 節點,都判斷下是否打斷,shouldYield 返回 true 的時候就終止這次循環。

那怎麼恢復呢?

每次 setState 引起的渲染都是由 Scheduler 調度執行的,它維護了一個任務隊列,上個任務執行完執行下個。

沒渲染完的話,再加一個新任務進去不就行了?

判斷是否是被中斷的還是已經渲染完了,這個也很簡單,當全部 fiber 節點都渲染完,那 workInProgress 的指針就是 null 了。

而如果是渲染到一半 yield 的,那 wip 就不是 null。

所以可以這樣根據 wip 是否是 null 判斷是否是中斷了:

然後把剩下的節點 schdule 就好了。當再次 schedule 到這個任務,就會繼續渲染。

這就是併發模式的實現,也就是在 workLoop 裏通過 shouldYield 的判斷來打斷渲染,之後把剩下的節點加入 Schedule 調度,來恢復渲染。

那 shouldYield 是根據什麼來打斷的呢?

根據過期時間,每次開始處理時記錄個時間,如果處理完這個 fiber 節點,時間超了,那就打斷。

那優先級呢?不會根據任務優先級打斷麼?

並不會,優先級高低會影響 Scheduler 裏的 taskQueue 的排序結果,但打斷只會根據過期時間。

也就是時間分片的含義。

那這樣就算併發了,不還是高優先級任務得不到即使執行?

那不會,因爲一個時間分片是 5ms,所以按照按優先級排序好的任務順序來執行,就能讓高優先級任務得到及時處理。

這個地方也是很多同學的誤區,react 的併發模式的打斷只會根據時間片,也就是每 5ms 就打斷一次,並不會根據優先級來打斷,優先級只會影響任務隊列的任務排序。

那具體都有哪些優先級呢?

react 裏的優先級

首先,上面談到的優先級是調度任務的優先級,有這 5 種:

Immediate 是離散的一些事件,比如 click、keydown、input 這種。

UserBlocking 是連續的一些事件,比如 scroll、drag、mouseover 這種。

react 是這麼劃分的,離散的事件比連續事件優先級更高,這個倒是很容易理解。

然後是默認的優先級 NormalPriority、再就是低優先級 LowPriority,空閒優先級 IdlePriority。

Scheduler 會根據任務的優先級對任務排序來調度。

併發模式下不同的 setState 的優先級不同,就是通過指定 Scheduler 的優先級實現的。

但在 React 裏優先級不是直接用這個。

因爲 Schduler 是分離的一個包了,它的優先級機制也是獨立的。

而且 React 有自己的一套優先級機制,那個分類可不止上面這 5 種,足足有 31 種,React 的那套優先級機制叫做 Lane。

31 種?那就是從 0 到 31 的數字唄 ?

並不是,react 是通過二機制的方式來保存不同優先級的:

這樣設計的好處,自然是可以用二進制運算快速得到是哪種優先級了:

比如按位與、按位或等:

這樣性能會更好一點,位運算的性能肯定是最高的。

不過不好的地方是看這樣的代碼會繞一點。

那爲啥就 Lane 呢?

Lane 是賽道的意思,看二進制的這種表示方式:

是不是就很像賽道:

這就是爲啥 react 的優先級機制叫 Lane,就是形象地表示了這種二進制的優先級存儲方式。

除了 react 的 lane 的優先級機制外,react 還給事件也區分了優先級:

事件的優先級會轉化爲 react 的 Lane 優先級,Lane 的優先級也可以轉化爲事件優先級。

那 react 通過 Scheduler 調度任務的時候,優先級是怎麼轉呢?

先把 Lane 轉換爲事件優先級,然後再轉爲 Scheduler 優先級。

爲什麼呢?

因爲 Lane 的優先級有 31 個啊,而事件優先級那 4 個剛好和 Scheduler 的優先級對上。

怎麼實現的 Lane 優先級轉 Event 優先級,那就是有幾個分界點了:

也就是說,react 內部有 31 種 Lane 優先級,但是調度 Scheduler 任務的時候,會先轉成事件優先級,然後再轉成 Scheduler 的 5 種優先級。

知道了時間分片和優先級機制,那我們對 react 的併發模式的實現原理也就算比較瞭解了。

接下來看一些基於併發模式實現的 api:

useTransition、useDeferredValue

前面介紹了兩種 workLoop 的執行方式:

同步執行:

併發執行:

所謂的併發執行就是加了個 5ms 一次的時間分片。

react18 裏同時存在着這兩種循環方式,普通的循環和帶時間分片的循環。

也不是所有的特性都要時間分片,只有部分需要。

那就如果這次 setState 更新裏包含了併發特性,就是用 workLoopConcurrent,否則走 workLooSync 就好了。

如上,react 會根據 lane 來判斷是否要開啓時間分片。

看到這其實就能理解什麼是併發特性的 api 了。

所有能設置開啓時間分片的 lane 的 api 都是基於併發的 api。

比如 startTransition、useTransition、useDeferredValue 這些。

我們知道併發特性是可以給不同的 setState 標上不同的優先級的,怎麼標呢?

就通過 trasition 的 api:

import React, { useTransition, useState } from "react";

export default function App() {
  const [text, setText] = useState('guang');
  const [text2, setText2] = useState('guang2');

  const [isPending, startTransition] = useTransition()

  const handleClick = () ={
    startTransition(() ={
      setText('dong');
    });

    setText2('dong2');
  }

  return (
    <button onClick={handleClick}>{text}{text2}</button>
  );
}

比如上面有兩個 setState,其中一個優先級高,另一個優先級低,那就把低的那個用 startTransition 包裹起來。

就可以實現高優先級的那個優先渲染。

怎麼實現的呢?

我們來看下源碼:

源碼裏是在調用回調函數之前設置了更新的優先級爲 ContinuousEvent 的優先級,也就是連續事件優先級,比 DiscreteEvent 離散事件優先級更低,所以會比另一個 setState 觸發的渲染的優先級低,在調度的時候排在後面。

這裏設置的其實就是 Lane 的優先級:

那渲染的時候就會走 workLoopConcurrent 的帶時間分片的循環,然後通過 Scheduler 對任務按照優先級排序,就實現了高優先級的渲染先執行的效果。

這就是 startTransition、useTransition 的用法和原理。

在就是 useDeferredValue 的 api,它的應用場景是這樣的:

比如這樣一段代碼:

function App() {
  const [text, setText] = useState("");

  const handleChange = (e) ={
    setText(e.target.value);
  };

  return (
    <div>
      <input value={text} onChange={handleChange}/>
      <List text={text}/>
    </div>
  );
};

List 裏是根據輸入的 text 來過濾結果展示的,現在每次輸入都會觸發渲染。

我們希望在內容輸入完了再處理通知 List 渲染,就可以這樣:

function App() {
  const [text, setText] = useState("");
  const deferredText = useDeferredValue(text);

  const handleChange = (e) ={
    setText(e.target.value);
  };

  return (
    <div>
      <input value={text} onChange={handleChange}/>
      <List text={deferredText}/>
    </div>
  );
};
function

對 state 用 useDeferredValue 包裹之後,新的 state 就會放到下一次更新。

這部分的源碼看 react17 的比較容易理解:

react 17 裏就是通過 useEffect 把這個值的更新時機延後了:

也就是其他的 setState 觸發的 render 處理完了之後,在 commit 階段去 setState,這就是 DeferedValue 的意思。

react 18 裏也有這個 api,雖然功能一樣,但實現變了,現在是基於併發模式的,通過 Lane 的優先級實現的延後更新。

這倆都是基於併發機制,也就是基於 Lane 的優先級實現的 api。當用到這些 api 的時候,react 纔會啓用 workLoopConcurrent 帶時間分片的循環。

總結

react 的渲染流程是 render + commit。render 階段實現 vdom 轉 fiber 的 reconcile,之後 commit 階段執行增刪改 dom,更新 ref、調用 effect 回調和生命週期函數等。

多次 setState 會引起多個渲染流程,這之間可能有重要程度的不同,也就是優先級的不同。

爲了讓高優先級的更新能先渲染,react 實現了併發模式。

同步模式是循環處理 fiber 節點,併發模式多了個 shouldYield 的判斷,每 5ms 打斷一次,也就是時間分片。並且之後會重新調度渲染。

通過這種打斷和恢復的方式實現了併發。

然後 Scheduler 可以根據優先級來對任務排序,這樣就可以實現高優先級的更新先執行。

react 裏有 Lane 的優先級機制,基於二進制設計的。它和事件的優先級機制、Scheduler 的優先級機制能夠對應上。調度任務的時候先把 Lane 轉事件優先級,然後轉 Scheduler 的優先級。

react18 的 useTransition、useDeferredValue 都是基於併發特性實現的,useTransition 是把回調函數里的更新設置爲連續事件的優先級,比離散事件的優先級低。useDeferredValue 則是延後更新 state 的值。

這些併發特性的 api 都是通過設置 Lane 實現的,react 檢測到對應的 Lane 就會開啓帶有時間分片的 workLoopConcurrent 循環。

這就是 React 併發機制的實現原理。

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