原文:React Fiber Architecture(2026/01/14現在の情報です)
Introduction
React Fiberは、Reactの中核アルゴリズムを再実装するための継続的な取り組みで、Reactチームが2年以上にわたって行なってきた研究の集大成です。
Fiberの目的は、アニメーション、レイアウト、ジェスチャーといった分野における適性を高めることです。その最大の特徴は「incremental rendering」と呼ばれ、レンダリング処理を小さな単位(=チャンク)に分割し、複数のフレームに分散して実行できる点にあります。
その他の主要な機能として、新たな更新が入った際に処理を一時停止・中断・再利用できること、更新の種類ごとに優先度を割り当てられること、そして新しい並行処理のprimitive(=低レイヤーの機能)が上げられます。
本ドキュメントについて
Fiberには、コードを見ただけでは理解しにくい、斬新な概念がいくつも導入されています。本ドキュメントは、ReactにおけるFiberの実装を理解するために私自身が書いていたメモを整理し、他の人にも役立つ形にまとめ直したものです。
できるだけ平易な言葉で説明し、重要な用語はその都度定義しながら、外部の参考資料もなるべく多く紹介します。
私はReactチームのメンバーではなく、本ドキュメントも公式なものではありません。内容の正確さについては、Reactチームメンバーにレビューを依頼しています。
本ドキュメントは現在も作成途中です。Fiber自体が進行中のプロジェクトであり、完成までに大幅なリファクタリングが行われる可能性があります。本ドキュメントにおける設計の解説も、同様に継続して更新していく予定です。改善案や提案は大歓迎です。
このドキュメントは、読み終えた読者がFiberを十分に理解していること、また最終的にReactへのcontributeまでできるようになることを目標としています。
Review
新しい話に入る前に、いくつかの基本的な概念をおさらいしておきましょう。
「reconciliation」とは?
reconciliation(リコンシリエーション)
Reactが2つのツリーを比較し、どの部分を変更する必要があるかを判断するために用いる差分検出アルゴリズム。
update(以下、「更新」と訳します)
Reactアプリの描画に使われるデータの変更のこと。通常は
setStateの呼び出しによって発生し、最終的には再レンダリングに繋がる。
Reactにおける基本的な考え方は、「更新が起きるたびにアプリ全体が再レンダリングされるかのように考える」という点にあります。これにより、開発者は状態遷移を逐一意識する必要がなくなり、宣言的にアプリの状態を考えられるようになります。
ただ「変更のたびにアプリ全体を再レンダリングする」という実装を行おうとすると、小さなアプリなら動きますが、現実的なアプリでこれを行おうとするとパフォーマンスが非常に遅くなります。この問題を解決するために、Reactでは「(パフォーマンスを維持しながら)アプリ全体を再レンダリングしているように見せる」という最適化が行われます。この最適化の大部分を担っているのが「reconciliation(リコンシリエーション)」と呼ばれるプロセスです。
reconciliationは、一般に「仮想DOM」として理解されている仕組みの基盤となるアルゴリズムです。Reactアプリケーションをレンダリングすると、アプリの構造を表すノードのツリーが生成され、メモリ上に保持されます。その後、このツリーは描画へ反映されます(たとえばブラウザの場合は、DOM操作に変換されます)。アプリが更新されると(通常はsetState経由)、新しいツリーが生成されます。この新しいツリーを前のツリーと比較し、画面を更新するために必要な操作を導出します。
Fiberはこのreconcilerを一から書き直したものですが、Reactのドキュメントで説明されている高レベルのアルゴリズム自体は、概ねこれまでと変わりません。主なポイントは次のとおりです。
- 異なる種類のコンポーネントは、別のツリーを生成するとみなされます。Reactはこれらを差分比較せず、既存のツリーを丸ごと置き換えます
- リストの差分は
keyを使って実行されます。keyは「stable(安定していること), predictable(予測可能であること), unique(一意であること)」である必要があります
reconciliation vs rendering
DOMは、Reactが描画できるレンダリング先の一つに過ぎず、他にもReact Nativeを介したiOSやAndroidのネイティブビューを描画対象としています。(つまり"仮想DOM"という呼び方はやや誤解を招く表現ということになります)
Reactがこれほど多くの描画先に対応できるのは、reconciliationとrenderingが別々のフェーズとして設計されているからです。reconcilerはツリーのどの部分が変更されたのかを計算し、その結果をもとにrendererが実際に描画内容を更新します。
この分離設計により、React DOMとReact Nativeは、Reactコアが提供する同一のreconcilerを共有しつつ、それぞれ独自のrendererを使うことができます。
Fiberはreconcilerを再実装するもので、主にレンダリングを目的としたものではありません。ですが、この新しいアーキテクチャを導入し利点を活かすためには、renderer側の変更も必要になります。
スケジューリング
スケジューリング
処理をいつ実行するかを決定するプロセスのこと。
処理
実行する必要のある、あらゆる計算処理を指します。(多くの場合、
setStateなどの更新によって発生します)
この点については、ReactのDesign Principlesドキュメントが非常によくまとまっているため、ここではそのまま引用します。
現在のReactの実装では、ツリーを再帰的にたどり、更新されたツリー全体の
render関数を1回の処理の中で呼び出します。しかし今後、フレーム落ち1を防ぐために、一部の更新を遅延させるようになる可能性があります。これはReactの設計に一貫して見られる考え方です。多くのライブラリは、新しいデータが利用可能になった時点ですぐに処理を実行する"push型"のアプローチを採用していますが、Reactでは、必要になるまで処理を遅らせることができる"pull型"のアプローチを取っています。
Reactは汎用的なデータ処理ライブラリではなく、ユーザーインターフェースを構築するためのライブラリです。アプリケーションの中で、「どの計算処理が今必要で」「どれがそうでないか」を判断できる、独自の立ち位置にあると私たちは考えています。
画面外の要素に関する処理は後回しにできます。また、データの更新がフレームレートよりも速い場合には、更新を集約して、一度に反映することができます。さらに、フレーム落ち1を防ぐために、ユーザー操作に起因する処理(たとえばボタン操作によるアニメーション)を、重要度の低いバックグラウンド処理(ネットワーク経由で読み込まれた新しいコンテンツの描画など)よりも優先して実行することができます。
重要なポイントは次のとおりです。
-
UIにおいては、すべての更新を即座に反映する必要はない。むしろそれを行うと、無駄な処理が増え、フレーム落ちを引き起こしてユーザー体験を損なうことになる
-
更新には種類ごとに異なる優先度がある。たとえば、アニメーションに関する更新は、データストアからの更新よりも、より早く完了する必要がある
-
push型のアプローチでは、処理をどのようにスケジューリングするかをアプリケーション側(つまり開発者自身)が判断する必要がある。一方、pull型のアプローチでは、フレームワーク(React)がその判断を行い、スケジューリングを決定する
現時点のReact(React 15以前)は、スケジューリングの仕組みを本格的には活用できておらず、更新が発生すると、サブツリー全体が即座に再レンダリングされます。スケジューリングを活かせるようにReactのコアアルゴリズムを抜本的に見直したことこそが、Fiberの根底にある考え方です。
それでは、Fiberの実装に踏み込んでいきます。次のセクションは、ここまでの内容よりも技術的になりますので、先に進む前に、これまでの内容を十分に理解していることを確認してください。
Fiberとは?
これからReact Fiberのアーキテクチャの核心について説明します。Fiberは、アプリケーション開発者が一般的に考えているものよりも、はるかに低レベルな抽象概念です。理解しようとして行き詰まっても、落ち込む必要はありません。根気よく読み進めれば、いずれ理解できるようになります。(最終的に理解できた際には、このセクションの改善点についてご意見をお聞かせください)
それでは始めましょう!
ここまでで、Fiberの主な目的の一つが、Reactがスケジューリングを利活用できるようにすることだと確認しました。具体的には、下記が行える必要があります。
- 処理を一時停止し、後から再開する
- 処理の種類ごとに優先度を割り当てる
- 既に完了した処理を再利用する
- 不要になった処理を中止する
これらを実現するためには、まず処理を小さなユニットに分割できる仕組みが必要です。その意味で、Fiberとはまさにその「処理単位」を表すものです。Fiberは処理のユニットを表現しています。
理解を深めるために、Reactコンポーネントを"データの関数"として捉える考え方に立ち返りましょう。これは一般に、次のように表現されます。
v = f(d)
Reactアプリをレンダリングするということは、内部で別の関数を呼び出し、その中でもさらに関数が呼び出されていくような関数を実行することに近いと言えます。この例えは、Fiberを理解するうえで役立ちます。
コンピュータは通常、コールスタックを使ってプログラムの実行状況を管理します。関数が実行されると、新しいスタックフレームがスタックに積まれます。このスタックフレームが、その関数によって実行された処理内容を表しています。
UIを扱う場合、一度に多くの処理を実行すると、アニメーションがフレーム落ちによるカクつきが問題になります。しかもこれらの処理の一部は、より新しい更新によって上書きされるのであれば、そもそも不要な場合もあります。この点で、UIコンポーネントを単なる関数と同様に考えるには限界があります。コンポーネントは一般的な関数よりも、より固有の事情や制約を持っていることが、この理由です。
新しいブラウザ(およびReact Native)には、この問題に対処するためのAPIが用意されています。requestIdleCallbackは、ブラウザがアイドル状態のときに低優先度の処理を実行するためのAPIで、requestAnimationFrameは、次のアニメーションフレームで高優先度の処理を実行するためのAPIです。ただし、これらのAPIを活用するには、レンダリング処理を小さなユニットに分割できる必要があります。コールスタックに依存しているだけでは、スタックが空になるまで処理が続いてしまい、途中で制御を戻すことができないからです。
UIのレンダリングに最適化できるよう、コールスタックの挙動を自由にカスタマイズできたら素晴らしいと思いませんか?必要に応じてコールスタックの処理を中断し、スタックフレームを手動で操作できたら、なおさら理想的ではないでしょうか。
それこそがReact Fiberの目的です。Fiberは、Reactコンポーネントに特化した形でコールスタックを再実装したものです。1つひとつのFiberは、仮想的なスタックフレームだと考えることができます。
スタックを再実装する利点は、スタックフレームをメモリ上に保持したまま、好きなタイミングで、好きな順序で実行できることにあります。これは、私たちが目指しているスケジューリングの仕組みを実現するうえで、非常に重要なポイントです。
スケジューリング以外にも、スタックフレームを手動で扱えるようになることで、並行処理やエラーバウンダリといった機能を実現できる可能性が広がります。これらのトピックについては、後のセクションで説明します。
次のセクションでは、Fiberの構造についてさらに詳しく見ていきます。
Fiberの構造
Note: 実装の詳細に踏み込むにつれて、内容が変更される可能性は高くなります。誤りや古くなった情報に気づいた場合は、ぜひPRを送ってください。
具体的に言うと、Fiberとは、コンポーネント、インプット、アウトプットに関する情報を保持するJavaScriptオブジェクトです。
Fiberはスタックフレームに対応するものですが、同時にコンポーネントのインスタンスにも対応しています。
以下は、Fiberに含まれる重要なフィールドの一部です(このリストはすべてを網羅しているわけではありません)。
typeとkey
Fiberのtypeとkeyは、React elementの場合と同じ役割を果たします。(実際、elementからFiberが生成される際には、これら2つのフィールドがそのままコピーされます。)
Fiberのtypeは、それが対応するコンポーネントを表します。複合コンポーネントの場合、typeは関数コンポーネントやクラスコンポーネントそのものです。一方、divやspanなどホストコンポーネント(DOMやネイティブUI等の描画先の環境を指す)の場合、typeは文字列になります。
概念的には、typeはスタックフレームによって実行状況が追跡されている関数、つまりv = f(d)におけるfに相当します。
また、typeとあわせてkeyは、reconciliationの際に、そのFiberを再利用できるかどうかを判断するために使われます。
childとsibling
これらのフィールドは、他のFiberを参照しており、Fiberが再帰的なツリー構造を持つことを表しています。
childFiberは、コンポーネントのrenderメソッドが返す値に対応します。
例えば下記の例ではParentのchildFiberはChildに対応します。
function Parent() {
return <Child />
}
siblingフィールドは、renderが複数のchildを返す場合に対応するためのものです(これはFiberで新しく追加された機能)。
function Parent() {
return [<Child1 />, <Child2 />]
}
childFiberたちは、最初のchildを先頭とする単方向リンクリストを形成します。したがってこの例では、ParentのchildはChild1であり、Child1のsiblingがChild2になります。
先ほどの関数のたとえに戻ると、childFiberはtail-called function(=末尾呼び出しされた関数)のように考えることができます。
return
returnFiberは、現在のFiberの処理が終わったあとに、処理が戻る先となるFiberを指します。概念的には、スタックフレームにおけるリターンアドレスと同じものです。また、親Fiberと考えることもできます。
1つのFiberが複数のchildFiberを持つ場合、それぞれのchildFiberのreturnFiberは同じ親になります。したがって、前のセクションの例では、Child1とChild2のreturnFiberはいずれもParentになります。
pendingPropsとmemoizedProps
概念的には、propsは関数に渡される引数に相当します。Fiberでは、pendingPropsが処理の開始時に設定され、memoizedPropsは処理の終了時に設定されます。
新しく渡されるpendingPropsがmemoizedPropsと等しい場合、そのFiberの前の出力を再利用できることになるため、不要な処理を行わずに済みます。
pendingWorkPriority
これは、Fiberが表す処理の優先度を示す数値です。ReactPriorityLevelモジュールには、さまざまな優先度レベルと、それぞれの意味が定義されています。
NoWork(値は0)を除き、数値が大きいほど優先度は低くなります。たとえば、あるFiberの優先度が、指定したレベル以上かどうかを判定するには、次のような関数が考えられます。
function matchesPriority(fiber, priority) {
return fiber.pendingWorkPriority !== 0 &&
fiber.pendingWorkPriority <= priority
}
この関数はあくまで説明用の例であり、実際のReact Fiberのコードベースに含まれているものではありません。
スケジューラは、priorityフィールドを使って、次に実行する処理単位を検索します。このアルゴリズムについては、後のセクションで詳しく説明します。
alternate
flush: Fiberをflushするとは、その出力を実際に画面へ描画することを指します。
work-in-progress: まだ処理が完了していないFbierのことです。概念的には、まだリターンしていないスタックフレームに相当します。
任意の時点で、1つのコンポーネントインスタンスに対応するFiberは最大で2つだけ存在します。1つはすでにflushされたcurrentFiber、もう1つは処理中のwork-in-progressFiberです。
currentFiberのalternateはwork-in-progressFiberであり、逆にwork-in-progressFiberのalternateは currentFiberになります。
Fiberのalternateは、cloneFiberという関数によって必要になったタイミングで遅延生成されます。cloneFiberは、常に新しいオブジェクトを作るのではなく、既存のalternateがあればそれを再利用し、不要なメモリ確保を抑えるようになっています。
alternateフィールドは実装上の詳細として捉えるべきものですが、コードベースの中で頻繁に登場するため、ここで説明しておく価値があります。
output
host component: Reactアプリケーションにおけるleaf nodes(葉ノード)にあたるコンポーネントです。描画先の環境に依存しており、たとえばブラウザアプリではdivやspanなどが該当します。JSXでは、小文字のタグ名で表現されます。
概念的には、Fiberのoutputは関数の戻り値に相当します。
すべてのFiberは最終的にoutputを持ちますが、実際にoutputが生成されるのは、host componentにあたるleaf nodesだけです。そのoutputは、ツリーを遡るように上位へと伝播していきます。
このoutputが最終的にrendererに渡され、描画先の環境へ変更をflushするために使われます。outputをどのように生成し、更新するかを定義するのはrendererの責務です。
Future sections
現時点での説明は以上ですが、本ドキュメントはまだ完成にはほど遠い状態です。今後のセクションでは、更新のライフサイクル全体を通して使われるアルゴリズムについて解説していきます。取り上げる予定のトピックは次のとおりです。
- スケジューラが次に実行すべき処理単位をどのように見つけるのか
- 優先度がFiberツリー内でどのように管理・伝播されるのか
- スケジューラが処理を一時停止・再開するタイミングをどのように判断するのか
- 処理結果がどのようにflushされ、完了としてマークされるのか
- ライフサイクルメソッドなどの副作用がどのように扱われるのか
- コルーチンとは何か、そしてcontextやlayoutといった機能の実装にどのように使われるのか