React源碼揭祕1 架構設計與首屏渲染

歡迎訂閱  React技術揭祕  代碼參照React 16.13.1 。

和其餘React教程有何不一樣?

假設React是你平常開發的框架,在日復一日的開發中,你萌生了學習React源碼的念頭,在網上一頓搜索後,你發現這些教程能夠分爲2類:javascript

  1. 《xx行代碼帶你實現迷你React》,《xx行代碼實現React hook》這樣短小精幹的文章。若是你只是想花一點點時間瞭解下React的工做原理,我向你推薦 這篇文章,很是精彩。html

  2. 《React Fiber原理》,《React expirationTime原理》這樣摘錄React源碼講解的文章。若是你想學習React源碼,當你都不知道Fiber是什麼,不知道expirationTime對於React的意義時,這樣的文章會給人「你講解的代碼我看懂了,但這些代碼的做用是什麼」的感受。java

我要寫的這個系列文章和對應倉庫的存在就是爲了解決這個問題。react

簡單來講,這個系列文章會講解React爲何要這麼作,以及大致怎麼作,但不會有大段的代碼告訴你怎麼作。git

當你看完文章知道咱們要作什麼後,再來看倉庫中具體的代碼實現。github

同時爲了防止堆砌不少功能後,代碼量太大影響你理解某個功能的實現,我爲倉庫每一個功能的實現打了一個git tagweb

配套的倉庫如何使用?

若是React是一個毛線團的話,那麼他的線頭必定是
RectDOM.render(<App/>, document.getElementById('app'));複製代碼
經過這個線頭,我梳理出React首屏渲染會作的工做,將他們從React代碼中抽離出來,加了不少註釋,這就是 v1版本的React

沒有state、沒有Hooks、沒有函數組件和類組件,只能渲染首屏元素,可是全部目錄架構、文件名、方法都和React同樣,代碼片斷徹底同樣(由於就是一邊debug一邊抄的)。npm

若是你想讀React源碼,但又被React龐大的代碼量勸退,我相信這個項目適合你起步。react-native

這個系列的每篇文章,都是 對應倉庫的學習筆記。若是你想跟着我一塊兒學習,能夠找到對應版本的git tag ,clone到本地,安裝依賴後
npm start複製代碼
會打開當前版本的示例,配合文章 + debug 服用。同時經過create-react-app建立一個React應用,跑一樣的示例代碼做爲對照。你會發現,咱們項目的渲染流程和React是一致的。

這是這個系列第一篇文章,對應 git tag v1,正餐開始~數組

schedule + render + commit = React

咱們知道,React是一個聲明式的UI庫,咱們經過組件的形式聲明UI,React會爲咱們輸出DOM並渲染到頁面上。


在React中,對UI的聲明是經過一種稱爲JSX的語法糖來實現。JSX在編譯時會被Babel轉換爲React.createElement方法。

在運行時咱們獲取到的實際上是

React.createElement方法的調用結果,即
一個描述組件結構的對象。

// 輸入JSX
const a = <div>Hello</div>

// 在編譯時,被babel編譯爲React.createElement函數
const a = React.createElement('div', null, 'Hello');

// 在運行時,執行函數,返回描述組件結構的對象
const a = {
  ?typeof: Symbol(react.element),
  "type": "div",
  "key": null,
  "props": {
    "children": "Hello"
  }
}複製代碼

咱們能夠看到,在運行時描述組件結構的對象離渲染到頁面上的DOM還相去甚遠,爲了能渲染DOM到頁面上,React內部確定有2個模塊:

  1. 負責解析JSX對象,決定哪些JSX對象是須要最終渲染成DOM節點的。
  2. 把須要渲染的DOM元素渲染到頁面上。

在React中,咱們把模塊1作的工做叫render,把模塊2作的工做叫commit

爲何叫這個名字呢,想一想你寫的ClassComponent的render方法,在render階段要作的一件事就是執行render方法。

至於commit,可能你會想到 git commit 。事實上,React的工做流程和Git多分支開發很是類似。

因此,更新下咱們的架構:


schedule階段簡析

到目前爲止,咱們簡單介紹了render和commit,有了這2個階段,咱們已經能夠實現除了異步模式(Concurrent)外React的大部分功能。

可是,設想如下場景:

有一個地址搜索框,在輸入字符時會實時請求當前已輸入內容的地址匹配結果。


這裏包括2個狀態變化:
  1. 用戶在輸入框內輸入的字符變化
  2. 顯示實時匹配結果的下拉框內容變化
當同時觸發這兩個狀態變化時咱們通常指望輸入框輸入內容不能有卡頓,實時結果顯示的下拉框更新有延遲是能夠接受的。 也就是說,1的優先級若是能高於2那用戶體驗想必是更好的。

甚至極端的考慮,咱們已經觸發了2,在計算2須要改變的DOM節點的過程當中用戶又觸發了1,這時候若是能擱置2轉而優先處理1,這種體驗是符合預期的。

因此咱們須要一種機制來處理更新的優先級,決定哪一個狀態變化帶來的更新應該被優先執行。

爲了達到這個目的,咱們知道須要爲現有架構增長一個schedule階段:

  1. schedule階段,當觸發狀態改變後,schedule階段判斷觸發的更新的優先級,通知render階段接下來應該處理哪一個更新。
  2. render階段,收到schedule階段的通知,處理更新對應的JSX,決定哪些JSX對象是須要最終被渲染的。
  3. commit階段,將render階段整理出的須要被渲染的內容渲染到頁面上。


commit階段簡析

基於咱們如今的設計,commit階段負責把須要渲染的DOM元素渲染到頁面上。

可是React的野心歷來不只限於web端,理論上當render階段決定了哪些JSX須要被渲染後,咱們對應不一樣的commit,就能實如今不一樣平臺的渲染。


render的最小單元——Fiber

要實現咱們的三個階段,還有三個小問題:
  1. 因爲 render階段產生的結果能對應多個平臺的 commit,那 render階段產生的結果就不能是平臺相關的。若是 render階段產生的節點都是DOM節點,顯然這些節點是無法在Native環境被 commit的。因此咱們須要一種平臺無關的節點結構。
  2. 咱們輸入的JSX是一種描述組件結構的對象,但他無法描述哪一個節點更新,哪一個節點刪除這樣的節點行爲,因此咱們須要一種可以描述節點行爲的結構。
  3. 在講到 schedule階段時,咱們但願低優先級的 schedule是能夠被終止以從新開始一個更高優先級的 schedule的。那麼 schedule的節點粒度必定要夠細,這樣咱們才能徹底操控節點終止 schedule的位置並清除節點 schedule 產生的結果再從新開始。
爲了解決這三個問題,React提出了一種名叫 Fiber的結構,以下圖:

當咱們嘗試渲染 <App/> 時,在render階段會生成右側的Fiber結構。Fiber的完整結構看這裏

  • Fiber中能夠保存節點的類型,例子中App節點是一個函數組件節點,div節點是一個原生DOM節點,I am節點是一個文本節點。
  • 能夠保存節點的信息(好比state,props)。
  • 能夠保存節點對應的值(好比App節點對應App函數,div節點對應div DOMElement)。這樣的結構也解釋了爲何函數組件經過Hooks能夠保存state。由於state並非保存在函數上,而是保存在函數組件對應的Fiber節點上。
  • 能夠保存節點的行爲(更新/刪除/插入),後面會介紹

在React中,咱們的組件會造成一棵組件樹,一樣的,有了Fiber的結構後,咱們須要將他們連接在一塊兒組成Fiber樹。咱們爲Fiber增長以下字段:

  • child:指向第一個子Fiber
  • sibling:指向右邊的兄弟節點
同時因爲Fiber是一層層向下遍歷,當遍歷到圖中的div Fiber節點,咱們已經知道他的父節點是App Fiber節點,這時候能夠賦值 div Fiber.return = App Fiber; 即用return指向本身的父節點。


小朋友,此時你是否有不少???

爲啥這個字段叫return,不叫parent,React核心團隊的Andrew Clark解釋說:能夠理解爲return指向當前Fiber處理完後返回的那個Fiber,當子Fiber被處理完後會返回他的父Fiber。好吧

因此咱們的完整Fiber結構是這樣的:

你能夠在 這篇文章看到React團隊當初設計Fiber架構時的心路歷程。

render和commit的總體流程

如今咱們有了描述組件的節點類型(Fiber),能夠愉快的開始首屏渲染了。

須要注意的是,因爲執行ReactDOM.render產生的首屏渲染並不涉及到其餘更高優先級的更新,因此對於首屏渲染,咱們掠過schedule階段。

好比剛纔介紹schedule階段舉的地址輸入框的例子,首屏渲染了輸入框,更高優先級的更新是後續在輸入框中輸入文字產生的。

這裏咱們以 項目V1版本的Demo爲例:

當咱們首次進入render階段時,咱們傳入JSX:

整個render階段須要作2件事:

  1. 向下遍歷JSX,爲每一個JSX節點的子JSX節點生成對應的Fiber,並賦值
effectTag字段表示當前 Fiber須要執行的反作用,最多見的反作用是:
  • Placement 插入DOM節點
  • Update 更新DOM節點
  • Deletion 刪除DOM節點
固然,首屏渲染只會涉及到 Placement。(全部effectTag 見這裏

PS:這裏同窗可能會奇怪,這一步爲何是「爲每一個節點的子節點生成對應的Fiber」而不是「爲當前節點生成對應的Fiber」?還記得下面這行代碼麼:

執行這行初始化的代碼首先會建立一個根Fiber節點,因此當從根Fiber向下建立Fiber時,咱們始終是爲子節點建立Fiber。這是要作的第一件事。

2. 爲每一個Fiber生成對應的DOM節點,保存在Fiber.stateNode

作完這2件過後咱們進入commit階段,此時咱們知道

  1. 哪些Fiber須要執行哪些操做(由Fiber.effectTag得知)
  2. 執行這些操做的Fiber他們對應的DOM節點(由Fiber.stateNode得知)

有了這些信息,Commit階段只須要遍歷全部有Placement反作用的Fiber,依次執行DOM插入操做就完成了首屏的渲染。

這就是首屏渲染render+commit的整個過程。機智如你,是否是理解起來徹底沒壓力呢。

深刻render階段

咱們剛纔講了render階段會作2件事(會調用的2個函數),如今咱們給他們起個名字吧:

beginWork

向下遍歷JSX,爲每一個JSX節點的子JSX節點生成對應的Fiber,並設置effectTag

咱們叫他beginWork,這是每一個節點render階段開始工做的起點。

completeWork

爲每一個Fiber生成對應的DOM節點

咱們叫他completeWork,這是每一個節點render階段完成工做的終點。

咱們經過workInProgress這個全局變量表示當前render階段正在處理的Fiber,當首屏渲染初始化時, workInProgress === 根Fiber。

調用workLoopSync方法,他內部會循環調用performUnitOfWork方法。

performUnitOfWork每次接收一個Fiber,調用beginWorkCompleteWork,處理完該Fiber後返回下一個須要處理的Fiber。


performUnitOfWork返回null時,就表明全部節點的render階段結束了。

整個流程雖然看起來繁瑣,但就作了2件事:

  1. 採用深度優先遍歷,從上往下生成子Fiber,生成後繼續向子Fiber遍歷( 代碼
  2. 當遍歷到底沒有子Fiber時,開始從底往上遍歷,爲每一個步驟1中已經建立的Fiber建立對應的DOM節點( 代碼

在這個過程當中若是遇到兄弟節點,又重複步驟1,直到最終又回到根Fiber,完成整棵樹的建立與遍歷。

優化渲染階段

到目前爲止咱們的已經很接近React了,只需再優化兩點簡直就是React本act了。

effectList

在咱們的設計中,commit階段會遍歷找到全部含有effectTag的Fiber節點。若是Fiber樹很龐大的話,這個遍歷會很耗時。

但其實在render階段咱們已經知道哪些Fiber會被設置Fiber.effectTag, 因此咱們能夠在render階段就提早標記好他們,將他們組織成鏈表的形式。

假設圖中標紅的Fiber表明本次調度該Fiber有effectTag,咱們用鏈表的指針將他們連接起來造成一條單向鏈表,這條鏈表就是 effectList

用Redux做者Dan Abramov的話來講,effectList相對於Fiber樹,就像聖誕樹上的彩蛋



有了effectListcommit階段只須要遍歷這條鏈表就能知道全部有effectTag的Fiber了。這部分代碼在completeUnitOfWork函數中

首屏渲染的特別之處

按照咱們的架構,咱們會給須要插入到DOM的Fiber賦值

fiber.effectTag = Placement;複製代碼

這對於某次增量更新來講沒有問題,但對於首屏渲染卻過低效了,畢竟對首屏渲染來講,全部Fiber節點對應的DOM節點都是須要渲染到頁面上的。

難道咱們要給全部Fiber賦值effectTag = Placement;再在commit階段一次次的執行DOM插入操做來生成一整棵DOM樹?對於首屏渲染,咱們須要稍微變通下。

當咱們在render階段執行completeWork建立Fiber對應的DOM節點時,咱們遍歷一下這個Fiber節點的全部子節點,將子節點的DOM節點插入到建立的DOM節點下。

(子Fiber的completeWork會先於父Fiber執行,因此當執行到父Fiber時,子Fiber必定存在對應的DOM節點)。代碼見這裏

這樣當遍歷到根Fiber節點時,咱們已經有一棵構建好的離屏DOM樹,這時候咱們只須要賦值根節點的effectTag就能在commit階段一次性將整課DOM樹掛載。

// 僅賦值根fiber一個節點effectTag
RootFiber.effectTag = Placement; 複製代碼


render階段以前發生了什麼

到這裏咱們已經接近實現React的首屏渲染了,還差最後一步,那就是從
到賦值

// 賦值根fiber
workInProgress = Rootfiber;複製代碼

這中間發生了什麼?

複習小課堂:workInProgress指當前render階段正在處理的Fiber,ReactDOM.render會建立一個RootFiber,他會賦值給workInProgress

爲了理解這個問題,咱們須要知道,排除SSR相關,都有哪些方法能觸發React組件的渲染?
  1. ReactDOM.render
  2. this.setState
  3. tihs.forceUpdate
  4. useReducer hook
  5. useState hook (PS:useState其實就是一種特別的useReducer)
既然有這麼多方法觸發渲染,那麼咱們須要一種統一的機制來表示組件須要更新。在React中,這種機制叫update, 代碼見這裏。如今咱們能夠只關注update的以下參數
{
  // UpdateState | ReplaceState | ForceUpdate | CaptureUpdate
  tag: UpdateState,
  // 更新的state
  payload: null,
  // 指向當前Fiber的下一個update
  next: null
}複製代碼
能夠這麼理解:

調用React ClassComponent的this.setState,會產生一個update,update.payload爲須要更新的state,在該ClassComponent對應的Fiber執行beginWork時會處理state的更新帶來的組件狀態改變,固然,在V1版本咱們尚未實現。

對於調用ReactDOM.render使根Fiber初始化時,會產生一個update,update.payload爲對應須要渲染的JSX(代碼見這裏),在根Fiber的beginWork中會觸發這篇文章講到的render流程。

最後的最後

至此咱們跑通了React的首屏渲染流程。若是你看到了這裏,爲本身鼓鼓掌吧。

篇幅有限,咱們講的不少都是宏觀的東西,要了解細節還須要多多debug代碼,把咱們的Demo單步調試幾遍。

這裏再給你推薦一篇極好的React原理文章,配合本文食用效果極佳

相關文章
相關標籤/搜索