剖析react核心設計原理--非同步執行排程

有道技術團隊發表於2022-02-25
JS的執行通常在單執行緒的環境中,遇到比較耗時的程式碼時,我們首先想到的是將任務分割,讓它能夠被中斷,同時在其他任務到來的時候讓出執行權,當其他任務執行後,再從之前中斷的部分開始非同步執行剩下的計算。所以關鍵是實現一套非同步可中斷的方案。那麼我們將如何實現一種具備任務分割、非同步執行、而且還能讓出執行權的解決方案呢。React給出了相應的解決方案。

背景

React起源於 Facebook 的內部專案,用來架設 Instagram 的網站,並於 2013 年 5 月開源。該框架主要是一個用於構建使用者介面的 JavaScript 庫,主要用於構建 UI,對於當時雙向資料繫結的前端世界來說,可謂是獨樹一幟。更獨特的是,他在頁面重新整理中引入了區域性重新整理的機制。優點有很多,總結後react的主要特性如下:

1. 1 變換

框架認為 UI 只是把資料通過對映關係變換成另一種形式的資料。同樣的輸入必會有同樣的輸出。這恰好就是純函式。

1.2 抽象

​實際場景中只需要用一個函式來實現複雜的 UI。重要的是,你需要把 UI 抽象成多個隱藏內部細節,還可以使用多個函式。通過在一個函式中呼叫另一個函式來實現複雜的使用者介面,這就是抽象。

1.3 組合

為了達到可重用的特性,那麼每一次組合,都只為他們創造一個新的容器是的。你還需要“其他抽象的容器再次進行組合。”就是將兩個或者多個容器。不同的抽象合併為一個。

React 的核心價值會一直圍繞著目標來做更新這件事,將更新和極致的使用者體驗結合起來,就是 React 團隊一直在努力的事情。

變慢==>升級

隨著應用越來越複雜,React15 架構中,dom diff 的時間超過 16.6ms,就可能會讓頁面卡頓。那麼是哪些因素導致了react變慢,並且需要重構呢。

React15之前的版本中協調過程是同步的,也叫stack reconciler,又因為js的執行是單執行緒的,這就導致了在更新比較耗時的任務時,不能及時響應一些高優先順序的任務,比如使用者在處理耗時任務時輸入頁面會產生卡頓。頁面卡頓的原因大概率由CPU佔用過高產生,例如:渲染一個 React 元件時、發出網路請求時、執行函式時,都會佔用 CPU,而CPU佔用率過高就會產生阻塞的感覺。如何解決這個問題呢?

在我們在日常的開發中,JS的執行通常在單執行緒的環境中,遇到比較耗時的程式碼時,我們首先想到的是將任務分割,讓它能夠被中斷,同時在其他任務到來的時候讓出執行權,當其他任務執行後,再從之前中斷的部分開始非同步執行剩下的計算。所以關鍵是實現一套非同步可中斷的方案。

那麼我們將如何實現一種具備任務分割、非同步執行、而且還能讓出執行權的解決方案呢。React給出了相應的解決方案。

2.1 任務劃分

如何單執行緒的去執行分割後的任務,尤其是在react15中更新的過程是同步的,我們不能將其任意分割,所以react提供了一套資料結構讓他既能夠對映真實的dom也能作為分割的單元。這樣就引出了我們的Fiber。

Fiber

Fiber是React的最小工作單元,在React中,一切皆為元件。HTML頁面上,將多個DOM元素整合在一起可以稱為一個元件,HTML標籤可以是元件(HostComponent),普通的文字節點也可以是元件(HostText)。每一個元件就對應著一個fiber節點,許多fiber節點互相巢狀、關聯,就組成了fiber樹(為什麼要使用連結串列結構:因為連結串列結構就是為了空間換時間,對於插入刪除操作效能非常好),正如下面表示的Fiber樹和DOM的關係一樣:

Fiber樹 DOM樹

   div#root div#root
      | |
    <App/> div
      | / \
     div p a
    / ↖
   / ↖
  p ----> <Child/>
             |
             a

​一個 DOM 節點一定要著一個光纖節點節點,但一個光纖節點卻非常有匹配的 DOM 節點節點。fiber作為工作單元的結構如下:

export type Fiber = {
  // 識別 fiber 型別的標籤。
  tag: TypeOfWork,

  // child 的唯一識別符號。
  key: null | string,

  // 元素的值。型別,用於在協調 child 的過程中儲存身份。
  elementType: any,

  // 與該 fiber 相關的已解決的 function / class。
  type: any,

  // 與該 fiber 相關的當前狀態。
  stateNode: any,

  // fiber 剩餘的欄位

  // 處理完這個問題後要返回的 fiber。
  // 這實際上就是 parent。
  // 它在概念上與堆疊幀的返回地址相同。
  return: Fiber | null,

  // 單連結串列樹結構。
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,

  // 最後一次用到連線該節點的引用。
  ref:
    | null
    | (((handle: mixed) => void) & { _stringRef: ?string, ... })
    | RefObject,

  // 進入處理這個 fiber 的資料。Arguments、Props。
  pendingProps: any, // 一旦我們過載標籤,這種型別將更加具體。
  memoizedProps: any, // 用來建立輸出的道具。

  // 一個狀態更新和回撥的佇列。
  updateQueue: mixed,

  // 用來建立輸出的狀態
  memoizedState: any,

  mode: TypeOfMode,

  // Effect
  effectTag: SideEffectTag,
  subtreeTag: SubtreeTag,
  deletions: Array<Fiber> | null,

  // 單連結串列的快速到下一個 fiber 的副作用。
  nextEffect: Fiber | null,

  // 在這個子樹中,第一個和最後一個有副作用的 fiber。
  // 這使得我們在複用這個 fiber 內所做的工作時,可以複用連結串列的一個片斷。
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,

  // 這是一個 fiber 的集合版本。每個被更新的 fiber 最終都是成對的。
  // 有些情況下,如果需要的話,我們可以清理這些成對的 fiber 來節省記憶體。
  alternate: Fiber | null,
};

瞭解完光纖的結構,那麼光纖與光纖之間是如何並建立的連結串列樹連結的呢。這裡我們引出雙緩衝機制

​在頁面中被重新整理用來渲染使用者介面的樹,被稱為 current,它用來渲染當前使用者介面。每當有更新時,Fiber 會建立一個 workInProgress 樹(佔用記憶體),它是由 React 元素中已經更新資料建立的。React 在這個 workInProgress 樹上執行工作,並在下次渲染時使用這個更新的樹。一旦這個 workInProgress 樹被渲染到使用者介面上,它就成為 current 樹。

在這裡插入圖片描述
2.2 非同步執行

那麼fiber是如何被時間片非同步執行的呢,提供一種思路,示例如下

let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
  //...
  return nextFiber.next
}

function workLoop(deadline){
  while(nextFiber && !shouldYield){
          nextFiber = performUnitOfWork(nextFiber)
          shouldYield = deadline.timeReaming < 1
        }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

我們知道瀏覽器有一個api叫做requestIdleCallback,它可以在瀏覽器空閒的時候執行一些任務,我們用這個api執行react的更新,讓高優先順序的任務優先響應。對於requsetIdleCallback函式,下面是其原理。

​const temp = window.requestIdleCallback(callback[, options]);

對於普通的使用者互動,上一幀的渲染到下一幀的渲染時間是屬於系統空閒時間,Input輸入,最快的單字元輸入時間平均是33ms(通過持續按同一個鍵來觸發),相當於,上一幀到下一幀中間會存在大於16.4ms的空閒時間,就是說任何離散型互動,最小的系統空閒時間也有16.4ms,也就是說,離散型互動的最短幀長一般是33ms。

requestIdleCallback回撥呼叫時機是在回撥註冊完成的上一幀渲染到下一幀渲染之間的空閒時間執行

callback 是要執行的回撥函式,會傳入 deadline 物件作為引數,deadline 包含:

timeRemaining:剩餘時間,單位 ms,指的是該幀剩餘時間。

didTimeout:布林型,true 表示該幀裡面沒有執行回撥,超時了。

options 裡面有個重要引數 timeout,如果給定 timeout,那到了時間,不管有沒有剩餘時間,都會立刻執行回撥
callback。

但事實是requestIdleCallback存在著瀏覽器的相容性和觸發不穩定的問題,所以我們需要用js實現一套時間片執行的機制,在react中這部分叫做scheduler。同時React團隊也沒有看到任何瀏覽器廠商在正向的推動requestIdleCallback的覆蓋程式,所以React只能採用了偏hack的polyfill方案。

requestIdleCallback polyfill 方案( Scheduler )

上面說到requestIdleCallback存在的問題,在react中實現的時間片執行機制叫做scheduler,瞭解時間片的前提是瞭解通用場景下頁面渲染的整個流程被稱為一幀,瀏覽器渲染的一次完整流程大致為

執行JS--->計算Style--->構建佈局模型(Layout)--->繪製圖層樣式(Paint)--->組合計算渲染呈現結果(Composite)

*幀的特性:*

幀的渲染過程是在JS執行流程之後或者說一個事件迴圈之後

幀的渲染過程是在一個獨立的UI執行緒中處理的,還有GPU執行緒,用於繪製3D檢視

幀的渲染與幀的更新呈現是非同步的過程,因為螢幕重新整理頻率是一個固定的重新整理頻率,通常是60次/秒,就是說,渲染一幀的時間要儘可能的低於16.6毫秒,否則在一些高頻次互動動作中是會出現丟幀卡頓的情況,這就是因為渲染幀和重新整理頻率不同步造成的
使用者通常的互動動作,不要求一幀的渲染時間低於16.6毫秒,但也是需要遵循谷歌的RAIL模型的

那麼Polyfill方案是如何在固定幀數內控制任務執行的呢,究其根本是藉助requestAnimationFrame讓一批扁平的任務恰好控制在一塊一塊的33ms這樣的時間片內執行。

Lane

以上是我們的非同步排程策略,但是僅有非同步排程,我們怎麼確定應該排程什麼任務呢,哪些任務應該被先排程,哪些應該被後排程,這就引出了類似於微任務巨集任務的Lane

有了非同步排程,我們還需要細粒度的管理各個任務的優先順序,讓高優先順序的任務優先執行,各個Fiber工作單元還能比較優先順序,相同優先順序的任務可以一起更新

關於lane的設計可以看下這篇:

https://github.com/facebook/r...

應用場景

有了上面所介紹的這樣一套非同步可中斷分配機制,我們就可以實現batchUpdates批量更新等一系列操作:
在這裡插入圖片描述
更新fiber前
在這裡插入圖片描述
更新fiber後

以上除了cpu的瓶頸問題,還有一類問題是和副作用相關的問題,比如獲取資料、檔案操作等。不同裝置效能和網路狀況都不一樣,react怎樣去處理這些副作用,讓我們在編碼時最佳實踐,執行應用時表現一致呢,這就需要react有分離副作用的能力。

設計serve computer

我們都寫過獲取資料的程式碼,在獲取資料前展示loading,資料獲取之後取消loading,假設我們的裝置效能和網路狀況都很好,資料很快就獲取到了,那我們還有必要在一開始的時候展示loading嗎?如何才能有更好的使用者體驗呢?

看下下面這個例子

function getSomething(id) {
  return fetch(`${host}?id=${id}`).then((res)=>{
    return res.param
  })
}

async function getTotalSomething(id1, id2) {
  const p1 = await getSomething(id1);
  const p2 = await getSomething(id2);

  return p1 + p2;
}

async function bundle(){
  await getTotalSomething('001', '002');
}

我們通常可以用async+await的方式獲取資料,但是這會導致呼叫方法變成非同步函式,這就是async的特性,無法分離副作用。

分離副作用,參考下面的程式碼

function useSomething(id) {
  useEffect((id)=>{
      fetch(`${host}?id=${id}`).then((res)=>{
       return res.param
      })
  }, [])
}

function TotalSomething({id1, id2}) {
  const p1 = useSomething(id1);
  const p2 = useSomething(id2);

  return <TotalSomething props={...}>
}

這就是hook解耦副作用的能力。

解耦副作用在函數語言程式設計的實踐中非常常見,例如redux-saga,將副作用從saga中分離,自己不處理副作用,只負責發起請求。

function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

嚴格意義上講react是不支援Algebraic Effects的,但是藉助fiber執行完更新之後交還執行權給瀏覽器,讓瀏覽器決定後面怎麼排程,Suspense也是這種概念的延伸。

const ProductResource = createResource(fetchProduct);

​const Proeuct = (props) => {
    const p = ProductResource.read( // 用同步的方式來編寫非同步程式碼!
          props.id
    );
  return <h3>{p.price}</h3>;
}

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Proeuct id={123} />
      </Suspense>
    </div>
  );
}

可以看到ProductResource.read是同步的寫法,把獲取資料的部分分離出了Product元件之外,原理是ProductResource.read在獲取資料之前會throw一個特殊的Promise,由於scheduler的存在,scheduler可以捕獲這個promise,暫停更新,等資料獲取之後交還執行權。這裡的ProductResource可以是localStorage甚至是redis、mysql等資料庫等。這就是我理解的server componet的雛形。

本文作為react16.5+版本後的核心原始碼內容,淺析了非同步排程分配的機制,瞭解了其中的原理使我們在系統設計以及模型構建的情況下會有較好的大局觀。對於較為複雜的業務場景設計也有一定的輔助作用。這只是react原始碼系列的第一篇,後續會持續更新,希望可以幫到你。

happy hacking~~

相關文章