How React Works (一)首次渲染

lcllao發表於2019-03-04

一、前言

     本文將會通過一個簡單的例子,結合React原始碼(v 16.4.2)來說明 React 是如何工作的,並且幫助讀者理解 ReactElement、Fiber 之間的關係,以及 Fiber 在各個流程的作用。看完這篇文章有助於幫助你更加容易地讀懂 React 原始碼。初期計劃有以下幾篇文章:

  1. 首次渲染
  2. 事件機制
  3. 更新流程
  4. 排程機制

二、核心型別解析

     在正式進入流程講解之前,先了解一下 React 原始碼內部的核心型別,有助於幫助我們更好地瞭解整個流程。為了讓大家更加容易理解,後續的描述只抽取核心部分,把 ref、context、非同步、排程、異常處理 之類的簡化掉了。   

1. ReactElement

  我們寫 React 元件的時候,通常會使用JSX來描述元件。<p></p>這種寫法經過babel轉換後,會變成以 React.createElement(type, props, children)形式。而我們的例子中,type會是兩種型別:functionstringfunction一般是指ReactComponentconstructor或者函式式元件的function,而string型別的就是HTML標籤。

  這個方法,最終是會返回一個 ReactElement ,他是一個普通的 Object ,不是通過某個 class 例項化而來的,大概看看即可,核心成員如下:

key type desc
$$typeof Symbol|Number 物件型別標識,用於判斷當前Object是否一個某種型別的ReactElement
type Function|String|Symbol|Number|Object 如果當前ReactElement是一個ReactComponent,那這裡將是它對應的Constructor;而普通HTML標籤,一般都是String
props Object ReactElement上的所有屬性,包含children這個特殊屬性

2. ReactRoot

  當前放在ReactDom.js內部,可以理解為React渲染的入口。我們呼叫ReactDom.render之後,核心就是建立一個 ReactRoot ,然後呼叫 ReactRoot 例項的render方法,進入渲染流程的。

key type desc
render Function 渲染入口方法
_internalRoot FiberRoot 根據當前DOMContainer建立的一個FiberTree的根

3. FiberRoot

  FiberRoot 是一個 Object ,是後續初始化、更新的核心根物件。核心成員如下:

key type desc
current (HostRoot)FiberNode 指向當前已經完成的Fiber Tree 的Root
containerInfo DOMContainer React的DOM容器,把整個React渲染到這個DOM內部
finishedWork (HostRoot)FiberNode|null 指向當前已經完成準備工作的Fiber Tree Root

current、finishedWork,都是一個(HostRoot)FiberNode,到底是為什麼呢?先賣個關子,後面將會講解。

4. FiberNode

  在 React 16之後,Fiber Reconciler 就作為 React 的預設排程器,核心資料結構就是由FiberNode組成的 Node Tree 。先參觀下他的核心成員:

key type desc
例項相關 --- ---
tag Number FiberNode的型別,可以在packages/shared/ReactTypeOfWork.js中找到。當前文章 demo 可以看到ClassComponent、HostRoot、HostComponent、HostText這幾種
type Function|String|Symbol|Number|Object 和ReactElement表現一致
stateNode FiberRoot|DomElement|ReactComponentInstance FiberNode會通過stateNode繫結一些其他的物件,例如FiberNode對應的Dom、FiberRoot、ReactComponent例項
Fiber遍歷流程相關
return FiberNode|null 表示父級 FiberNode
child FiberNode|null 表示第一個子 FiberNode
sibling FiberNode|null 表示緊緊相鄰的下一個兄弟 FiberNode
alternate FiberNode|null Fiber排程演算法採取了雙緩衝池演算法,FiberRoot底下的所有節點,都會在演算法過程中,嘗試建立自己的“映象”,後面將會繼續講解
資料相關
pendingProps Object 表示新的props
memoizedProps Object 表示經過所有流程處理後的新props
memoizedState Object 表示經過所有流程處理後的新state
副作用描述相關
updateQueue UpdateQueue 更新佇列,佇列內放著即將要發生的變更狀態,詳細內容後面再講解
effectTag Number 16進位制的數字,可以理解為通過一個欄位標識n個動作,如Placement、Update、Deletion、Callback……所以原始碼中看到很多 &=
firstEffect FiberNode|null 與副作用操作遍歷流程相關 當前節點下,第一個需要處理的副作用FiberNode的引用
nextEffect FiberNode|null 表示下一個將要處理的副作用FiberNode的引用
lastEffect FiberNode|null 表示最後一個將要處理的副作用FiberNode的引用

5. Update

  在排程演算法執行過程中,會將需要進行變更的動作以一個Update資料來表示。同一個佇列中的Update,會通過next屬性串聯起來,實際上也就是一個單連結串列。

key type desc
tag Number 當前有0~3,分別是UpdateState、ReplaceState、ForceUpdate、CaptureUpdate
payload Function|Object 表示這個更新對應的資料內容
callback Function 表示更新後的回撥函式,如果這個回撥有值,就會在UpdateQueue的副作用連結串列中掛在當前Update物件
next Update UpdateQueue中的Update之間通過next來串聯,表示下一個Update物件

6. UpdateQueue

  在 FiberNode 節點中表示當前節點更新的副作用(主要是Callback)的集合,下面的結構省略了CapturedUpdate部分

key type desc
baseState Object 表示更新前的基礎狀態
firstUpdate Update 第一個 Update 物件引用,總體是一條單連結串列
lastUpdate Update 最後一個 Update 物件引用
firstEffect Update 第一個包含副作用(Callback)的 Update 物件的引用
lastEffect Update 最後一個包含副作用(Callback)的 Update 物件的引用

三、程式碼樣例

  本次流程說明,使用下面的原始碼進行分析


//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));



//App.js
import React, { Component } from 'react';
import './App.css';

class App extends Component {
  constructor() {
    super();
    this.state = {
      msg:'init',
    };
  }
  render() {
    return (
      <div className="App">
        <p className="App-intro">
          To get started, edit <code>{this.state.msg}</code> and save to reload.
        </p>
        <button onClick={() => {
          this.setState({msg: 'clicked'});
        }}>hehe
        </button>
      </div>
    );
  }

}

export default App;
複製程式碼

四、渲染排程演算法 - 準備階段

  從ReactDom.render方法開始,正式進入渲染的準備階段。

1. 初始化基本節點

  建立 ReactRoot、FiberRoot、(HostRoot)FiberNode,建立他們與 DOMContainer 的關係。

How React Works (一)首次渲染

2. 初始化(HostRoot)FiberNodeUpdateQueue

  通過呼叫ReactRoot.render,然後進入packages/react-reconciler/src/ReactFiberReconciler.jsupdateContainer -> updateContainerAtExpirationTime -> scheduleRootUpdate一系列方法呼叫,為這次初始化建立一個Update,把<App />這個 ReactElement 作為 Update 的payload.element的值,然後把 Update 放到 (HostRoot)FiberNode 的 updateQueue 中。

How React Works (一)首次渲染

然後呼叫scheduleWork -> performSyncWork -> performWork -> performWorkOnRoot,期間主要是提取當前應該進行初始化的 (HostFiber)FiberNode,後續正式進入演算法執行階段。

五、渲染排程演算法 - 執行階段

  由於本次是初始化,所以需要呼叫packages/react-reconciler/src/ReactFiberScheduler.jsrenderRoot方法,生成一棵完整的FiberNode Tree finishedWork

1. 生成 (HostRoot)FiberNode 的workInProgress,即current.alternate

  在整個演算法過程中,主要做的事情是遍歷 FiberNode 節點。演算法中有兩個角色,一是表示當前節點原始形態的current節點,另一個是表示基於當前節點進行重新計算的workInProgress/alternate節點。兩個物件例項是獨立的,相互之前通過alternate屬性相互引用。物件的很多屬性都是先複製再重建的。

第一次建立結果示意圖:

How React Works (一)首次渲染

  這個做法的核心思想是雙緩池技術(double buffering pooling technique),因為需要做 diff 的話,起碼是要有兩棵樹進行對比。通過這種方式,可以把樹的總體數量限制在2,節點、節點屬性都是延遲建立的,最大限度地避免記憶體使用量因演算法過程而不斷增長。後面的更新流程的文章裡,會了解到這個雙緩衝怎麼玩。

2. 工作執行迴圈

示意程式碼如下:

nextUnitOfWork = createWorkInProgress(
  nextRoot.current,
  null,
  nextRenderExpirationTime,
);
....

while (nextUnitOfWork !== null) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
複製程式碼

剛剛建立的 FiberNode 被作為nextUnitOfWork,從此進入工作迴圈。從上面的程式碼可以看出,在是一個典型的遞迴的迴圈寫法。這樣寫成迴圈,一來就是和傳統的遞迴改迴圈寫法一樣,避免呼叫棧不斷堆疊以及呼叫棧溢位等問題;二來在結合其他Scheduler程式碼的輔助變數,可以實現遍歷隨時終止、隨時恢復的效果。

我們繼續深入performUnitOfWork函式,可以看到類似的程式碼框架:

const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
    next = completeUnitOfWork(workInProgress);
}
//...
return next;
複製程式碼

從這裡可以看出,這裡對 workInProgress 節點進行一些處理,然後會通過一定的遍歷規則返回next,如果next不為空,就返回進入下一個performUnitOfWork,否則就進入completeUnitOfWork

3. beginWork

  每個工作的物件主要是處理workInProgress。這裡通過workInProgress.tag區分出當前 FiberNode 的型別,然後進行對應的更新處理。下面介紹我們例子裡面可以遇到的兩種處理比較複雜的 FiberNode 型別的處理過程,然後再單獨講解裡面比較重要的processUpdateQueue以及reconcileChildren過程。

3.1 HostRoot - updateHostRoot

  HostRoot,即文中經常講到的 (HostRoot)FiberNode,表示它是一個 HostRoot 型別的 FiberNode ,程式碼中通過FiberRoot.tag表示。

  前面講到,在最開始初始化的時候,(HostRoot)FiberNode 在初始化之後,初始化了他的updateQueue,裡面放了準備處理的子節點。這裡就做兩個動作:

  • 處理更新佇列,得出新的state - processUpdateQueue方法
  • 建立或者更新 FiberNode 的child,得到下一個工作迴圈的入參(也是FiberNode) - ChildReconciler方法

  通過這兩個函式的詳細內容屬於比較通用的部分,將在後面單獨講解。

3.2 ClassComponent - updateClassComponent

  ClassComponent,即我們在寫 React 程式碼的時候自己寫的 Component,即例子中的App

3.2.1 建立ReactComponent例項階段

  對於尚未初始化的節點,這個方法主要是通過FiberNode.type這個 ReactComponent Constructor 來建立 ReactComponent 例項並建立與 FiberNode 的關係。

(ClassComponent)FiberNode 與 ReactComponent 的關係示意圖:

How React Works (一)首次渲染

  初始化後,會進入例項的mount過程,即把 Component render之前的週期方法都呼叫完。期間,state可能會被以下流程修改:

  • 呼叫getDerivedStateFromProps
  • 呼叫componentWillMount -- deprecated
  • 處理因上面的流程產生的Update所呼叫的processUpdateQueue
3.2.2 完成階段 - 建立 child FiberNode

  在上面初始化Component例項之後,通過呼叫例項的render獲取子 ReactElement,然後建立對應的所有子 FiberNode 。最終將workInProgress.child指向第一個子 FiberNode。

3.4 處理節點的更新佇列 - processUpdateQueue 方法

  在解釋流程之前,先回顧一下updateQueue的資料結構:

How React Works (一)首次渲染

  從上面的結構可以看出,UpdateQueue 是存放整個 Update 單向連結串列的容器。裡面的 baseState 表示更新前的原始 State,而通過遍歷各個 Update 連結串列後,最終會得到一個新的 baseState。

  對於單個 Update 的處理,主要是根據Update.tag來進行區分處理。

  • ReplaceState:直接返回這裡的 payload。如果 payload 是函式,則使用它的返回值作為新的 State。
  • CaptureUpdate:僅僅是將workInProgress.effectTag設定為清空ShouldCapture標記位,增加DidCapture標記位。
  • UpdateState:如果payload是普通物件,則把他當做新 State。如果 payload 是函式,則把執行函式得到的返回值作為新 State。如果新 State 不為空,則與原來的 State 進行合併,返回一個新物件
  • ForceUpdate:僅僅是設定 hasForceUpdate為 true,返回原始的 State。

  整體而言,這個方法要做的事情,就是遍歷這個 UpdateQueue ,然後計算出最後的新 State,然後存到workInProgress.memoizedState中。

3.5 處理子FiberNode - reconcileChildren 方法

  在 workInProgress 節點自身處理完成之後,會通過props.children或者instance.render方法獲取子 ReactElement。子 ReactElement 可能是物件陣列字串迭代器,針對不同的型別進行處理。

  • 下面通過 ClassComponent 及其 陣列型別 child的場景來講解子 FiberNode 的建立、關聯流程(reconcileChildrenArray方法):

  在頁面初始化階段,由於沒有老節點的存在,流程上就略過了位置索引比對、兄弟元素清理等邏輯,所以這個流程相對簡單。

  遍歷之前render方法生成的 ReactElement 陣列,一一對應地生成 FiberNode。FiberNode 有returnFiber屬性和sibling屬性,分別指向其父親 FiberNode和緊鄰的下一個兄弟 FiberNode。這個資料結構和後續的遍歷過程相關。

  現在,生成的FiberNode Tree 結構如下:

How React Works (一)首次渲染

  圖中的兩個(HostComponent)FiberNode就是剛剛生成的子 FiberNode,即原始碼中的<p>...</p><button>...</button>。這個方法最後返回的,是第一個子 FiberNode,就通過這種方式建立了(ClassComponent)FiberNode.child與第一個子 FiberNode的關係。

  這個時候,再搬出剛剛曾經看過的程式碼:

const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
    next = completeUnitOfWork(workInProgress);
}
//...
return next;
複製程式碼

  意味著剛剛返回的 child 會被當做 next 進入下一個工作迴圈。如此往復,會得到下面這樣的 FiberNode Tree :

How React Works (一)首次渲染

  生成這棵樹之後,被返回的是左下角的那個 (HostText)FiberNode。而重新進入beginWork方法後,由於這個 FiberNode 並沒有 child ,根據上面的程式碼邏輯,會進入completeUnitOfWork方法。

注意:雖然說本例子的 FiberNode Tree 最終形態是這樣子的,但實際上演算法是優先深度遍歷,到葉子節點之後再遍歷緊鄰的兄弟節點。如果兄弟節點有子節點,則會繼續擴充套件下去。

4. completeUnitOfWork

  進入這個流程,表明 workInProgress 節點是一個葉子節點,或者它的子節點都已經處理完成了。現在開始要完成這個節點處理的剩餘工作。   

4.1 建立DomElement,處理子DomElement 繫結關係

completeWork方法中,會根據workInProgress.tag來區分出不同的動作,下面挑選2個比較重要的來進一步分析:

4.1.1 HostText

  此前提到過,FiberNode.stateNode可以用於存放 DomElement Instance。在初始化過程中,stateNode 為 null,所以會通過document.createTextNode建立一個 Text DomElement,節點內容就是workInProgress.memoizedProps。最後,通過__reactInternalInstance$[randomKey]屬性建立與自己的 FiberNode的聯絡。

How React Works (一)首次渲染

4.1.2 HostComponent

  在本例子中,處理完上面的 HostText 之後,排程演算法會尋找當前節點的 sibling 節點進行處理,所以進入了HostComponent的處理流程。

  由於當前出於初始化流程,所以處理比較簡單,只是根據FiberNode.tag(當前值是code)來建立一個 DomElement,即通過document.createElement來建立節點。然後通過__reactInternalInstance$[randomKey]屬性建立與自己的 FiberNode的聯絡;通過__reactEventHandlers$[randomKey]來建立與 props 的聯絡。

  完成 DomElement 自身的建立之後,如果有子節點,則會將子節點 append 到當前節點中。現在先略過這個步驟。

  後續,通過setInitialProperties方法對 DomElement 的屬性進行初始化,而<code>節點的內容、樣式、class、事件 Handler等等也是這個時候存放進去的。

  現在,整個 FiberNode Tree 如下:

How React Works (一)首次渲染

  經過多次迴圈處理,得出以下的 FiberNode Tree:

How React Works (一)首次渲染

  之後,回到紅色箭頭指向的 (HostComponent)FiberNode,可以分析一下之前省略掉的子節點處理流程。

  在當前 DomElement 建立完畢後,進入appendAllChildren方法把子節點 append 到當前 DomElement 。由上面的流程可以知道,可以通過 workInProgress.child -> workInProgress.child.sibling -> workInProgress.child.sibling.sibling ....找到所有子節點,而每個節點的 stateNode 就是對應的 DomElement,所以通過這種方式的遍歷,就可以把所有的 DomElement 掛載到 父 DomElement中。

  最終,和 DomElement 相關的 FiberNode 都被處理完,得出下面的FiberNode 全貌:

How React Works (一)首次渲染

4.2 將當前節點的 effect 掛在到 returnFiber 的 effect 末尾

  在前面講解基礎資料結構的時候描述過,每個 FiberNode 上都有 firstEffect、lastEffect ,指向一個Effect(副作用) FiberNode連結串列。在處理完當前節點,即將返回父節點的時候,把當前的鏈條掛接到 returnFiber 上。最終,在(HostRoot)FiberNode.firstEffect 上掛載著一條擁有當前 FiberNode Tree 所有副作用的 FiberNode 連結串列。

5. 執行階段結束

  經歷完之前的所有流程,最終 (HostRoot)FiberNode 也被處理完成,就把 (HostRoot)FiberNode 返回,最終作為finishedWork返回到 performWorkOnRoot,後續進入下一個階段。

六、渲染排程演算法 - 提交階段

  所謂提交階段,就是實際執行一些周期函式、Dom 操作的階段。

  這裡也是一個連結串列的遍歷,而遍歷的就是之前階段生成的 effect 連結串列。在遍歷之前,由於初始化的時候,由於 (HostRoot)FiberNode.effectTagCallback(初始化回撥)),會先將 finishedWork 放到連結串列尾部。結構如下:

How React Works (一)首次渲染

每個部分提交完成之後,都會把遍歷節點重置到finishedWork.firstEffect

1. 提交節點裝載( mount )前的操作

  當前這個流程處理的只有屬於 ReactComponent 的 getSnapshotBeforeUpdate方法。   

2. 提交端原生節點( Host )的副作用(插入、修改、刪除)

  遍歷到某個節點後,會根據節點的 effectTag 決定進行什麼操作,操作包括插入( Placement )修改( Update )刪除( Deletion )

  由於當前是首次渲染,所以會進入插入( Placement )流程,其餘流程將在後面的《How React Works(三)更新流程》中講解。

2.1 插入流程( Placement )

  要做插入操作,必先找到兩個要素:父親 DomElement ,子 DomElement。

2.1.1 找到相對於當前 FiberNode 最近的父親 DomElement

  通過FiberNode.return不斷往上找,找到最近的(HostComponent)FiberNode、(HostRoot)FiberNode、(HostPortal)FiberNode節點,然後通過(HostComponent)FiberNode.stateNode(HostRoot)FiberNode.stateNode.containerInfo(HostPortal)FiberNode.stateNode.containerInfo就可以獲取到對應的 DomElement 例項。   

2.1.2 找到相對於當前 FiberNode 最近的所有遊離子 DomElement

  實際上,把目標是查詢當前 FiberNode底下所有鄰近的 (HostComponent)FiberNode、(HostText)FiberNode,然後通過 stateNode 屬性就可以獲取到待插入的 子DomElement 。

  所謂所有鄰近的,可以通過這幅圖來理解:

How React Works (一)首次渲染

  圖中紅框部分FiberNode.stateNode,就是要被新增到父親 DomElement的 子 DomElement。

  遍歷順序,和之前的生成 FiberNode Tree時順序大致相同:

a) 訪問child節點,直至找到 FiberNode.type 為 HostComponent 或者 HostRoot 的節點,獲取到對應的 stateNode ,append 到 父 DomElement中。

b) 尋找兄弟節點,如果有,就訪問兄弟節點,返回 a) 。

c) 如果沒有兄弟節點,則訪問 return 節點,如果 return 不是當前演算法入參的根節點,就返回a)。

d) 如果 return 到根節點,則退出。

3. 改變 workInProgress/alternate/finishedWork 的身份

  雖然是短短的一行程式碼,但這個十分重要,所以單獨標記:

    root.current = finishedWork;
複製程式碼

  這意味著,在 DomElement 副作用處理完畢之後,意味著之前講的緩衝樹已經完成任務,翻身當主人,成為下次修改過程的current。再來看一個全貌:

How React Works (一)首次渲染

4. 提交裝載、變更後的生命週期呼叫操作

  在這個流程中,也是遍歷 effect 連結串列,對於每種型別的節點,會做不同的處理。

4.1 ClassComponent

  如果當前節點的 effectTag 有 Update 的標誌位,則需要執行對應例項的生命週期方法。在初始化階段,由於當前的 Component 是第一次渲染,所以應該執行componentDidMount,其他情況下應該執行componentDidUpdate

  之前講到,updateQueue 裡面也有 effect 連結串列。裡面存放的就是之前各個 Update 的 callback,通常就來源於setState的第二個引數,或者是ReactDom.rendercallback。在執行完上面的生命週期函式後,就開始遍歷這個 effect 連結串列,把 callback 都執行一次。

4.2 HostRoot

  操作和 ClassComponent 處理的第二部分一致。

4.3 HostComponent

  這部分主要是處理初次載入的 HostComponent 的獲取焦點問題,如果元件有autoFocus這個 props ,就會獲取焦點。      

七、小結

  本文主要講述了ReactDom.render的內部的工作流程,描述了 React 初次渲染的內在流程:

  1. 建立基礎物件: ReactRoot、FiberRoot、(HostRoot)FiberNode
  2. 建立 HostRoot 的映象,通過映象物件來做初始化
  3. 初始化過程,通過 ReactElement 引導 FiberNode Tree 的建立
  4. 父子 FiberNode 通過childreturn連線
  5. 兄弟 FiberNode 通過sibling連線
  6. FiberNode Tree 建立過程,深度優先,到底之後建立兄弟節點
  7. 一旦到達葉子節點,就開始建立 FiberNode 對應的 例項,例如對應的 DomElement 例項、ReactComponent 例項,並將例項通過FiberNode.stateNode建立關聯。
  8. 如果當前建立的是 ReactComponent 例項,則會呼叫呼叫getDerivedStateFromPropscomponentWillMount方法
  9. DomElement 建立之後,如果 FiberNode 子節點中有建立好的 DomElement,就馬上 append 到新建立的 DomElement 中
  10. 構建完成整個FiberNode Tree 後,對應的 DomElement Tree 也建立好了,後續進入提交過程
  11. 在建立 DomElement Tree 的過程中,同時會把當前的副作用不斷往上傳遞,在提交階段裡面,會找到這種標記,並把剛建立完的 DomElement Tree 裝載到容器 DomElement中
  12. 雙緩衝的兩棵樹 FiberNode Tree 角色互換,原來的 workInProgress 轉正
  13. 執行對應 ReactComponent 的裝載後生命週期方法componentDidMount
  14. 其他回撥呼叫、autoFocus 處理

 下一篇文章將會描述 React 的事件機制(但據說準備要重構),希望我不會斷耕。

寫完第一篇,React 版本已經到了 16.5.0 ……

相關文章