jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

React Fiber

Reactv16 開始啟用了全新的架構,管理代號為 Fiber 。比起之前的實現極大地提高了性能,本文將會結合一個實例整體剖析一下 Fiber 的內部架構。

概覽#

先來看一個例子 :

class ClickCounter extends React.Component {
    constructor (props) {
        super(props)
        this.state = { count: 0 }
        this.handleClick = this.handleClick.bind(this)
    }
    handleClick () {
        this.setState(state => {
            return {
                count: state.count++
            }
        })
    }
    render () {
        return [
            <button key="1" onClick={this.handleClick}>更新計數器</button>,
            <span key="2">{this.state.count}</span>
        ]
    }
}

當我們點擊 更新計數器 按鈕以後,React 會做以下事情 :

  • 更新 ClickCounter 組件 statecount 屬性。
  • 檢索並比較 ClickCounter 組件的子組件及他們的 props
  • 更新 span 元素的 props

下面讓我們深入來解析這個過程~

從 React 元素到 Fiber 節點#

React 每個組件渲染的 UI 都是通過 render 方法的,我們使用的 JSX 語法,會被編譯成通過 React.createElemnet 方法來調用,比如上面的 UI 結構 :

<button key="1" onClick={this.onClick}>更新計數器</button>
<span key="2">{this.state.count}</span>

會被轉成下面的代碼 :

React.createElement(
	'button',
    {
        key: '1',
        onClick: this.onClick
    },
    '更新計數器'
),
React.createElement(
	'span',
    {
        key: '2'
    },
    this.state.count
)

之後會產生如下的兩種數據結構 :

[
    {
        $$typeof: Symbol(react.element),
        type: 'button',
        key: "1",
        props: {
            children: '更新計數器',
            onClick: () => { ... }
        }
    },
    {
        $$typeof: Symbol(react.element),
        type: 'span',
        key: "2",
        props: {
			children: 0
        }
    }
]

簡單解釋一下上述的屬性 :

  • $$typeof: 唯一地標識為 React 元素。
  • typekeyprops 描述元素對應的屬性。
  • 其它比如 ref 屬性暫不討論。

ClickCounter 組件就沒有任何 props 或者 key 了 :

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: ClickCounter
}

Fiber 節點#

在協調算法調用期間,每一個 render 轉化的 React 元素都會被合併到 Fiber 節點樹上,每個 React 元素都有一個對應的 Fiber 節點,不同於 React 元素,fibers 不會隨著每個 render 而重新創建,它們是可變的數據結構。

不同類型的 React 元素都有 **對應的 type** 來定義需要被做的工作。

從這個角度,Fiber 可以被理解成一種展示需要做什麼工作的數據結構,Fiber 架構也提供了一種便利的方式來追蹤、安排、暫停和停止工作。

Fiber 節點首次被創建之後,後續的更新 React 會復用 Fiber 節點並且只更新必要的屬性。如果定義了 keyReact 還會選擇是否僅單純移動節點來優化性能。

轉化完成以後我們就有了這樣一個樹結構 :

fiber-tree

所有的 Fiber 節點都通過一個鏈表相連,並帶有這三個屬性 : childsiblingreturn。(關於 Fiber 節點的設計後文會詳細講解)。

Current 和 workInProgress 樹#

上面轉化完成後的樹就是 current 樹,當React 開始更新時它會遍歷 current 樹,同時創建對應的節點組成了workInProgress 樹,當所有的更新及相關操作完成後,React 會把 workInProgress 樹的內容渲染到螢幕上,然後 workInProgress 樹就變成了 current 樹。

workInProgress 樹也被稱為 finishedWork 樹。

在源碼中關鍵的一個函數就是 :

function updateHostComponent (current, workInProgress, renderExpirationTime) { ... }

副作用線性表#

除了常規的更新操作,React 還定義了 "副作用" 的操作 : 獲取數據訂閱操作或是手動改變 DOM 結構,這些操作在 Fiber 節點中會被編碼成 effectTag 字段。

ReactSideEffectTags.js 定義了在實例更新處理之後將會被做的操作 :

  • 對於 DOM 組件:包含了新增、更新或移除元素的操作。
  • 對於類組件:包含了更新 ref 和調用 componentDidMountcomponentDidUpdate 生命週期方法。
  • ......

為了快速地處理更新, React 採用了很多高效的措施,其中一個就是建立 Fiber 節點的線性表,遍歷線性表的速度快於遍歷樹。建立線性表的目的是標記帶有 DOM 更新或其它副作用的節點,它是 finishedWork 樹的子集,通過 nextEffect 屬性相連。

舉個例子:當我們的更新造成 c2 被插入到 DOM 中,同時 d2c1 改變了屬性值,b2 調用了一个生命週期方法,副作用線性表會將它們連在一起這樣 React 之後可以直接跳過其它節點 :

effect-list

接下來讓我們來深入 Fiber 執行的算法~

兩個階段#

React 在兩個主要階段執行工作 : rendercommit

render 階段,React 對通過 setStateReact.render 計劃的組件應用更新並找出需要更新到 UI 上的部分。如果是初始渲染,React 會創建一個新的 Fiber 節點,在之後的更新中會復用已存在的 Fiber 節點來進行更新。這個階段最終產生了標記有副作用的 Fiber 節點樹。副作用描述了在接下來的 commit 階段需要做什麼。

render 階段的工作可以被異步地處理。在這個階段 React 可以依據(瀏覽器)空閒時間來處理一個或多個 Fiber 節點,然後停下來緩存已經完成的工作並響應一些事件,可以從暫停的地方繼續處理,也可以根據需要廢棄已完成的部分從頭再開始。這些可以出現是因為在這個階段的處理不會導致任何用戶可見的變化,比如說 DOM 更新。通俗地說,這個階段是 可中斷的

render 階段會調用一些生命週期方法 :

  • [不安全] componentWillMount (已廢棄)
  • [不安全] componentWillReceiveProps (已廢棄)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [不安全] componentWillUpdate (已廢棄)
  • render

從 v16.3 開始,一些遺留的生命週期方法已經被標記為 不安全,也就是官方已經不推薦使用的方法,它們將在未來的 v16.x 版本中啟用警告,並將會在 v17.0 版本中刪除,詳細可閱讀 Update on Async Rendering

為什麼官方會標記為不安全?因為 render 階段不會產生像 DOM 更新這樣的副作用,React 可以異步地更新處理組件,但是被標記為不安全的這些方法常常被開發者誤用,往往會在這些方法中放入帶有副作用的代碼造成異步渲染出錯。

相對地,commit 階段總是同步的,因為這個階段的處理會導致用戶可見的變化,比如說 DOM 更新,因此 React 需要一次完成它們。

commit 階段會調用這些生命週期方法 :

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因為這些方法在同步的 commit 階段被執行,所以它們可以包含帶有副作用的代碼。

render 階段#

協調算法始終使用 renderRoot 函數從頂部的 HostRoot fiber 節點開始,但是它會跳過已處理過的 fiber 節點直至遇到帶有未完成工作的節點。比如,如果你在組件樹深處中調用了 setStateReact 將會從頂部開始快速略過父組件直到抵達了調用了 setState 方法的組件 。

工作循環的主要步驟#

所有 fiber 節點會在 工作循環 函數中處理,看一下循環中同步部分的實現 :

function workLoop (isYieldy) {
    if (!isYieldy) {
        while (nextUnitOfWork !== null) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        }
    } else {
        ...
    }
}

nextUnitOfWork 包含來自 workInProgress 樹需要處理的節點的引用。當 React 遍歷 Fiber 樹時,它使用這個變量去獲知是否還有其它未完成的 fiber 節點,處理完當前 fiber 節點後,這個變量會指向樹中下一個 fiber 節點或是 null ,此時 React 會退出工作循環並準備提交更改。

當遍歷 Fiber 樹時有四個主要的函數被調用來初始化或者完成工作 :

這裡有一個形象的動畫來展示它們如何被使用,子節點將會被優先完成。

workloop

先看前兩個函數 performUnitOfWorkbeiginWork :

function performUnitOfWork (workInProgress) {
    let next = beginWork(workInProgress)
    if (next === null) {
        next = completeUnitOfWork(workInProgress)
    }
    return next
}
function beginWork (workInProgress) {
    console.log('work performed for ' + workInProgress.name)
    return workInProgress.child
}

performUnitOfWork 函數接收一個來自 workInProgress 樹的 fiber 節點然後通過調用 beginWork 函數開始工作,這個函數會執行一個 fiber 節點所有需要執行的操作(簡化處理這裡只打印了 fiber 節點的名字表示已完成)。beginWork 函數總是返回一個指向下一個子節點的指針或是 null

如果有下一個子節點,它就會被賦值給 workLoop 函數中的變量 nextUnifOfWork,如果沒有了,React 就知道已經到達這個節點分支的末尾,因此可以完成當前節點。一旦一個節點完成了,它將會處理兄弟節點的工作然後再回溯父節點。看一下 completeUnitOfWork 函數 :

function completeUnitOfWork (workInProgress) {
    while (true) {
        let returnFiber = workInProgress.return
        let siblingFiber = workInProgress.sibling
        
        nextUnitOfWork = completeWork(workInProgress)
        
        if (siblingFiber !== null) {
            return siblingFiber
        } else if (returnFiber !== null) {
            workInProgress = returnFiber
            continue
        } else {
            return null
        }
    }
}

function completeWork (workInProgress) {
    console.log('work completed for ' + workInProgress.name)
    return null
}

Commit 階段#

這個階段從調用 completeRoot 函數開始。在這裡 React 更新 DOM 並且調用可變的生命週期的方法。

在這個階段 ReactcurrentfinishedWorkworkInProgress)樹以及副作用線性表。

副作用線性表會告訴我們哪個節點需要被插入、更新或刪除,或者哪個組件需要調用它們的生命週期方法。這就是整個 commit 階段會遍歷處理的東西。

commit 階段主要運行的函數是 commitRoot,這個函數會做以下事情:

  • 調用標記了 Snapshot 副作用的節點的 getSnapshotBeforeUpdate 方法
  • 調用標記了 Deletion 副作用的節點的 componentWillUnmount 方法
  • 執行所有 DOM 的插入、更新和刪除操作
  • finishedWork 樹設為 current
  • 調用標記了 Placement 副作用的節點的 componentDidMount 方法
  • 調用標記了 Update 副作用的節點的 componentDidUpdate 方法

這有一個簡化版的函數描述了上述過程 :

function commitRoot (root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects()
    root.current = finishedWork
    commitAllLifeCycles()
}

每一個子函數都實現了一個循環來遍歷副作用線性表並檢查副作用的類型來更新 :

  • commitBeforeMutationLifecycles : 遍歷並檢查節點是否有 Snapshot 副作用標記。

    function commitBeforeMutationLifecycles () {
        while (nextEffect !== null) {
            const effectTag = nextEffect.effectTag
            if (effectTag & Snapshot) {
                const current = nextEffect.alternate
                commitBeforeMutationLifeCycles(current, nextEffect)
            }
            nextEffect = nextEffect.nextEffect
        }
    }
    
  • commitAllHostEffects : 執行 DOM 更新的地方,定義了節點需要做的操作並執行它。

    function commitAllHostEffects () {
        switch (primaryEffectTag) {
            case Placement: {
                commitPlacement(nextEffect)
                ...
            }
            case PlacementAndUpdate: {
                commitPlacement(nextEffect)
                commitWork(current, nextEffect)
                ...
            }
            case Update: {
                commitWork(current, nextEffect)
                ...
            }
            case Deletion: {
                commitDeletion(nextEffect)
                ...
            }
        }
    }
    

    有趣的是在 commitDeletion 函數中 React 會調用 componentWillUnmount 方法。

  • commitAllLifecycles : React 會調用剩餘的生命週期方法 componentDidMountcomponentDidUpdate

現在我們已經了解了協調算法主要執行的過程,那我們把目光放在 Fiber 節點的設計上:整體通過一個鏈表相連,並帶有 childsiblingreturn 三個屬性。為什麼要這樣設計?

設計原則#

我們已經知道 Fiber 架構有兩個主要的階段 : render 和 commit 。

在 render 階段 React 會遍歷整個組件樹並執行一系列操作,這些操作都在 Fiber 內部執行,並且不同的元素類型會有不同的工作要處理,就像 Andrew 說的 :

當處理 UI 時,很大的一個問題是如果大量的工作同時執行,就會造成動畫掉幀。

如果 React 採用同步的方式遍歷整個組件樹並且為每個組件處理工作,這很容易就會運行超過 16ms,進而就會造成視覺上的卡頓。但是我們完全沒有必要採取同步的方式,React設計原則 中有一些關鍵點 :

  • 並不是所有的 UI 更新都需要立即生效(這樣可能會掉幀)。
  • 不同類型的更新有不同的優先級(動畫響應遠高於數據獲取)。
  • 一個基於推送的方案是由開發者決定如何調度;而一個基於拉取的方案則是由 React 決定如何調度。

基於這些原則,我們所需要實現的架構就需要做到 :

  • 暫停任務並且可以在之後恢復。
  • 為不同的任務設置不同的優先級。
  • 可以復用已完成的任務。
  • 可以終止不再需要的任務。

那什麼東西可以幫助我們來實現這些?

新型的瀏覽器(和 React Native) 實現了可以幫助解決這個問題的 API : requestIdleCallback

這個全局函數可用於對在瀏覽器空閒期間調用的函數進行排隊,簡單看一下它的使用 :

requestIdleCallback((deadline) => {
    console.log(deadline.timeRemaining(), deadline.didTimeout)
})

deadline.timeRemaining() 會顯示有多少時間可以做任何工作,deadline.didTimeout 表示是否用完分配的所有時間。timeRemaining 會在瀏覽器完成某些工作後立即更改,所以必須不斷檢查。

requestIdleCallback 實際上有些過於嚴格導致常常 不足以實現流暢的 UI 渲染,因此 React 團隊不得不重新實現自己的版本

React 這樣調用 requestIdleCallback 來安排工作,將所有要執行的放入 performWork 函數中 :

requestIdleCallback((deadline) => {
    while ((deadlne.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent)
    }
})

為了能利用好處理工作的 API,我們需要將渲染工作分解為多個增量單元。為了解決這個問題,React 重新實現了算法,從原來的依賴於內置堆棧的同步遞歸模型改為帶有鏈表和指針的異步模型。就像 Andrew 寫的那樣 :

如果只依賴於內置的堆棧,那麼它將會持續工作直到棧空,如果我們可以隨意中斷堆棧和手動操作堆棧幀,這樣不是很好嗎?這就是 React Fiber 的目的。Fiber 就是專門針對 React 組件重新實現的堆棧,你可以將單個 fiber 視作一個虛擬的堆棧幀。

遞歸遍歷#

React 官方文檔 描述了以前的遞歸過程 :

默認情況下,當遞歸一個 DOM 節點的子節點時,React 會同時迭代兩個子列表,並在出現差異時生成一個改變。

每一個遞歸調用都會往棧中加入一個幀,這是同步執行的。我們就會有這樣的組件樹 :

recursivetree

遞歸的方式是非常直觀的,但是正如我們所說的那樣,它具有限制,最大的问题就是不能夠拆分任務,不能夠靈活地控制任務。因此 React 提出了新的單鏈表樹遍歷算法,使得暫停遍歷和終止增長的堆棧成為可能。

鏈表遍歷#

Sebastian Markbage 在這裡 簡略地描述了這個算法,為了實現這個算法,我們需要一個帶有這三個屬性的數據結構 :

  • child : 指向第一個孩子節點
  • sibling : 指向第一個兄弟節點
  • return : 指向父節點

基於這樣的數據結構我們就有了這樣的組件結構 :

linkedlistfiber

基於這樣的結構我們就可以用自己的實現來替代瀏覽器的堆棧實現。

如果你想了解兩種遍歷詳細的代碼實現可以見 The how and why on React’s usage of linked list in Fiber to walk the component’s tree

最後讓我們來看一下詳細的 Fiber 節點結構,以 ClickCounterspan 為例 :

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: { count: 0 },
    pendingProps: {},
    memoizedProps: {},
    tag: 1,
    effectTag: 0,
    nextEffect: null
}
{
    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
}

alternateeffectTagnextEffect 前文已經解釋過,我們來看看其它屬性 :

  • stateNode : 指代類組件、DOM 節點或者其它與 fiber 節點相關的 React 元素類型。
  • type : 描述了這個 fiber 對應的組件。
  • tag : 定義了 fiber 的類型,決定需要處理什麼工作。(處理函數為createFiberFromTypeAndProps
  • updateQueue : 一個狀態更新、回調函數以及 DOM 更新的隊列。
  • memoizedState : 用於創建輸出 fiber 的狀態。當更新時,它反映的是當前渲染在螢幕上的狀態。
  • pendingProps : 與之相對的是 memoizedProps,前者在開始執行時被設置,後者在結束時設置。如果傳入的 pendingProps 等於 memoizedProps,則表示這個 fiber 先前的輸出可以復用,避免不必要的工作。
  • key : 作為唯一標識符可以幫助 React 確定哪些項被更改、添加或者移除。(可見lists and keys

關於 Fiber 的運作過程,還有一個視頻是非常值得去看的 : Lin Clark - A Cartoon Intro to Fiber - React Conf 2017

參考 :

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。