輕烤 React 核心機制:React Fiber 與 Reconciliation

React Fiber 是 React v16.x 推出船新架構,而 Reconciliation 是 React 的 Diff 算法,二者都是 React 的 核心機制。本文將會來研究一下 React Fiber 和 Reconciliation,看看 Fiber 究竟是什麼?Reconciliation 是如何基於 Fiber 運做的?Reconciliation 這個算法是怎麼工做的?因爲是 React 的核心機制,因此涉及到不少的概念和邏輯,燒烤哥也只能「輕輕地」烤一下,總結一些主要的原理,具體到源碼層面的實現細節,還有待深刻研究(並且一篇文章的篇幅確定是說不清也說不完)。html

文章篇幅過長,建議收藏後觀看前端

1、從 DOM 到 fiber 對象

首先來一道 「開胃前菜」,讓咱們來看看 DOM、Virtual DOM、React 元素、Fiber 對象的相關概念。node

DOM:

文檔對象模型,實際上就是一個樹,樹中的每個節點就是 HTML 元素(Element),每一個節點其實是一個 JS 對象,這個對象除了包含了該元素的一些屬性,還提供了一些接口(方法),以便編程語言能夠插入和操做 DOM。可是 DOM 自己並無針對動態 UI 的 Web 應用程序進行優化。所以,當一個頁面有不少元素須要更新時,更新相對應的 DOM 會使得程序變得很慢。由於瀏覽器須要從新渲染全部的樣式和 HTML 元素。這其實在頁面沒有任何變化的狀況下也時常發生。react

Virtual DOM:

爲了優化「真實」 DOM 的更新,衍生出了 「Virtual DOM」的概念。本質來講,Virtual DOM 是真實 DOM 的模擬,它實際上也是一棵樹。真實的 DOM 樹由真實的 DOM 元素組成,而 Virtual DOM 樹是由 Virtual DOM 元素組成。git

當 React 組件的狀態發生變化時,會產生一棵新的 Virtual DOM 樹,而後 React 會使用 diff 算法去比較新、舊兩棵 Virtual DOM 樹,獲得其中的差別,而後將「差別」更新到真實的 DOM 樹中,從而完成 UI 的更新。github

要說明一點是:這裏並非說使用了 Virtual DOM 就能夠加快 DOM 的操做速度,而是說 React 讓頁面在不一樣狀態之間變化時,使用了次數儘可能少的 DOM 操做來完成。算法

React 元素(React Element):

在 React 的世界中,React 給 Virtual DOM 元素取名爲 React 元素(React Element)。也就是說,在 React 中的 Virtual DOM 樹是由 React 元素組成的,樹中每個節點就是一個 React 元素。編程

咱們從源碼中(/package/shared/ReactElementType.js)來看看 React 元素的類型定義:數組

export type Source = {|
  fileName: string,
  lineNumber: number,
|};

export type ReactElement = {|
  $$typeof: any,
  type: any,
  key: any,
  ref: any,
  props: any,
  // ReactFiber
  _owner: any,
  ...
|};
複製代碼
  • $$typeof:React 元素的標誌,是一個 Symbol 類型;
  • type:React 元素的類型。若是是自定義組件(composite component),那麼 type 字段的值就是一個 class 或 function component;若是是原生 HTML (host component),若是 div、span 等,那麼 type 的值就是一個字符串('div'、'span');
  • key:React 元素的 key,在執行 diff 算法時會用到;
  • ref:React 元素的 ref 屬性,當 React 元素變爲真實 DOM 後,返回真實 DOM 的引用;
  • props:React 元素的屬性,是一個對象;
  • _owner:負責建立這個 React 元素的組件,另外從代碼中的註釋 「// ReactFiber」 能夠知道,它裏面包含了 React 元素關聯的 fiber 對象實例;

當咱們寫 React 組件時,不管是 class 組件仍是函數組件,return 的 JSX 會通過 JSX 編譯器(JSX Complier)編譯,在編譯的過程當中,會調用 React.createElement() 這個方法。瀏覽器

當調用 React.createElement() 的時候實際上調用的是 ReactElement.js(/packages/react/src/ReactElement.js) 中的 createElement() 方法。調用這個方法後,會建立一個 React 元素。

在上面 ClickCounter 組件這個例子中, <button><span><ClickCounter> 的子組件。而 <ClickCounter> 組件其實它自己其實也是一個組件。它是 <App> 組件的子組件:

class App extends React.Component {
    ...
    render() {
        return [
            <ClickCounter />
        ]
    }
}
複製代碼

因此在調用 <App>render() 時,會建立 <ClickCounter> 組件對應的 react element:

當執行 ReactDOM.render() 後,建立的整棵 react rlement 樹大體以下:

Fiber 對象:

每當咱們建立一個 react element 時,還會建立一個與這個 react element 相關聯的 fiber node。fiber node 爲 Fiber 對象的實例。

Fiber 對象是一個用於保存「組件狀態」、「組件對應的 DOM 的信息」、以及「工做任務 (work)」的數據結構,負責管理組件實例的更新、渲染任務、以及與其餘 fiber node 的關係。每一個組件(react element)都有一個與之對應關聯的 Fiber 對象實例(fiber node),和 react element 不同的是,fiber node 不須要再每一次界面更新的時候都從新建立一遍。

在執行 Reconciliation 這個算法的期間,組件 render 方法所返回的 react element 的信息(屬性)都會被合併到對應的 fiber node 中。這些 fiber node 所以也組成了一棵與 react element tree 相對應的 fiber node tree。(咱們要緊緊記住的是:每一個 react element 都會有一個與之對應的 fiber node)。

Fiber 對象類型定義(/package/react-reconciler/src/ReactInternalTypes.js):

export type Fiber = {|
    tag: WorkTag;
    key: null | string;
    type: any;
    stateNode: any;
    updateQueue: mixed;
    memoizedState: any;
    memoizedProps: any,
    pendingProps: any;
    nextEffect: Fiber | null,
    firstEffect: Fiber | null,
    lastEffect: Fiber | null,
    return: Fiber | null;
    child: Fiber | null;
    sibling: Fiber | null;
    ...
|};
複製代碼
  • tag:這字段定義了 fiber node 的類型。在 Reconciliation 算法中,它被用於決定一個 fiber node 所須要完成的 work 是什麼 ;
  • key:這個字段和 react element 的 key 的含義和內容有同樣(由於這個 key 是從 react element 的key 那裏直接拷貝賦值過來的),做爲 children 列表中每個 item 的惟一標識。它被用於幫助 React 去計算出哪一個 item 被修改了,哪一個 item 是新增的,哪一個 item 被刪除了。官方文檔中有對 key 更詳細的講解;
  • type:這個字段表示與這個 fiber node 相關聯的 react element 的類型。這個字段的值和 react element 的 type 字段值是同樣的(由於這個 type 是從 react element 的 type 那裏直接拷貝賦值過來的)。若是是自定義組件(composite component),那麼 type 字段的值就是一個 class 或 function component;若是是原生 HTML (host component),如 div、span 等,那麼 type 的值就是一個字符串('div'、'span');
  • updateQueue:這個字段用來存儲組件狀態更新、回調和 DOM 更新任務的隊列,fiber node 正是經過這個字段,來管理 fiber node 所對應的 react element 的渲染、更新任務;(若是老鐵們看過燒烤哥的那篇《烤透 React Hook》,就會知道,其實 updateQueue 存儲的就是一個這個 fiber node 須要處理的 effect 鏈表);
  • memoizedState:已經被更新到真實 DOM 的 state(已經渲染到 UI 界面上的 state);
  • memoizedProps: 已經被更新到真實 DOM 的 props(已經渲染到 UI 界面上的 props),
  • pendingProps:等待被更新到真實 DOM 的 props;
  • return:這個字段至關於一個指針,指向父 fiber node;
  • child:這個字段至關於一個指針,指向子 fiber node;
  • sibling:這個字段至關於一個指針,指向兄弟 fiber node;
  • nextEffect:指向下一個帶有 side-effect 的 fiber node;
  • firstEffect:指向第一個帶有 side-effect 的 fiber node
  • lastEffect:指向最後一個帶有 side-effect 的fiber node;

關於其餘屬性的解析,請看源碼中的註釋,或者這篇文章

下面咱們來看看上面例子中的 <ClickCounter> 組件的 fiber node 長什麼樣吧:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: {count: 0},
    pendingProps: {},
    memoizedProps: {},
    tag: 1,
    effectTag: 0,
    nextEffect: null
}
複製代碼

<span> 的 fiber node:

{
    stateNode: new HTMLSpanElement,
    type: "span",
    alternate: null,
    key: "2",
    updateQueue: null,
    memoizedState: null,
    pendingProps: {children: 0},
    memoizedProps: {children: 0},
    tag: 5,
    effectTag: 0,
    nextEffect: null
}
複製代碼

在上述 <ClickCounter> 的例子中,因爲每一個組件的 react element 都會有一個與之對應的 fiber node,所以咱們會獲得一棵 fiber node tree:

Side-effects 是啥?

在 fiber node 的類型定義中,有三個屬性:firstEffect、lastEffect 和 nextEffect,他們指向的是帶有「side-effects」的 fiber node。那 "side-effect" 究竟是什麼東西呢?寫過 React 組件的老鐵都知道,React 組件實際上就是一個函數,這個函數接收 props 和 state 做爲輸入,而後經過計算,最終返回 react element。在這個過程當中,會進行一些操做,例如更改 DOM 結構、調用組件的生命週期等等,React 把這些「操做」統稱爲「side-effect」,簡稱 「effect」,也就是常說的「反作用」。官方文檔中有對 effect 進行介紹。

大部分組件的 state 和 props 的更新都會致使 side-effect 的產生。此外,咱們還能夠經過 useEffect 這個 React Hook 來自定義一些 effect。在燒烤哥以前寫的 《烤透 React Hook》一文中曾提到過,fiber node 的 effect 會以「循環鏈表」的形式存儲,而後 fiber node 的 updateQueue 會指向這個 effect 循環鏈表。

Effects list:

在一個 fiber node tree 中,有一些 fiber node 是有 effect 須要處理的,而有一些 fiber node 是沒有 effect 須要處理的。爲了加快整棵 fiber node tree 的 effect 的處理速度,React 爲那些帶有 effect 須要處理的 fiber node 構建了一個鏈表,這個鏈表叫作 「effects list」。這個鏈表存儲這那些帶有 effect 的 fiber node。維護這個鏈表的緣由是:由於遍歷一個鏈表比遍歷一整棵 fiber node tree 的速度要快得多,對於那些沒有 effect 須要處理的 fiber node,咱們沒有必要花時間去迭代它。這個鏈表經過前面說過的 fiber node 的 firstEffect、lastEffect 和 nextEffect 三個屬性來維護:firstEffect 指向第一個帶有 effect 的 fiber node,lastEffect 指向最後一個帶有 effect 的fiber node,nextEffect 指向下一個帶有 effect 的 fiber node。

舉個例子,下面有一個 fiber node tree,其中顏色高亮的節點時帶有 effect 須要處理的 fiber node。假設咱們的更新流程將會致使 H 被插入到 DOM 中,C 和 D 將會改變自身的屬性(attribute),G將會調用自身的生命週期方法等等。

那麼,這個 fiber node tree 的 effect list 將會把這些節點鏈接到一塊兒,這樣,React 在遍歷 fiber node tree 的時候能夠跳過其餘沒有任何任務須要處理的 fiber node 了。

小結:

咱們再來回顧一下,React 對於頁面上 UI 的一步步抽象轉化:

2、React 架構

一、任務調度器 Scheduler:決定渲染(更新)任務優先級,將高優的更新任務優先交給 Reconciler;當 class 組件調用 render() 方法(或者 function 組件 return)時,實際上並不會立刻就開始這個組件的渲染工做,此時只是會返回「渲染信息」(該渲染什麼的描述),該描述包含了用戶本身寫的 React 組件(如 <MyComponent>),還有平臺特定的組件(如瀏覽器的 <div>)。而後 React 會經過 Scheduler 來決定在將來的某個時間點再來執行這個組件渲染任務。

二、協調器 Reconciler:負責找出先後兩個 Virtual DOM(React Element)樹的「差別」,並把「差別」告訴 Renderer。關於 協調器是怎麼運做的,咱們後面再來詳細研究;

三、渲染器 Renderer:負責將「差別」更新到真實的 DOM 上,從而更新 UI;不一樣的平臺配會有不一樣的 renderer。DOM 只是 React 可以適配的的渲染平臺之一。其餘主要的渲染平臺還有 IOS 和安卓的視圖層(經過 React Native 這個 renderer 來完成)。這種分離的設計意味着 React DOM 和 React Native 能使用獨立的 renderer 的同時公用相同的,由 React core 提供的 reconciler。React Fiber 重寫了 reconciler,但這事大體跟 rendering 無關。不過,不管怎麼,衆多 renderer 確定是須要做出一些調整來整合新的架構。

3、Reconciliation 是啥?

React 是一個用於構建用戶界面的 JavaScript 類庫。React 的核心機制是跟蹤組件狀態變化,而後將更新的狀態映射到用戶界面上。

使用 React 時,組件中 render() 函數的做用就是建立一棵 react element tree(React 元素樹)。當調用 setState(),即下一個 state 或 props 更新時,render() 函數將會返回一棵不一樣的 react element tree。接下來,React 將會使用 Diff 算法去高效地更新 UI,來匹配最近時刻的 React 元素樹。這個 Diff 算法就是 Reconciliation 算法。

Reconciliation 算法主要作了兩件事情:

  1. 找出兩棵 react element tree 的差別;
  2. 將差別更新到真實 DOM,從而完成 UI 的更新;

Stack Reconciler

在 React 15.x 版本以及以前的版本,Reconciliation 算法採用了棧調和器( Stack Reconciler )來實現,可是這個時期的棧調和器存在一些缺陷:不能暫停渲染任務,不能切分任務,沒法有效平衡組件更新渲染與動畫相關任務的執行順序,即不能劃分任務的優先級(這樣有可能致使重要任務卡頓、動畫掉幀等問題)。Stack Reconciler 的實現

Fiber Reconciler

爲了解決 Stack Reconciler 中固有的問題,以及一些歷史遺留問題,在 React 16 版本推出了新的 Reconciliation 算法的調和器—— Fiber 調和器(Fiber Reconciler)來替代棧調和器。Fiber Reconciler 將會利用調度器(Scheduler)來幫忙處理組件渲染/更新的工做。此外,引入 fiber 這個概念後,原來的 react element tree 有了一棵對應的 fiber node tree。在 diff 兩棵 react element tree 的差別時,Fiber Reconciler 會基於 fiber node tree 來使用 diff 算法,經過 fiber node 的 return、child、sibling 屬性能更方便的遍歷 fiber node tree,從而更高效地完成 diff 算法。

Fiber Reconciler 功能(優勢)

  1. 可以把可中斷的任務切片處理;
  2. 可以調整任務優先級,重置並複用任務;
  3. 能夠在父子組件任務間前進後退切換任務;
  4. render 方法能夠返回多個元素(便可以返回數組);
  5. 支持異常邊界處理異常;

4、Reconciliation 工做流程

上文提到,Reconciliation 算法主要作了兩件事情:

  1. 找出兩棵 react element tree 的差別;
  2. 將差別更新到真實 DOM,從而完成 UI 的更新;

下面將圍繞上述的「兩件事情」,來看看 Reconciliation 算法是怎麼運做的。

一、找出兩棵 react element tree 的差別

三個策略

在對比兩棵 react element tree 的時,React 制定了 3 個策略:

  1. 只對同級的 react element 進行對比。若是一個 DOM 節點在先後兩次更新中跨越了層級,那麼 React 不會嘗試複用它;
  2. 兩個不一樣類型(type 字段不同)的 react element 會產生不一樣的 react element tree。例如元素 div 變爲 p,React 會銷燬 div 及其子孫節點,並新建 p 及其子孫節點;
  3. 開發者能夠經過 key 屬性來暗示哪些子元素在不一樣的渲染下能保持穩定。下面用一個例子說明一下 key 的做用:
// 更新前
<div>
    <p key="qianduan">前端</p>
    <h3 key="shaokaotan">燒烤攤</h3>
</div>
// 更新後
<div>
    <h3 key="shaokaotan">燒烤攤</h3>
    <p key="qianduan">前端</p>
</div>
複製代碼

假如沒有 key,React 會認爲 div 的第一個節點由 p 變爲 h3,第二個子節點由 h3 變爲 p。這符合第 2 個原則,所以會銷燬並新建相應的 DOM 節點。

但當咱們加上了 key 屬性後,便指明瞭節點先後的對應關係,React 知道 key 爲 「shaokao」 的 p 在更新後還存在,因此 DOM 節點能夠複用,只是須要交換一下順序而已。

Diff 具體過程

(關於 Diff 的源碼:/packages/react-reconciler/src/ReactChildFiber.new.js)

根據上述的第一個策略「只對同級的 react element 對比」,意思就是隻對同級的節點作對比。那「同級」的意思是什麼呢?——直屬於同一父節點的那些節點即爲同級節點。舉個例子:

如上圖所示,新舊兩棵樹的根節點默認爲同級節點:

  1. 舊 react element tree 的 節點 B、C、D 的父節點爲 A;
  2. 新 react element tree 的 節點 B、C、D 的父節點也爲 A;
  3. 因此舊 react element tree 的節點 B、C、D 和新 react element tree 的節點 B、C、D 屬於同級節點。在 Diff 的過程當中,將會對比新、舊 react element tree 的 B、C、D 的差別;
  4. 同理,新舊 tree 的 E、F 節點的父節點均爲 B,因此新舊 tree 的 E、F 節點爲同級節點;

具體到對比某個節點時,可分 2 種狀況:

  1. 若新舊節點(react element)的類型(type)或 key(若是沒有 key,則看 index) 有其中一個不相同,則 DOM 不會被複用,直接銷燬;
  2. 若新舊節點(react element)的類型(type)和 key(若是沒有 key,則看 index) 都相同,則會複用該 DOM;
對比不一樣類型(type)的 react element

當對比得出節點(react element) 的 type 不相同時 ,React 會銷燬原來的節點及其子孫節點,而後從新建立一個新的節點及其子孫節點。例如:

// 舊
<div>
    <A />
    <div>
        <B />
        <C />
    </div>
</div>
// 新
<div>
    <A />
    <span>
        <B />
        <C />
    </span>
</div>
複製代碼

上面的例子中,原來的節點類型爲 div,更新後節點的類型變爲了 span,React 發現了這其中的不一樣後,會銷燬 div 節點及其子節點(B 和 C),而後從新建立一個類型爲 span 的節點及其子節點(B 和 C)。

對比相同類型(type)的 react element(Composite Component、Host Component)

當對比得出節點(react element)的類型(type)相同時,React 會保留該 react element 對應的 DOM 節點(複用該 DOM),而後僅比對及更新有改變的屬性(attribute)。例如:

// 舊
<div className="before" title="stuff" />
// 新
<div className="after" title="stuff" />
複製代碼

經過對比,React 知道只須要修改 DOM 元素上的 className 屬性便可。

當更新 style 屬性時,React 僅更新有所改變的屬性,例如:

// 舊
<div style={{color: 'red', fontWeight: 'bold'}} />
// 新
<div style={{color: 'green', fontWeight: 'bold'}} />
複製代碼

經過對比,React 知道只須要修改 DOM 元素上的 color 樣式,而無需修改 fontWeight。

以上所舉的例子都是 Host Component(原生 HTML 元素),假如是對比相同類型的 Composite Component(本身寫的 React 組件),此時主要看的是組件的 props 和 state 有沒有改變,假若有改變,則更新組件及其子組件。

在對比同級節點(react element)時,有如下 2 種狀況考慮:

  1. 同級只有一個節點(例如上圖的 G);
  2. 同級有多個節點(例如上圖的 B、C、D);
同級只有一個節點

這種狀況相對簡單,就是對比新舊兩個節點而已,根據上面說的兩種狀況(節點類型相同、類型不一樣)判斷處理便可。

同級有多個節點

當同級有多個節點時,須要處理 3 種狀況:

  1. 節點更新(類型、屬性更新)
  2. 節點新增或刪除
  3. 節點移動位置

對於同級有多個節點的 Diff,必定屬於以上三種狀況中的一種或多種。React 團隊發現,在平常開發中,相對於增長和刪除,更新組件發生的頻率更高,因此 React 的 Diff 算法會優先判斷並處理節點的更新。

針對同級的多個節點,咱們能夠將其看作是一個鏈表(由於實際上同級的 react element 它們各自對應的 fiber node 會經過 sibling 字段來鏈接成一個單向鏈表)。Diff 算法將會對「新同級節點鏈表」進行 2 次遍歷:

  1. 第一輪遍歷:處理更新的節點(節點對應的 DOM 可複用,只需更新其中的一些屬性就能夠了);
  2. 第二輪遍歷:處理新增、刪除、移動的節點;
第一輪遍歷

爲了方便說明,如下將會分別把「舊 react element tree 的同級節點」和 「新 react element tree 的同級節點」稱爲「舊同級節點鏈表」和「新同級節點鏈表」

  1. 遍歷「新同級節點鏈表」和 「舊同級節點鏈表」,從第一個節點開始遍歷(i = 0),判斷新、舊節點的類型(type)是否相同和 key 是否相同,若是 type 和 key 都相同,則說明對應的 DOM 可複用;
  2. 若是這個節點對應的 DOM 可複用,則 i++,去判斷下一組新、舊節點的 type 和 key,看它們對應的 DOM 是否可複用,若是能夠複用,則重複步驟 2;
  3. 若是發現某一組新、舊節點對應的 DOM 不可複用,則結束遍歷;
  4. 若是「新同級節點鏈表」遍歷完了 或者 「舊同級節點鏈表」遍歷完了,則結束遍歷;

以上流程的簡單模擬代碼以下(注意只是「簡單模擬」的代碼,和源碼的具體實現仍是有區別的,若是想看源碼具體的實現,請看 /packages/react-reconciler/srcReactChildFiber.new.js 的 reconcileChildArray() 函數):

// newNodeList 爲 新同級節點鏈表
// oldNodeList 爲 舊同級節點鏈表
for (let i = 0; i < newNodeList.length; i++) {
    if (!oldNodeList[i]) break;  // 若是「舊同級節點鏈表」已經遍歷完了,則結束遍歷
    if (newNodeList[i].key=== oldNodeList[i].key && 
        newNodeList[i].type === oldNodeList[i].type) {
        continue;  // 對應的 DOM 可複用,則繼續遍歷
    } else {
        break; // 對應的 DOM 不可複用,則結束遍歷
    }
}
複製代碼

對於上述流程,當咱們結束遍歷時,會有兩種結果:

「結果一」:在步驟 3 結束了遍歷,此時「新同級節點鏈表」和「舊同級節點鏈表」都沒有遍歷完。

看個例子:

// 舊
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
// 新
<li key="0">0</li>
<li key="1">1</li>
<div key="2">2</div>
<li key="3">3</li>
複製代碼

前面 key === 0,key === 1 的節點均可以複用,可是 到了 key === 2 時,因爲節點的 type 發生了改變,所以對應的 DOM 不可複用,直接結束遍歷。此時至關於「舊同級節點鏈表」中 key === 2 的節點未被遍歷處理、「新同級節點鏈表」中 key ===二、 key === 3 的節點也沒有被遍歷處理。

「結果二」:若是是在步驟 4 結束遍歷,那麼多是 「新同級節點鏈表」遍歷完、或者「舊同級節點鏈表」遍歷完,又或者他們同時遍歷完。例如:

// 舊
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
            
// 新
// 「新同級節點鏈表」和「舊同級節點鏈表」同時遍歷完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
複製代碼

// 舊
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
// 新 
//「新同級節點鏈表」沒遍歷完,「舊同級節點鏈表」就遍歷完了
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
複製代碼

// 舊
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
// 新
//「新同級節點鏈表」遍歷完了,「舊同級節點鏈表」還沒遍歷完
<li key="0" className="aa">0</li>
複製代碼

第二輪遍歷

第二輪遍歷時,主要是遍歷「新同級節點鏈表」中剩下還沒被遍歷處理過的節點。

假如上一輪遍歷結果爲 「結果二」

一、若是是「新同級節點鏈表」沒有遍歷完,「舊同級節點鏈表」已經遍歷完的這種狀況,則說明有節點新增,即將要新增的這個節點將會被打上一個 Placement 的標記 (newFiber.flags = Placement)。

二、若是是「新同級節點鏈表」已經遍歷完,「舊同級節點鏈表」沒有遍歷完的這種狀況,則說明有節點須要被刪除,這個即將要被刪除的節點將會被打上一個 Deletion 的標記(returnFiber.flags |= Deletion)。

假如上一輪遍歷結果爲 「結果一」

假如爲結果一,說明新、舊同級節點鏈表都沒有遍歷完,這意味着有的節點在此次更新中可能改變了位置!接下來是處理位置變換的節點。處理節點位置變換的 2 個主要思想就是:「剩下的節點中,哪些節點須要「右」移動?」「移動到什麼位置?」

因爲有節點交換了位置,因此咱們不能再經過節點的索引來對比新舊的節點了。不要慌,問題不大,咱們還能夠利用 key 來將新舊的節點對應上。

在遍歷「新同級節點鏈表」時,爲了能快速在「舊同級節點鏈表」中找到對應的舊節點,React 會將「舊同級節點鏈表」中還沒被處理過的節點以 map 的形式存放起來,其中 key 屬性爲 key,fiber node 爲 value,這個 map 叫作 existingChildren

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
複製代碼

existingChildren 是如何發揮做用的呢?在第二輪遍歷時:

一、假如遍歷到的「新同級節點」A 的 key 在 existingChildren 中能夠找到,則說明在「舊同級節點鏈表」中能夠找到一個和 A 的key 相同的「舊同級節點」A1。因爲是經過 map 的實行來匹配的,很明確的一點就是 A 和 A1 的 key 是相同的,接下來就是判斷它們的 type 是否相同:

  • 假如 key 相同、type 也相同,說明該節點對應的 DOM 可複用,只是位置發生了變化;
  • 假如 key 相同、type 不一樣,則該節點對應的 DOM 不可複用,須要銷燬原來的節點,並從新插入一個新的節點;

二、假如遍歷到的「新同級節點」A 的 key 在 existingChildren 中找不到,則說明在「舊同級節點鏈表」中找不到和 A 的 key 相同的「舊同級節點」A1,那就說明 A 是一個新增節點;

解決了 「新節點如何對應找到舊節點的問題」 後。接下來咱們來看看具體在第二輪循環的時候如何處理節點新增、刪除、移動的。

其實新增和刪除節點的狀況很好理解,其實上面講「兩種結果」的時候已經說明了新增、刪除的狀況了。下面咱們重點來研究一下節點移動的狀況。在前面曾經說過,處理節點的位置變化,主要抓住兩個點:

  • 哪一個節點須要向右移?
  • 向右移動到哪一個位置?

以上兩個問題實際上涉及到的是 方向位移,若是想要明確這兩個東西,就須要一個「基準點」,或者說「參考點」。React 使用 lastPlacedIndex 這個變量來存放「參考點」。咱們能夠在源碼的 reconcileChildrenArray() 函數的開頭,看到:

let lastPlacedIndex = 0;
複製代碼

lastPlacedIndex 這個變量表示當前最後一個可複用的節點,對應在「舊同級節點鏈表」中的索引。初始值爲 0。(這個定義理解起來可能有點繞,不過不要緊,等下看兩個例子就知道它究竟存的什麼東西了)

在遍歷剩下的「新同級節點鏈表」時,每個新節點會經過 existingChildren 找到對應的舊節點,而後就能夠獲得舊節點的索引 oldIndex(即在「舊同級節點鏈表」中的位置)。

接下來會進行如下判斷:

  • 假如 oldIndex >= lastPlacedIndex,表明該複用節點不須要移動位置,並將 lastPlacedIndex = oldIndex;
  • 假如 oldIndex < lastPlacedIndex,表明該節點須要向右移動,而且該節點須要移動到上一個遍歷到的新節點的後面;

上述就是處理節點移動的邏輯。看完以後可能仍是有點懵,此時就須要配合一些栗子來服用,效果會更佳~

栗子1:

假設現有新舊兩個同級節點列表(下列圖中全部圓圈表明的節點的 type 均爲 li,圈圈中的字母就是該節點的 key):

// 舊
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
// 新
<li key="a">a</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="b">b</li>
複製代碼

首先是第一輪循環:

第二輪循環:

剛剛第一遍循環只處理了第一個節點 a,目前「舊同級節點鏈表」中還有 b、c、d 還未被遍歷處理,「新同級節點列表」中還有 c、d、b 還未被遍歷處理。新、舊同級節點鏈表均沒有完成遍歷,也就是說,沒有節點新增或刪除,說明有節點變化了位置。所以接下來的第二輪循環,主要是處理節點的位置移動。在開始處理以前,先把「舊同級節點鏈表」中未被遍歷處理的的 b、c、d 節點以 map 的形式存放到 existingChildren 中。

「新同級節點鏈表」遍歷到節點 c:

「新同級節點鏈表」遍歷到節點 d:

「新同級節點鏈表」遍歷到節點 b:

第二輪遍歷到此結束,最終,節點 a、c、d 對應的 DOM 節點都沒有移動,而節點 b 對應的 DOM 則會被標記爲「須要移動」。

因而,通過兩輪循環後,React 就知道了,想要從「舊同級節點鏈表」變成「新同級節點鏈表」那樣子,須要「舊同級節點鏈表」通過如下每一個節點的操做:

  1. 節點 a 位置不變;
  2. 節點 b 向右移動到節點 d 的後面;
  3. 節點 c 位置不變;
  4. 節點 d 位置不變;

什麼?感受只舉一個栗子有點意猶未盡?咱們再來一個栗子~

假設現有新舊兩個同級節點列表:

// 舊
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>

// 新
<li key="d">d</li>
<li key="a">a</li>
<div key="b">b</div>
<li key="c">c</li>
複製代碼

第一輪循環:

第二輪循環:

「新同級節點鏈表」遍歷到節點 d:

「新同級節點鏈表」遍歷到節點 a:

「新同級節點鏈表」遍歷到節點 b:

「新同級節點鏈表」遍歷到節點 c:

第二輪遍歷到此結束。

通過兩輪循環後,React 就知道了,想要從「舊同級節點鏈表」變成「新同級節點鏈表」那樣子,須要「舊同級節點鏈表」通過如下每一個節點的操做:

  1. 節點 d 位置不變;
  2. 節點 a 向右移動到節點 d 的後面;
  3. 在節點 a 後面插入一個新的節點 b,其類型爲 div,而後刪除原來的節點 b;
  4. 節點 c 向右移動到節點 b 的後面;

上述每一個節點各自的「操做」(work)—— 「移動到哪裏」、「位置不變」、「插入新的,刪掉舊的」 等等,會存放到節點各自對應的 fiber node 中。等到渲染階段(Render phase)時,React 會讀取並執行這些「操做」,從而完成 DOM 的更新。

小結:

咱們經過下面這樣圖來回顧一下整個 diff 流程:

二、將差別更新到真實 DOM,從而完成 UI 的更新

通過上面的對比找出了「差別」以後,React 知道了「哪些 react element 要被刪除」、「哪些 react element 須要添加子節點」、「哪些 react element 位置須要移動」、「哪些 react element 的屬性須要更新」等等的一系列操做,這些操做會被看做一個個更新任務(work)。每一個 react element 自身的更新任務(work)會存儲在與這個 react element 對應的 fiber node 中。

渲染階段(Render phase),Reconciliation 會從 fiber node tree 最頂端的節點開始,從新對整棵 fiber node tree 進行 深度優先遍歷,遍歷樹中的每個 fiber node,處理 fiber node 中存儲的 work。遍歷一次 fiber node tree 的執行其中的 work 的這個過程被稱做一次 work loop。當一個 fiber node 本身和其全部子節點(child)分支上的 work 都被完成了,此時這個 fiber node 的 work 纔算完成。一旦一個 fiber node 的 work 完成了,也就是說這個 fiber node 被結束了,而後 React 會接着去處理它的兄弟節點(silbing 字段所指向的 fiber node)的 work,在完成這個兄弟節點(sibling)的 work 後,就會繼續移步到下一個兄弟節點......以此類推。當全部的 sibling 節點的 work 都處理完成後,React 纔會回溯到 parent 節點(經過 return 字段一步步回溯)。這個過程發生在 completeUnitOfWork 函數(/packages/react-reconciler/src/ReactFiberWorkLoop.new.js)中。

React 的開發者在這裏作了一個優化(也就是前面提到過的 「Effect List」),React 會跳過那些已經處理過的 fiber node,只會去處理那些帶有未完成 work 的 fiber node。舉個例子,若是你在組件樹的深層去調用 setState() 方法的話,那麼 React 雖然仍是會從 fiber node tree 的頂部的節點開始遍歷,可是它會跳過前面全部的父節點,直奔那個調用了 setState() 方法的子節點。

work loop 結束後(也就是遍歷完整棵 fiber node tree 後),就會準備進入 commit 階段(Commit phase)。在 commit 階段,React 會去更新真實 DOM 樹,從而完成 UI 的更新渲染。

(PS:因爲篇幅有限,關於在 Render phase 和 Commit phase 兩個階段中更具體的流程以及在這個過程 fiber node 的每一個字段的做用和變化、還有 Scheduler、Renderer 的原理等等細節,徹底能夠再寫幾篇文章了[笑哭],後面有機會在來做更深一步的研究和總結)

5、源碼查看思路

(基於 React v17.0.1 源碼)

  1. ReactElement 類型定義:package/shared/ReactElementType.js
  2. fiber 類型定義:packages/react-reconciler/src/ReactInternalTypes.js
  3. 建立 fiber:packages/react-reconciler/src/ReactFiber.new.js
  4. diff 過程:reconcileChildFibers 函數(/packages/react-reconciler/src/ReactChildFiber.new.js)
  5. work loop 過程:completeUnitOfWork 函數(/packages/react-reconciler/src/ReactFiberWorkLoop.new.js)

6、後記

本文是燒烤哥基於 React 源碼和網絡上的一些文章總結而來,鑑於 React 內部機制複雜龐大和燒烤哥的能力有限,文中可能會出現錯誤或者總結得不夠到位的地方,但願各位老鐵吃完燒烤以後在評論區指出,你們一塊兒交流探討,期待經過和老鐵們的交流來加深對前端知識的理解。

7、參考文獻

關注「前端燒烤攤」 掘金 or 微信公衆號, 第一時間獲取燒烤哥前的總結與發現。

相關文章
相關標籤/搜索