[翻譯] React Fiber 現狀確認

原文:http://blog.koba04.com/post/2017/04/25/a-state-of-react-fiber/
- 若翻譯有誤還請指正

因為在 F8 也有關於 React Fiber 的發表,注意到 React Fiber 的人逐漸變多,所以想在這邊對 React Fiber 的現狀做簡單的說明。

講到 React 全面改寫,雖然有些人以為連使用方式都完全改變了,但實際上只有內部運作改變,從使用者角度來看則是沒什麼差別;當然也不需要安裝叫 react-fiber 的 package。

相較之下,因為 v16 保持與現在版本相同的實作及相容性,當 v16 發布時,若是使用 v15.5 的話我想無痛升級至 v16 是沒有問題的。而且,要是沒有說的話也不會發現內部運作已經改變了。

懶人包 [TL;DR]

  • v16 基本上和 v15 的運作方式是一樣的。也就是說效能(應該)也不會改變。
  • render() method 能夠返回像 [ <Foo />, <Bar /> ] 這樣的陣列,不需要再用多包一層 ReactElement 的方式回傳。
  • v17 預設會變成非同步 rendering。v16 時似乎也會將一些 API 改為預設,如想嚐鮮可使用 ReactDOM.unstable_deferredUpdates 試試。
  • v17 並非追求更快速的效能優化,而是預計針對排程(scheduling)更新處理做出更彈性的設計,以及讓使用者在輸入時不被 UI 更新給 block 住。

注意

以下的資訊都是關於內部運作,如果只是純粹想使用 React 的人可以左轉。對多數人來說,到 v17 前,以上關於 React Fiber 的資訊就足夠了。

React 的組成

在解釋新的 Fiber 前,先來一起看看 v16 現在的實作吧。React 的 source code 的組成大概像下面這樣。

src
├── fb
├── isomorphic
│ ├── children
│ ├── classic
│ │ ├── class
│ │ ├── element
│ │ └── types
│ ├── hooks
│ └── modern
│ ├── class
│ └── element
├── renderers
│ ├── art
│ ├── dom
│ │ ├── fiber
│ │ ├── shared
│ │ └── stack
│ ├── native
│ ├── noop
│ ├── shared
│ │ ├── fiber
│ │ ├── hooks
│ │ ├── shared
│ │ ├── stack
│ │ └── utils
│ └── testing
│ └── stack
├── shared
│ ├── types
│ └── utils
└── test
(以上省略測試部分的資料夾)

如上述,可以在 renderers 目錄底下發現,Fiber 是 renderer 的其中一員。和 fiber 同一層的 stack 是目前實作的 renderer。

除了 renderers/shared/ 底下有 fiberstack 以外,renderers/dom/ 底下也有 fiberstack。兩者分別是目前 renderer 內部實作的兩種類型,並根據其支援對應的 DOM 和 Native 組合成上述的資料夾結構。

Fiber 和 Stack 在 React 內部擔任 reconciliation 的角色。reconciliation 是指從 ReactElement 生成 Component 實體(instance)、計算差異(diff)並應用到 Host (DOM) 及呼叫 Lifecycle methods 的流程。計算差異並應用到 Host 的部分會依據不同的 Host 而有不同的實作;同樣的,應用方式也會依據 reconciliation 的實作而有所差異,所以 renderers/dom/ 底下才會同時存在 Stack 和 Fiber 兩種實作。

Host 會根據 React 的執行環境改變:在瀏覽器的話就是 DOM、在 ReactNative 的話就是 Native 的 View。

Stack

那麼,接下來首先就讓我們簡單的介紹 Stack renderer。Stack 是類似將 ReactElement 變成樹狀(tree)結構並從上到下依序處理。

Stack

從上圖可以看到巢狀的 mountComponent() 被呼叫,並且是以同步的方式在運作。也就是說,如果是從最上層的 Component 重新 render 的情況的話,

  • 所有 child component 的 render() 都會被執行過並重新建構成 ReactElement tree
  • 更新時,會和先前的 ReactElement tree 做比較
  • 將差異處應用到 Host
  • 呼叫 Lifecycle method

以上的步驟均為同步處理,因此若 tree 的結構較為複雜的話,運作這些步驟將會很花時間,而且 UI 還會完全被 block 住。

或者可以看當還在執行動畫或是使用者打字等需要即時執行的動作的狀況:因為 Stack 所有的動作均為同步處理,當程式處理 server 回傳的結果並呼叫 React 重新 render 時,動畫和打字等執行都會被中斷。雖然這些不是針對效能做調教時會重視的指標,但對於使用者體驗來說是相當重要的。

React Fiber 就是為了解決上述的問題而誕生的。

順帶一提,跟 Stack 有關的原始碼似乎會在 v16 發佈時從 React 內部被移除。

Fiber

從 wikipedia 上的敘述來看,Fiber 是一種「最輕量化的緒程(lightweight threads)」。

React Fiber 是以一個「Fiber」為單位來進行 reconciliation。基本上可以把 Fiber 想像成對應到一個 ReactElement。

嚴謹一點來說,ReactElement 的單位和 Fiber 不一定相等,Fiber 甚至可能透過 fiber.alternate 來將自己複製(clone)後再次利用,不過以一個 Fiber 對應到一個 ReactElement 來想像的話比較容易理解。

Flow 的形態宣告來看的話,Fiber 的定義如下:

// 省略一部分
type Fiber = {
tag: TypeOfWork,
key: null | string,
type: any,
stateNode: any,
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & {_stringRef: ?string}),
pendingProps: any, // This type will be more specific once we overload the tag.
memoizedProps: any, // The props used to create the output.
updateQueue: UpdateQueue | null,
memoizedState: any,
effectTag: TypeOfSideEffect,
nextEffect: Fiber | null,
firstEffect: Fiber | null,
lastEffect: Fiber | null,
pendingWorkPriority: PriorityLevel,
progressedPriority: PriorityLevel,
progressedChild: Fiber | null,
progressedFirstDeletion: Fiber | null,
progressedLastDeletion: Fiber | null,
alternate: Fiber | null,
};

雖然在這邊我們不針對每個屬性(property)一一做介紹,不過我們可以得知 returnchildsibling 等屬性會 reference 到其他的 Fiber — Fiber 是一種 Linked List 的結構。Stack 是 tree 的結構,會沿著樹狀階層結構向下處理;Fiber 則會依照 returnchildsibling 的順序來針對該 ReactElement 做處理。讓我們來看看下面這張 call stack tree 就可以瞭解了。

Call Stack Tree of Stack

從圖中可以看到,Fiber 並不會像 Stack 一樣累積過深的 function call stack。

Stack 在執行時是以一個 tree 為單位處理;Fiber 則是以一個 Fiber 的單位執行。Stack 只能同步的執行;Fiber 則可以針對該 Fiber 做排程處理。也就是說,假設現在有個 Fiber 其 Linked List 結構為 A → B → C,當 A 執行到 B 被中斷的話,可以之後再次執行 B → C,這對 Stack 的同步處理結構來說是很難達到的。那如果用 Generators 呢?對於這件事,Sebastian Markbåge 有在下面這個 issue 說明為什麼不使用 Generators,有興趣的人可以看看。

Fiber 的排程

那麼我們就來具體的看看排程是如何運作的。

Fiber 總共有 beginWorkcompleteWorkcommitWork 三種階段(phase)。beginWork 會執行 component 實體化(instantiate)、呼叫 component 的 render() 方法、以及進行 shouldComponentUpdate() 結果的比較。

completeWork 會進行 effectTag 的設定來標記副作用(Side Effect),以及 Host 的實體化等工作。(completeWork 只會在末端的 Host 執行)

副作用的定義如下:

module.exports = {
NoEffect: 0, // 0b0000000
Placement: 1, // 0b0000001
Update: 2, // 0b0000010
PlacementAndUpdate: 3, // 0b0000011
Deletion: 4, // 0b0000100
ContentReset: 8, // 0b0001000
Callback: 16, // 0b0010000
Err: 32, // 0b0100000
Ref: 64, // 0b1000000
};

commitWork 則會呼叫 componentDid(Mount|Update) 等 Lifecycle method,以及將基於 completeWork 設定的 effectTag 的結果反映給 Host。

在這三個階段中,beginWorkcompleteWork 是以 Fiber 為單位來執行,commitWork 則會在所有 Fiber 處理完後才會執行。

例如,像以下這樣結構的 Component,

Text = () => '...';
List = () => [
<div>...</div>,
<div>...</div>,
<div>...</div>,
];
class App extends React.Component {
render() {
return (
<main>
<h2>...</h2>
<p>...</p>
<div>
<Text />
<List />
</div>
</main>
);
}
}

將會如以下流程被執行。

  1. beginWork … (HostRoot)
  2. beginWork … <App> (ClassComponent)
  3. beginWork … <main> (HostComponent)
  4. beginWork, completeWork … <h2> (HostComponent)
  5. beginWork, completeWork … <p> (HostComponent)
  6. beginWork … <div> (HostComponent)
  7. beginWork … <Text> (FunctionalComponent)
  8. beginWork completeWork … ‘…’ (HostText)
  9. beginWork … <List> (Functional Component)
  10. beginWork, completeWork … : <div> (HostComponent)
  11. beginWork, completeWork … : <div> (HostComponent)
  12. beginWork, completeWork … : <div> (HostComponent)
  13. commitAllWork … (HostRoot)

React Fiber 在非同步 rendering 的情況下,權重較低(待會會提到)的步驟會使用 requestIdleCallback(不支援的話會使用 polyfill)來執行,他們會被排到後面被非同步的執行。在使用 requestIdleCallback 時可以從 callback function 利用 IdleDeadline.timeRemaining() 取得閒置時間,因此當時間已不夠處理剩下的步驟的話,Fiber 就會重新呼叫 requestIdleCallback 將剩餘的步驟再往後排程;如此一來,權重較低的 UI 處理或其他處理就不會被 block 住了。

下圖是 Fiber 以同步模式下執行的 stack 結果。可以看到全部都是同步的執行。在這個執行的區間內 UI 會完全被 block 住。

React Fiber — Sync Mode

下圖則是以非同步模式執行同一段程式的 stack 結果。可以發現 call stack 變得斷斷續續的,這樣就能夠實現 non-block UI。右邊較細的 call stack 則是commitWork 階段,從這可以發現 rendering(紫色區塊的部份)只會發生在 commitWork 之後。

React Fiber — Async Mode

在這邊可以注意到的一點是,在 beginWorkcompleteWork 之間不會有 render 至 Host View 這樣的副作用。例如若在非同步處理中進行 render,View 會以部分更新的方式執行,而這樣會導致 UI 看起來會有點 lag。React Fiber 則是會將所有的 View 更新動作在 commitWork 執行,因此並不會發生這種狀況;反過來說,commitWork 很容易花上許多時間,造成幀速(frame)降低,因此做效能調教時要特別小心。另外,因為 componentDid(Mount|Update) 也是在這個期間執行,因此同樣要注意不要在裡面做複雜的處理來增加負擔。

題外話,為了解決上述的 Lifecycle methods 真的遇到瓶頸的狀況,也有提案要將這些 Lifecycle method 做成 Promise based 的非同步 API。 還有,其實在 mount 時 completeWork 也會處理 SideEffect,這是為了處理 mount 時 DOM 還沒被加到 HostContainer、加入後也不會發生顯示問題而作的。這也是為了維持幀速而加入的技巧之一。

接下來,上面有提到除了會使用 requestIdleCallback 來進行排程的工作之外,還會使用權重(Priority)來針對排程做分類。

權重的定義如下:

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6;
module.exports = {
NoWork: 0, // No work is pending.
SynchronousPriority: 1, // For controlled text inputs. Synchronous side-effects.
TaskPriority: 2, // Completes at the end of the current tick.
AnimationPriority: 3, // Needs to complete before the next frame.
HighPriority: 4, // Interaction that needs to complete pretty soon to feel responsive.
LowPriority: 5, // Data fetching, or result from updating stores.
OffscreenPriority: 6, // Won't be visible but do the work in case it becomes visible.
};

藉由給予每個更新程序不同權重,取得 API response 等權重較低的任務就能不中斷使用者互動或是動畫等等的更新處理。

從下面連結的範例可以看到以上敘述的效果。在 Async mode 時,5000 個 list item 會以每 100ms 的速度,在 LowPriority 權重下更新;Sync mode 則是使用 SynchronousPriority 權重來做同步 render。

請在 input 欄位隨意輸入。在 Async mode 輸入時 list item 不會被更新,雖然打字多少還是有點頓,但仍然能夠順暢的進行 render;Sync mode 的話,輸入時 list item 會一邊即時更新並干擾使用者的輸入。

權重較低的任務會用 requestIdleCallback 在閒置時執行,權重較高的任務則會使用 requestAnimationFrame 盡快的同步 render 出來。

權重低的任務在執行時,若有權重高的任務進來,則權重低的任務會被中斷,並執行權重高的任務。權重高的任務結束後,再繼續執行權重低的任務。這種情況下,權重較低的 Fiber 在執行時被中斷前,尚未被處理且權重高的 Fiber 是可以被重新利用的。

就像上面所說的,因為任務的中斷,Fiber 能夠被重複地利用。在非同步的情況下 componentWillMount 等 Lifecycle method 會被重複呼叫。componentDidMount 等在 commitWork 被呼叫的 method 則不會被重複執行。

另外,有個叫做 OffscreenPriority 的權重。利用它,我們能夠針對第一次 render 不需要的部分做 pre-render 或是 double buffering。例如對 ReactDOM 來說,有 hidden 屬性的 DOM 就會被當作 OffscreenPriority

雖然還有其他像 AnimationPriority 等等的權重,不過現在還沒有能夠控制權重的 API,所以目前還不知道該如何使用。(官方團隊似乎會在 facebook.com 一邊實驗非同步 render 一邊決定 API 規格)

關於其他更細節的變動,我想從 ReactIncremental-test.js 的測試來看就可以發現能夠做到哪些事了。

在這裡使用的 ReactNoop 是測試用的 renderer,可以在沒有 UI 的測試環境下彈性的控制 timeRemaining ,是在 React Fiber 開發初期就已經拿來使用的 renderer。如果要做 custom renderer 的話,或許會是個不錯的參考範例。

Error Boundary

另外,雖然和 Fiber 本身沒有直接關係,但官方似乎會支援 Error Boundary 的機制。Error Boundary 是可以在當 child component 的 render 出錯時用來將 parent 重新 render 的機制(雖然出錯的 child 不會顯示任何東西)。

class App extends React.Component {
constructor(props) {
super(props);
this.state = {
error: null
};
}
// 定義 Error Handling
unstable_handleError(error) {
this.setState({error});
}
render() {
if (this.state.error) {
return <div>有錯誤</div>;
}
return this.props.children;
}
}

CoroutineComponent

Fiber 有 CoroutineComponentCoroutineHandlerPhaseYieldComponent 三種 Component。雖然還不太清楚用途,有一種可能是當 parent component 執行 render 到一半被中斷時,可以在 child component 接收到結果後再繼續 parent 的 render。舉例來說,代表 Layout 的 Component 正在試著 render 子元件 ,接著拿到 size 等結果後會再一次試著 render 父元件。

ReactCoroutin-test.js 有關於上述機制的測試,或許看完後會更了解。state 有種反正有在動就好的感覺。

Custom Renderer

在 Stack 時,要做 Custom Renderer 的話就一定要 hack;React Fiber 的話則只需要透過 Flow 給型態,非常的淺顯易懂。

export type HostConfig<T, P, I, TI, PI, C, CX, PL> = {
getRootHostContext(rootContainerInstance: C): CX,
getChildHostContext(parentHostContext: CX, type: T): CX,
getPublicInstance(instance: I | TI): PI,

createInstance(
type: T,
props: P,
rootContainerInstance: C,
hostContext: CX,
internalInstanceHandle: OpaqueHandle,
): I,
appendInitialChild(parentInstance: I, child: I | TI): void,
finalizeInitialChildren(
parentInstance: I,
type: T,
props: P,
rootContainerInstance: C,
): boolean,

prepareUpdate(
instance: I,
type: T,
oldProps: P,
newProps: P,
rootContainerInstance: C,
hostContext: CX,
): null | PL,
commitUpdate(
instance: I,
updatePayload: PL,
type: T,
oldProps: P,
newProps: P,
internalInstanceHandle: OpaqueHandle,
): void,
commitMount(
instance: I,
type: T,
newProps: P,
internalInstanceHandle: OpaqueHandle,
): void,

shouldSetTextContent(props: P): boolean,
resetTextContent(instance: I): void,
shouldDeprioritizeSubtree(type: T, props: P): boolean,

createTextInstance(
text: string,
rootContainerInstance: C,
hostContext: CX,
internalInstanceHandle: OpaqueHandle,
): TI,
commitTextUpdate(textInstance: TI, oldText: string, newText: string): void,

appendChild(parentInstance: I | C, child: I | TI): void,
insertBefore(parentInstance: I | C, child: I | TI, beforeChild: I | TI): void,
removeChild(parentInstance: I | C, child: I | TI): void,

scheduleAnimationCallback(callback: () => void): number | void,
scheduleDeferredCallback(
callback: (deadline: Deadline) => void,
): number | void,

prepareForCommit(): void,
resetAfterCommit(): void,

useSyncScheduling?: boolean,
};

如果想自己實際寫一個的話,可以參考前面介紹的 ReactNoop 和能將 ReactElement 返回成 JSON 的測試用 renderer ReactTestRendererReactART

但是 React 從 v16 後會變成 Flat bundle,也就是沒辦法再透過直接利用 react/lib/xxx 的方式來使用內部 library。目前還不知道會提供什麼給需要做 Custom Renderer 的開發者們。

Server Side Rendering

關於 Server Side Rendering 的部分,因為 Facebook 本身沒有使用,因此被延後尚未實作;但因為目前大家習慣的 renderToString() 是同步的,React Fiber 後要 render 出 HTML 字串又不阻擋 event loop 就變得比較容易(v16 後會變怎麼樣還不確定)。至於像 renderToStream 的話,會因為 commitWork 要產生副作用的關係而沒辦法正常的將需要 render 的 view 一次集合起來,該如何使用大家可以再想想。

v16 嘗試非同步 rendering 懶人包

  • 因為現在 ReactDOMFeatureFlagsfiberAsyncScheduling 這個 flag,如果將它 hard-coded 預設為 true 的話就能做非同步 rendering 了。但因為這樣的做法還沒有寫測試,我想應該還會有些 bug。
  • 或是利用 ReactDOM.unstable_deferredUpdates 並在其中以 lowPriority 來執行,就能變成非同步了。

其他資源

很好奇很想知道 Fiber 是什麼的人,我覺得可以看看 F8 上 Tom Occhino 的 talk,非常的淺顯易懂。

Lin Clark 在 React Conf 上用 Code Cartoon 的方式來說明 React Fiber,也非常的好懂。

Lin Clark — A Cartoon Intro to Fiber @ React Conf 2017

另外還有 Sebastian Markbåge 在 React Conf 的 Keynote 也有提到 React Fiber

Sebastian Markbåge — React Performance End to End (React Fiber) @ React Conf 2017

最後是我自己將有關 React Fiber 的資料整理起來的 repo。

那麼,變成 React Fiber 後有什麼好處?

變成 React Fiber 後,我想在比較框架效能的 benchmark 分數可能不會太好。變成 React Fiber 後,一直以來都是同步運作的 tree 會變成非同步的運作,相較之下非同步運作的優點是能夠更彈性的安排處理內容。而藉由彈性的處理,我們可更快速處理動畫並讓使用者互動運作更順暢。還有,因為有了基於非同步機制的 React Fiber,我認為以後並不會額外增加類似非同步處理的功能。

雖然這麼說,但 v16 還不會有如此改變,讓我們一起來期待以後會加上什麼功能吧。另外,對實作 React Fiber 有興趣的人也一定要讀讀看。

順道一提,React Fiber 的第一支 PR 應該是這隻。