メインコンテンツへ移動

React Fiber を完全に理解する

無料2018-01-06#Front-End#JS#React Fiber介绍#React Fiber工作原理#React Fiber源码剖析#React Fiber guide#Fiber reconciler

既知 - 求める - 解く - 類推する

一.目標

Fiber は React コアアルゴリズムのリファクタリングであり、2 年間のリファクタリングの産物が Fiber reconciler です

核心目標:その適用性を拡大すること。アニメーション、レイアウト、ジェスチャーを含む。5 つの具体的な目標に分かれる(後の 2 つはおまけ):

  • 中断可能な作業を小さなタスクに分割する

  • 進行中の作業の優先順位を調整し、やり直し、前回の(半分まで終わった)成果を再利用する

  • 親子タスク間で从容に切り替え(yield back and forth)、React 実行プロセス中のレイアウト更新をサポートする

  • render() が複数の要素を返すことをサポートする

  • error boundary をよりよくサポートする

そもそもの初衷は JS が制御不能に長時間実行されることを望まない(手動でスケジューリングしたい)ことだが、なぜ JS の長時間実行がインタラクション応答、アニメーションに影響するのか?

JavaScript はブラウザのメインスレッド上で実行され、たまたまスタイル計算、レイアウト、多くの場合描画と一緒に実行される。JavaScript の実行時間が長すぎると、これらの他の作業をブロックし、フレームドロップを引き起こす可能性がある。

Optimize JavaScript Execution より引用)

React は Fiber リファクタリングを通じてこの制御不能な現状を変え、インタラクション体験をさらに向上させることを望んでいる

P.S. Fiber 目標に関する詳細情報は、Codebase Overview を参照

二.重要な特性

Fiber の重要な特性は以下の通り:

  • 増分レンダリング(レンダリングタスクをブロックに分割し、複数のフレームに分散)

  • 更新時に一時停止、中止、レンダリングタスクの再利用が可能

  • 異なるタイプの更新に優先度を付与

  • 並行方面の新しい基礎能力

増分レンダリングはフレームドロップの問題を解決するために使用され、レンダリングタスクを分割した後、毎回一小節だけを行い、一節終えたら時間制御権をメインスレッドに返す。以前のように長時間占有しない。この戦略は cooperative scheduling(協調的スケジューリング)と呼ばれ、オペレーティングシステムの 3 つのタスクスケジューリング戦略の 1 つ(Firefox も真の DOM にこの技術を適用)

また、React 自身のkiller featureは virtual DOM。2 つの理由:

  • UI のコーディングが簡単になった(ブラウザがどうすべきか気にするのではなく、次の UI を React に伝える)

  • DOM が virtual できるなら、他の(ハードウェア、VR、native App)もできる

React の実装は 2 部分に分かれる:

  • reconciler ある時点の前後 2 版の UI の差異を探す。以前の Stack reconciler と現在の Fiber reconciler を含む

  • renderer プラグイン式で、プラットフォーム関連の部分。React DOM、React Native、React ART、ReactHardware、ReactAframe、React-pdf、ReactThreeRenderer、ReactBlessed などを含む

この波は reconciler の徹底的な改造であり、killer feature の強化

三.fiber と fiber tree

React 実行時存在する 3 種類のインスタンス:

DOM 真の DOM ノード
-------
Instances React が維持する vDOM tree node
-------
Elements UI がどのように見えるかを記述(type, props)

Instances は Elements に基づいて作成され、コンポーネントおよび DOM ノードの抽象表現。vDOM tree はコンポーネント状態およびコンポーネントと DOM ツリーの関係を維持

初回レンダリングプロセス中に vDOM tree を構築し、後続更新が必要な時(setState())、vDOM tree を diff して DOM change を得て、DOM change を DOM ツリーに適用(patch)

Fiber 以前の reconciler(Stack reconciler と呼ばれる)はトップダウンで再帰的にmount/updateし、中断不可(メインスレッドを継続占有)。これによりメインスレッド上のレイアウト、アニメーションなどの周期的タスクおよびインタラクション応答が即座に処理できず、体験に影響

Fiber がこの問題を解決する思路は、レンダリング/更新プロセス(再帰 diff)を一連の小さなタスクに分割すること。毎回ツリーの一小部分をチェックし、終えたら次のタスクを続ける時間があるか確認。あれば続け、なければ自分を一時停止し、メインスレッドが暇な時に継続

増分更新にはより多くのコンテキスト情報が必要。以前の vDOM tree は明らかに満足させられないため、fiber tree(Fiber コンテキストの vDOM tree)を拡張。更新プロセスは入力データおよび既存の fiber tree に基づいて新しい fiber tree(workInProgress tree)を構築するプロセス。したがって、Instance レイヤーにこれらのインスタンスが追加:

DOM
    真の DOM ノード
-------
effect
    各 workInProgress tree ノード上に effect list がある
    diff 結果を格納するために使用
    現在のノード更新完了時に effect list を上に merge(queue が diff 結果を収集)
- - - -
workInProgress
    workInProgress tree は reconcile プロセス中に fiber tree から構築された現在の進捗スナップショット。ブレークポイント回復に使用
- - - -
fiber
    fiber tree は vDOM tree と類似。増分更新に必要なコンテキスト情報を記述するために使用
-------
Elements
    UI がどのように見えるかを記述(type, props)

注意:破線上の 2 層はすべて一時的な構造。更新時のみ有用で、日常は継続維持しない。effectとは side effect のことで、行われる予定の DOM change を含む

fiber tree 上の各ノードの主な構造(各ノードをfiberと呼ぶ)は以下の通り:

// fiber tree ノード構造
{
    stateNode,
    child,
    return,
    sibling,
    ...
}

return は現在のノード処理完了後、誰に自分の成果(effect list)を提出すべきかを示す

P.S. fiber tree は実際には単方向リンクリスト(Singly Linked List)ツリー構造。react/packages/react-reconciler/src/ReactFiber.js を参照

P.S. 小 fiber と大 Fiber に注意。前者は fiber tree 上のノードを表し、後者は React Fiber を表す

四.Fiber reconciler

reconcile プロセスは 2 つのフェーズ(phase)に分かれる:

  1. (中断可能)render/reconciliation workInProgress tree を構築して change を得る

  2. (中断不可)commit これらの DOM change を適用

render/reconciliation

fiber tree を青本とし、各 fiber を作業ユニットとして、トップダウンでノードごとにworkInProgress tree(構築中の新 fiber tree)を構築

具体的なプロセスは以下の通り(コンポーネントノードを例に):

  1. 現在のノードが更新を必要としない場合、直接子ノードを clone して 5 にジャンプ。更新する場合は tag を付ける

  2. 現在のノード状態を更新(props, state, contextなど)

  3. shouldComponentUpdate() を呼び出し、false の場合、5 にジャンプ

  4. render() を呼び出して新しい子ノードを取得し、子ノードに fiber を作成(作成プロセスは既存の fiber をできるだけ再利用。子ノードの増減もここで発生)

  5. child fiber が生成されなかった場合、この作業ユニット終了。effect list を return にマージし、現在のノードの sibling を次の作業ユニットとする。そうでなければ child を次の作業ユニットとする

  6. 残りの利用可能時間がなくなった場合、次回メインスレッドが暇な時を待って次の作業ユニットを開始。そうでなければ、直ちに開始

  7. 次の作業ユニットがなくなった場合(workInProgress tree のルートノードに戻る)、第 1 フェーズ終了。pendingCommit 状態に入る

実際には 1-6 の作業ループ。7 は出口。作業ループは毎回 1 つのことだけを行い、終えたら息継ぎするかどうか確認。作業ループ終了時、workInProgress tree のルートノード身上的 effect list が収集したすべての side effect(1 つ終えるごとに上にマージするため)

したがって、workInProgress tree を構築するプロセスは diff のプロセス。requestIdleCallback を通じて一組のタスクの実行をスケジューリング。1 つのタスクを完了するごとに戻って割り込み(より緊急の)がないか確認。1 組のタスクを完了するごとに、時間制御権をメインスレッドに返し、次回requestIdleCallback コールバックを待って workInProgress tree の構築を継続

P.S. Fiber 以前の reconciler は Stack reconciler と呼ばれる。これらのスケジューリングコンテキスト情報がシステムスタックによって保存されるため。以前は一気に完了し、スタックを強調しても意味がないが、名前を付けるのは Fiber reconciler と区別しやすくするため

requestIdleCallback

メインスレッドに通知し、暇な時に教えてほしい。あまり急いでいないことをいくつかやりたい

具体的な使い方は以下の通り:

window.requestIdleCallback(callback[, options])
// 示例
let handle = window.requestIdleCallback((idleDeadline) => {
    const {didTimeout, timeRemaining} = idleDeadline;
    console.log(`超时了吗?${didTimeout}`);
    console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
    // do some stuff
    const now = +new Date, timespent = 10;
    while (+new Date < now + timespent);
    console.log(`花了${timespent}ms 搞事情`);
    console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
}, {timeout: 1000});
// 输出结果
// 超时了吗?false
// 可用时间剩余 49.535000000000004ms
// 花了 10ms 搞事情
// 可用时间剩余 38.64ms

注意requestIdleCallback スケジューリングはスムーズな体験を実現することを望むだけで、絶対に何かを保証できるわけではない。例えば:

// do some stuff
const now = +new Date, timespent = 300;
while (+new Date < now + timespent);

搞事情(React 中のライフサイクル関数など時間上 React 制御外のものを対応)に 300ms かけたら、どんなメカニズムもスムーズさを保証できない

P.S. 一般に残りの利用可能時間は 10-50ms 程度。スケジューリング空間はあ��り裕福ではない

commit

第 2 フェーズは直接一気に完了:

  1. effect list を処理(3 種類の処理を含む:DOM ツリーの更新、コンポーネントライフサイクル関数の呼び出しおよび ref など内部状態の更新)

  2. 対終了。第 2 フェーズ終了。すべての更新が DOM ツリーに commit された

注意、本当に一気に完了(同期実行、停止を叫べない)する。このフェーズの実際の作業量は比較的大きい。そのため、最後の 3 つのライフサイクル関数の中で重い作業をしないように

ライフサイクル hook

ライフサイクル関数も 2 つのフェーズに分かれる:

// 第 1 フェーズ render/reconciliation
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate

// 第 2 フェーズ commit
componentDidMount
componentDidUpdate
componentWillUnmount

第 1 フェーズのライフサイクル関数は複数回呼び出しされる可能性がある。デフォルトで low 優先度(後で紹介する 6 種類の優先度の 1 つ)で実行。高優先度タスクに中断された場合、後で再実行

五.fiber tree と workInProgress tree

ダブルバッファ技術(double buffering)。[redux 中のnextListeners](/articles/redux 源码解读/#articleHeader7) のように。fiber tree を主とし、workInProgress tree を従とする

ダブルバッファが具体的に指すのは workInProgress tree 構築完了。得られるのが新しい fiber tree。そして新しきを喜び旧しきを厭う(current ポインタを workInProgress tree に向け、古い fiber tree を捨てる)

こうする利点:

  • 内部オブジェクト(fiber)を再利用可能

  • メモリ割り当て、GC の時間オーバーヘッドを節約

各 fiber 上にalternate 属性があり、これも 1 つの fiber を指す。workInProgress ノード作成時に優先的にalternate を取得。ない場合は作成:

let workInProgress = current.alternate;
if (workInProgress === null) {
  //...这里很有意思
  workInProgress.alternate = current;
  current.alternate = workInProgress;
} else {
  // We already have an alternate.
  // Reset the effect tag.
  workInProgress.effectTag = NoEffect;

  // The effect list is no longer valid.
  workInProgress.nextEffect = null;
  workInProgress.firstEffect = null;
  workInProgress.lastEffect = null;
}

注釈が指す通り、fiber と workInProgress は互いに参照を保持。「新しきを喜び旧しきを厭う」後、旧 fiber は新 fiber 更新の予備スペースとして機能。fiber インスタンスの再利用目的を達成

P.S. ソースコード内にはいくつか面白いテクニックがある。例えば tag のビット演算

六.優先度戦略

各作業ユニット実行時に 6 種類の優先度:

  • synchronous 以前の Stack reconciler 操作と同じ。同期実行

  • task next tick 前に実行

  • animation 次フレーム前に実行

  • high 近い将来に直ちに実行

  • low 少し遅延(100-200ms)実行しても問題ない

  • offscreen 次回 render 時または scroll 時に実行

synchronous は首屏(初回レンダリング)用。できるだけ速く。UI スレッドをブロックするかどうかは気にしない。animation はrequestAnimationFrameを通じてスケジューリング。これにより次フレームで直ちにアニメーションプロセスを開始可能。後の 3 つはすべてrequestIdleCallback コールバックによって実行。offscreen は現在隠れた、画面外の(見えない)要素を指す

高優先度の例えばキーボード入力(直ちにフィードバックを得たい)。低優先度の例えばネットワークリクエスト。コメントを表示させるなど。また、緊急のイベントは割り込みを許可

このような優先度メカニズムには2 つの問題が存在:

  • ライフサイクル関数はどのように実行するか(頻繁に中断される可能性):トリガー順序、回数が保証されない

  • starvation(低優先度餓死):高優先度タスクが多い場合、低優先度タスクは実行機会が全くない(餓死する)

ライフサイクル関数の問題には公式例がある:

low A
componentWillUpdate()
---
high B
componentWillUpdate()
componentDidUpdate()
---
restart low A
componentWillUpdate()
componentDidUpdate()

第 1 の問題は解決中(まだ解決していない)。ライフサイクルの問題は一部の既存 App を破壊し、スムーズなアップグレードに困難をもたらす。Fiber チームはエレガントなアップグレード経路を見つけるために努力中

第 2 の問題は完了した操作の再利用(reusing work where it can)を通じて緩和。聞こえるところでは解決方法を模索中

これら 2 つの問題自体は解決があまり良くない。どの程度解決するかという問題。例えば第 1 の問題。コンポーネントライフサイクル関数に副作用が多すぎる場合、無傷で解決する方法がない。これらの問題は Fiber アップグレードに一定の抵抗をもたらすが、決して解けないわけではない(一歩譲って、新機能が十分な魅力があれば、第 1 の問題は各自で方法を考える)

七.まとめ

既知

React は一部の応答体験要求が高いシーンで適用しない。例えばアニメーション、レイアウト、ジェスチャー

根本的な原因はレンダリング/更新プロセスが開始されると中断できず、メインスレッドを継続占有。メインスレッドは JS 実行に忙しく、他を顧みる暇がない(レイアウト、アニメーション)。フレームドロップ、遅延応答(さらには無応答)などの不佳体験を引き起こす

求める

メインスレッド長時間占有問題を徹底的に解決できるメカニズム。眼前の問題に対応できるだけでなく、長遠な意義も持つ

The "fiber" reconciler is a new effort aiming to resolve the problems inherent in the stack reconciler and fix a few long-standing issues.

解く

レンダリング/更新プロセスを小块タスクに分割。合理的なスケジューリングメカニズムを通じて時間を制御(より細かい粒度、より強い制御力)

では、5 つの子問題に直面:

1.何を分割するか?何が分割できないか?

レンダリング/更新プロセスを 2 つのフェーズ(diff + patch)に分割:

1.diff ~ render/reconciliation
2.patch ~ commit

diff の実際の作業はprevInstancenextInstanceの状態を比較し、差異および対応する DOM change を找出す。diff は本質的にいくつかの計算(走査、比較)。分割可能(半分計算して後で続けて計算)

patch フェーズは今回の更新中のすべての DOM change を DOM ツリーに適用。一連の DOM 操作。これらの DOM 操作は見たところ分割可能(change list に従って一段一段行う)に見えるが、このようにすると一方で DOM 実際状態と維持する内部状態が不一致になる可能性があり、另外体験にも影響。また、一般シーン下、DOM 更新の耗时は diff およびライフサイクル関数耗时に比べて大したことない。分割の意義はあまり大きくない

したがって、render/reconciliation フェーズの作業(diff)は分割可能。commit フェーズの作業(patch)は分割不可

P.S. diff と reconciliation はただの対応関係。等価ではない。もし区別する必要があるなら、reconciliation は diff を含む:

This is a part of the process that React calls reconciliation which starts when you call ReactDOM.render() or setState(). By the end of the reconciliation, React knows the result DOM tree, and a renderer like react-dom or react-native applies the minimal set of changes necessary to update the DOM nodes (or the platform-specific views in case of React Native).

Top-Down Reconciliation より引用)

2.どのように分割するか?

まずでたらめにいくつかの diff 作業分割方案を考える:

  • コンポーネント構造で分割。良く分けられない。各コンポーネント更新の作業量を予測できない

  • 実際の工序で分割。例えばgetNextState(), shouldUpdate(), updateState(), checkChildren()に分け、いくつかのライフサイクル関数を挟む

コンポーネントで分割するのは太すぎる。明らかに大コンポーネントに対して不公平。工序で分割するのは細すぎる。タスクが多すぎる。頻繁なスケジューリングは割に合わない。では適切な分割単位はあるか?

ある。Fiber の分割単位は fiber(fiber tree 上の 1 つのノード)。実際には仮想 DOM ノードで分割。fiber tree は vDOM tree に基づいて構築されたもの。ツリー構造は一模一样。ノードが携える情報のみが差異

したがって、実際には vDOM node 粒度の分割(fiber を作業ユニットとして)。各コンポーネントインスタンスと各 DOM ノード抽象表現のインスタンスはすべて 1 つの作業ユニット。作業ループ中、毎回 1 つの fiber を処理。処理完了後、全体の作業ループを中断/一時停止可能

3.どのようにタスクをスケジューリングするか?

2 部分に分ける:

  • 作業ループ

  • 優先度メカニズム

作業ループは基本的なタスクスケジューリングメカニズム。作業ループ中は毎回 1 つのタスク(作業ユニット)を処理。処理完畢後に一息つく機会がある:

// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

shouldYield は時間を使い果たしたかどうか確認(idleDeadline.timeRemaining())。使い果たしていない場合、次のタスクを継続処理。使い果たした場合、終了して時間制御権をメインスレッドに返し、次回requestIdleCallback コールバックを待って継続:

// If there's work left over, schedule a new callback.
if (nextFlushedExpirationTime !== NoWork) {
  scheduleCallbackWithExpiration(nextFlushedExpirationTime);
}

つまり、(突発事件を考慮しない)正常なスケジューリングは作業ループによって完了。基本規則は:各作業ユニット終了時に次のタスクを行う時間があるか確認。時間がない場合は先に「一時停止」

優先度メカニズムは突発事件と最適次序を処理するために使用。例えば:

  • commit フェーズになったら、優先度を上げる

  • 高優タスクを半分やってエラーが出たら、優先度を下げる

  • 隙を見て低優タスクに注目。餓死させない

  • 対応 DOM ノードが此刻不可視の場合、最低優先度に下げる

これらの戦略はタスクスケジューリングを動的に調整するために使用。作業ループの補助メカニズム。最初に最も重要なことを行う

4.どのように中断/ブレークポイント回復するか?

中断:現在処理中の作業ユニットをチェック。現在の成果(firstEffect, lastEffect)を保存。tag を修正してマーク。迅速に收尾してもう 1 つのrequestIdleCallback を開始。次回機会があれば継続

ブレークポイント回復:次回この作業ユニットを処理する時、tag が中断されたタスクか確認。未完成の部分を継続またはやり直し

P.S. 時間を使い果たして「自然」に中断しても、高優タスクに乱暴に中断されても、中断メカニズムにとっては同じ

5.どのようにタスク結果を収集するか?

Fiber reconciliation の作業ループは具体的に以下の通り:

  1. ルートノードの優先度が最も高い workInProgress tree を見つけ、その待処理ノード(コンポーネントまたは DOM ノードを表す)を取得

  2. 現在のノードが更新を必要とするか確認。必要ない場合、直接 4 に

  3. マーク(tag を付ける)。自分を更新(コンポーネントはpropscontextなどを更新。DOM ノードは DOM change を記録)。子供に workInProgress node を生成

  4. 子ノードが生成されなかった場合、effect list(DOM change を含む)を親にマージ

  5. 子供または兄弟を待処理ノードとし、次の作業ループに入る準備。待処理ノードがない場合(workInProgress tree のルートノードに戻る)、作業ループ終了

各ノード更新終了時に上に effect list をマージしてタスク結果を収集。reconciliation 終了後、ルートノードの effect list に DOM change を含むすべての side effect が記録

類推する

タスクが分割可能(最終的に完全な effect list が得られればよい)なら、並行実行(複数の Fiber reconciler + 複数の worker)を許可。首屏もより簡単にブロックロード/レンダリング(vDOM 森林)

並行レンダリングの場合、噂では Firefox テスト結果によると、130ms のページは 30ms だけで搞定。そのためこの方面では期待に値する。React はすでに準備完了。これが React Fiber コンテキストでよく聞く待unlockのより多くの特性の 1 つ

八.ソースコード簡析

15 から 16 へ、ソースコード構造は大きな変化が発生:

  • もはやmountComponent/updateComponent()が見えない。(beginWork/completeWork/commitWork())に分割再組成

  • ReactDOMComponent も削除。Fiber 体系下で DOM ノード抽象は ReactDOMFiberComponent で表す。コンポーネントは ReactFiberClassComponent で表す。以前は ReactCompositeComponent

  • Fiber 体系の核心メカニズムはタスクスケジューリングを担当する ReactFiberScheduler。以前の ReactReconciler に相当

  • vDOM tree が fiber tree に。以前は上から下への簡単なツリー構造。現在は単方向リンクリストに基づくツリー構造。維持するノード関係がより多い

fiber tree の図を見て感受一下:

[caption id="attachment_1628" align="alignnone" width="970"]fiber-tree fiber-tree[/caption]

実際少し考えれば、Stack reconciler から Fiber reconciler へ、ソースコードレベルでは再帰をループに変更しただけ(もちろん、実際に行ったことは再帰をループに変更するだけでなく、これは第一歩)

总之、ソースコード変化は非常に大きい。Fiber 思路を事前に了解していない場合、ソースコードを見るのは比較艱難(React[15-] のソースコードを見たことがあれば、より迷惑しやすい)

P.S. この 清明フローチャート は正式に退役

参考資料

コメント

コメントはまだありません

コメントを書く