React Fiberアーキテクチャについて

初めに

React Fiberは、現在進行形で進められているReactのコアアルゴリズムの再実装であり、Reactチームの2年以上にわたる研究の成果です。

React Fiberは、アニメーション、レイアウト、ジェスチャーといった領域に対する適性を向上させることを目指しています。React Fiberの目玉となるインクリメンタルレンダリングは、レンダリング作業を分割して、複数のフレームに分散させることができる機能です。

他には主に、新たな更新があった際に作業を休止・強制終了・再利用できる機能、更新の種類別に優先順位をつけられる機能、新しい並行プリミティブなどが挙げられます。

このドキュメントについて

Fiberは、コードを見ただけでは分かりにくい斬新な概念をいくつも導入します。このドキュメントはそもそも、ReactプロジェクトにおけるFiberの実装に伴って私が取っていたメモを集めたものでした。メモが増えてくるにしたがって、他の方にも役立つ資料になるかもしれないと考えるようになったのです。

できるだけ分かりやすい言い回しを心掛け、鍵となる用語を明示的に定義して、専門用語は避けたいと思います。また可能な場合は、外部情報源へのリンクをどんどん張っていきます。

なお、私はReactチームのメンバーではなく、いかなる権限から話すわけでもありませんので、ご注意ください。これは公式ドキュメントではありません。Reactチームのメンバーには、このドキュメントに間違いがないかレビューを依頼しています。

また、この作業は現在進行中です。Fiberは、完成までにかなりのリファクタリングが必要となりそうな、継続中のプロジェクトです。よって、その設計をこうしてドキュメントにまとめていくという私の試みも、同じく続いています。改善すべき点や提案があれば、ぜひお聞かせください。

私が目指しているのは、このドキュメントを読んだ方が、Fiberをよく理解してその実装過程をフォローできるようになり、ゆくゆくはReactへのコントリビュートまでできるようになるということです。

このドキュメントを読む前に

読み進めていく前に、以下の資料に目を通していただくことを強くお勧めします。

  • React Components, Elements, and Instances(Reactのコンポーネント、エレメント、インスタンス):「コンポーネント」は、多重定義されることの多い用語です。これらの用語をきちんと把握しておくことが不可欠です。
  • Reconciliation(リコンシリエーション・調停):Reactのリコンシリエーションアルゴリズムについての概要です。
  • React Basic Theoretical Concepts(Reactの基本的な理論上の概念):Reactの概念モデルが実装負荷抜きで説明されています。初めて読んだ時には意味の通じない部分があるかもしれませんが、問題ありません。次第に分かってくるでしょう。
  • React Design Principles(Reactの設計原理):特に、スケジューリングのセクションに注目してください。React Fiberの理由がうまく説明されています。

復習

「このドキュメントを読む前に」のセクションをまだ読んでいない方は、そちらをご確認ください。

新しい内容を見ていく前に、いくつかの概念を再確認しておきましょう。

リコンシリエーションとは何か?

リコンシリエーション(調停)(reconciliation)
どの部分に変更が必要か判断すべく、ツリー同士の差分を出すためにReactが使うアルゴリズム。

更新(update)
Reactアプリをレンダリングするのに使われるデータ中の変更。通常は、setStateで行われる。最終的には再レンダリングに至る。

ReactのAPIで中心となる考え方は、更新があたかもアプリ全体の再レンダリングを引き起こすかのように見なすことです。これによって開発者は、アプリを何らかの状態から別の状態へ(AからBへ、BからCへ、CからAへなど)効率的に遷移させる方法を気にするのではなく、宣言的に思考できるようになるのです。

実は、アプリ全体を変更の度に再レンダリングするという方法は、ごく小規模なアプリでしかうまくいきません。現実的なアプリでは、パフォーマンスの点でコストが途方もなく高くなるのです。Reactでは、優れたパフォーマンスを維持しながらアプリ全体を再レンダリングしたように見せる最適化がなされています。こうした最適化の大部分は、リコンシリエーションと呼ばれるプロセスの一部となっています。

リコンシリエーションは、「仮想DOM」として広く理解されている仕組みの背後にあるアルゴリズムです。ざっと説明してみましょう。Reactアプリケーションをレンダリングする際、そのアプリを記述するノードのツリーがメモリ内に生成され保存されます。このツリーはその後、レンダリング環境にフラッシュされ(流し込まれ)ます。例えば、ブラウザアプリケーションの場合では、1組のDOM操作に変換されます。アプリが(通常はsetStateを介して)更新されると、新しいツリーが生成されます。この新しいツリーと以前のツリーとの差分を出すことで、描画されたアプリを更新するためにどの操作が必要であるかを計算するというものです。

Fiberはこのリコンシリエーションを行う部分(リコンサイラ)を一から書き直すものですが、Reactドキュメントに記載されている上位レベルのアルゴリズムは、大部分は変わらないでしょう。以下が重要なポイントです。

  • コンポーネントの種類が異なると、生成されるツリーも大幅に異なったものになることが想定される。そのような場合、Reactは差分を出すのではなく、元のツリーを完全に置き換えようとする。
  • リストの差分計算は、キーを使って実行される。キーは「安定性があり、予測でき、一意である」ことが求められる。

リコンシリエーションとレンダリングについて

DOMは、Reactの描画先となり得るレンダリング環境の1つにすぎません。他の主なターゲットは、React Nativeを介したネイティブのiOSビューとAndroidビューです(このため、「仮想DOM」という呼び方には若干語弊があるのです)。

Reactがそれほどのターゲットをサポートできる理由は、Reactが、リコンシリエーションとレンダリングが別個の工程となるように設計されているからです。リコンサイラは、ツリーのどの部分が変わったか計算する作業を行い、レンダラはその情報を使って、描画されたアプリを実際に更新します。

この役割分担のおかげで、React DOMとReact Nativeは、Reactのコアが提供する同じリコンサイラを共有しながら、独自のレンダラを用いることができるのです。

Fiberはリコンサイラを再実装するものです。この新しいアーキテクチャをサポート(して利用)するにはレンダラは変わる必要がありますが、Fiberは概してレンダリングに関与していません。

スケジューリング

スケジューリング(scheduling)
作業が実行されるべきタイミングを判断するプロセス。

作業(work)
実行されなければならない計算。作業は通常、更新(例えばsetState)の結果として生じる。

この件については、Reactのドキュメント「Design Principles」(設計原理)で申し分なく説明されていますので、以下にそのまま引用します。

Reactの現行の実装においては、ツリーの再帰的な走査と、更新されたツリー全体のrender関数の呼び出しは、単一のティック内で行われます。しかし今後は、フレーム落ちを回避するため、一部の更新を遅延させるようになるかもしれません。

これはReactの設計に共通したテーマです。広く使われているライブラリの中には、新しいデータが利用可能な時に計算が実行される「プッシュ」のアプローチを取っているものもあります。しかしReactは、必要になるまで計算を遅延させることのできる「プル」のアプローチを堅持しています。

Reactは、ジェネリックなデータ処理ライブラリではありません。ユーザインターフェースを構築するためのライブラリです。Reactは、現在どの計算が関連していてどの計算が関連していないかを把握するためのライブラリとして、アプリ内で独自に位置づけられるでしょう。

オフスクリーンのものがある場合、それに関連するロジックは遅延させることができます。フレームレートより速くデータが到着した場合、更新をまとめて一括処理することが可能です。フレーム落ちを回避するため、ユーザインタラクション(ボタンクリックで起動するアニメーションなど)に由来する作業は、比較的重要度の低いバックグラウンド作業(ネットワークからロードされたばかりの、新しいコンテンツのレンダリングなど)より優先させることができます。

重要なポイントは以下のとおりです。

  • UIでは、全ての更新が即座に適用される必要はない。実際には、そのようにすると無駄が生じ、フレーム落ちが発生して、ユーザエクスペリエンスの質が下がる可能性がある。
  • 更新の種類によって、優先順位が異なる。アニメーションの更新は、例えばデータストアからの更新よりも速く完了する必要がある。
  • プッシュベースのアプローチでは、アプリ(プログラマ)が作業のスケジューリングを決める必要がある。プルベースのアプローチでは、スマートなフレームワーク(React)がプログラマに代わってそうした判断を行うことができる。

Reactは現在のところ、スケジューリングを目立った形では活用していません。更新によってサブツリー全体が即座に再レンダリングされるようになっているのです。スケジューリングを活用するためにReactのコアアルゴリズムを洗い直すというのが、Fiberを推し進めている考え方です。


これでFiberの実装について見ていく準備ができました。次のセクションは、ここまでの説明よりも専門的な内容となります。読み進める前に、前述の資料が理解できているかご確認ください。

fiberとは何か?

React Fiberのアーキテクチャの核心についてお話ししていきます。Fiberは、アプリケーション開発者が一般的に考えるよりもずっと下位のレベルの抽象化です。Fiberを理解しようとしてうまくいかなかったとしても、諦めないでください。努力を続ければ、やがて意味が分かってくるでしょう(ついに理解できるようになった際には、このセクションの改善についてご提案いただければ幸いです)。

それでは始めましょう。


Fiberの最大の目的はReactがスケジューリングを活用できるようにすることだと、既に確認しました。具体的には、以下のことを可能にする必要があります。

  • 作業を休止し、あとでそこに戻る。
  • 作業の種類別に優先順位をつける。
  • 以前に完了した作業を再利用する。
  • 必要のなくなった作業を強制終了する。

上記のどれを実現させるにしても、まずは作業を構成単位に分解する手段が必要です。ある意味、それこそがfiberといえます。fiberは作業の構成単位を表すのです。

さらに詳しく見ていくため、データの関数としてのReactコンポーネントという概念を振り返ってみましょう。これは一般的に、以下のように表されます。

v = f(d)

つまり、Reactアプリのレンダリングは、本体に他の関数などへの呼び出しが含まれる関数を呼び出すことに似ているのです。この類似性は、fiberについて考える時に役立ちます。

コンピュータがプログラムの実行を追跡する典型的な方法は、コールスタックを用いることです。関数が実行される時、新しいスタックフレームがスタックに追加されます。そのスタックフレームは、その関数によって実行される作業を表します。

UIを扱う際の問題は、同時に実行される作業が多すぎる場合、アニメーションのフレーム落ちが発生して、滑らかに見えなくなる可能性があるということです。その上、作業の中には、より新しい更新によって置き換えられたため不要になっているものがあるかもしれません。コンポーネントには一般の関数よりも具体的な用途があるため、この場面ではUIコンポーネントと関数を比較することができません。

比較的新しいブラウザ(とReact Native)は、まさにこの問題への対処に役立つAPIを実装しています。requestIdleCallbackは、アイドル期間中に呼び出されるべき優先順位の低い関数をスケジューリングし、requestAnimationFrameは、次のアニメーションフレームで呼び出されるべき優先順位の高い関数をスケジューリングするのです。問題は、そうしたAPIを使うためには、レンダリング作業をインクリメンタルな構成単位に分解する手段が必要だということです。コールスタックだけに依存している場合は、スタックが空になるまで作業を続けることになります。

UIのレンダリング向けに最適化するためコールスタックの振る舞いをカスタマイズできるとしたら、素晴らしいことですよね。また、コールスタックに自由に割り込んでスタックフレームを手動で操作できるとしたら、素晴らしいことですよね。

それがReact Fiberの目的です。Fiberは、Reactコンポーネントに特化した、スタックの再実装です。単一のfiberは仮想スタックフレームと見なすことができます。

スタックを再実装する利点は、スタックフレームをメモリに保持し、好きなように(かつ、いつでも)それを実行できるということです。これは、スケジューリングに関する目的を果たすのに不可欠なことです。

スケジューリングに加えて、スタックフレームを手動で扱うことで、並行処理やエラー境界といった機能への可能性が生まれます。それらのテーマについては、今後のセクションで取り上げる予定です。

次のセクションでは、fiberの構造をさらに詳しく見ていきましょう。

fiberの構造

注:実装の細部について具体的に説明していきますので、変更の生じている内容があるかもしれません。誤りや古い情報に気づかれた場合は、ぜひプルリクエストをお送りください。

fiberは具体的にいえば、コンポーネントとその入出力についての情報を含んだ、JavaScriptオブジェクトです。

fiberはスタックフレームに相当しますが、コンポーネントのインスタンスにも相当します。

以下に、fiberに属する重要なフィールドをいくつか挙げていきます(網羅的なリストではありません)。

typekey

fiberのtypeとkeyは、Reactエレメントの場合と同じ目的のために使われます(実は、fiberがエレメントから生成される際、この2つのフィールドは直接コピーされます)。

fiberのtypeは、対応するコンポーネントを示します。複合コンポーネントの場合、typeは関数かクラスコンポーネントそのものとなります。ホストコンポーネント(divspanなど)の場合、typeは文字列となります。

typeは概念上、実行がスタックフレームによって追跡される、(v = f(d)で示されるような)関数です。

typeに加えて、keyはリコンシリエーションの際に、そのfiberが再利用できるかを判断するために使われます。

childsibling

これらのフィールドは他のfiberを指し、fiberの再帰ツリー構造を示します。

child fiberは、コンポーネントのrenderメソッドによって返される値に相当します。例をご覧ください。

function Parent() {
  return <Child />
}

上記において、Parentのchild fiberは、Childに相当します。

siblingフィールドは、renderが複数のchildを返す(Fiberの新機能です)場合に使われます。

function Parent() {
  return [<Child1 />, <Child2 />]
}

上記のchild fiberは、先頭が1番目のchildである単方向リストとなります。この例では、ParentのchildはChild1であり、Child1のsiblingはChild2です。

再び関数とのアナロジーで考えてみると、child fiberは、末尾呼び出しされる関数と見なすことができます。

return

return fiberは、プログラムが現在のfiberを処理したあとの復帰先となるfiberです。これは概念的に、スタックフレームのリターンアドレスと同じものです。parent fiberと見なすこともできます。

fiberが複数のchild fiberを持つ場合、各child fiberのreturn fiberはparentとなります。よって、前のセクションの例でいえば、Child1Child2のreturn fiberは、Parentです。

pendingPropsmemoizedProps

概念上、propsは関数の引数です。fiberのpendingPropsは実行の最初に設定され、memoizedPropsは最後に設定されます。

新たに設定されるpendingPropsmemoizedPropsと等しい場合、fiberの前回の出力を再利用して、不要な作業を回避できるということになります。

pendingWorkPriority

fiberによって表現される作業の優先順位を示す数値です。ReactPriorityLevelモジュールは、様々な優先レベルとその意味をリストにしています。

優先順位が0であるNoWorkは例外として、数値が大きいほど優先順位が低くなっています。例えば、以下の関数を使ってみると、あるfiberの優先順位が指定したレベル以上であるかを確認できます。

function matchesPriority(fiber, priority) {
  return fiber.pendingWorkPriority !== 0 &&
         fiber.pendingWorkPriority <= priority
}

上記の関数は説明専用のものですので、実際にはReact Fiberのコードベースに含まれていません。

スケジューラはpriorityフィールドを使って、実行すべき次の作業単位を検索します。このアルゴリズムについては、今後のセクションで取り上げる予定です。

alternate

フラッシュ(flush)
fiberをフラッシュするとは、fiberの出力を画面上にレンダリングすること。

進行中の作業(work-in-progress)
未完了のfiber。概念的には、まだ返されていないスタックフレーム。

あるコンポーネントインスタンスはどの時点においても、対応するfiberを多くて2つ持っています。フラッシュされた現在のfiberと、作業進行中のfiberです。

現在のfiberのalternateは作業進行中のfiberであり、作業進行中のfiberのalternateは現在のfiberです。

fiberのalternateは、cloneFiberと呼ばれる関数を使って遅延評価で生成されます。cloneFiberは、新しいオブジェクトを常に生成するのではなく、fiberのalternateが存在すればそれを再利用し、割り当てを最小限に抑えます。

alternateフィールドは実装の細部と見なすといいでしょう。ただし、コードベース内でよく登場しますので、ここで取り上げる価値のあるフィールドです。

output

ホストコンポーネント(host component)
Reactアプリケーションのリーフノード。レンダリング環境に固有(例:ブラウザアプリでは、divspanなど)。JSXでは、小文字のタグ名で示される。

概念上、fiberのoutputは、関数の戻り値です。

どのfiberも最終的にoutputを持ちますが、outputはホストコンポーネントによってリーフノードでのみ生成されます。outputはその後、ツリーの上方へ移されるのです。

outputは、レンダリング環境へ変更をフラッシュできるようにするため、最終的にレンダラに渡されます。outputが生成・更新される方法を定義するのは、レンダラの役目となります。

今後のセクション

差し当たっての説明は以上ですが、このドキュメントは完成には程遠い状態です。今後のセクションでは、更新のライフサイクル全体を通して使われるアルゴリズムについてお話ししたいと思います。取り上げる予定のテーマは以下のとおりです。

  • スケジューラが、実行すべき次の作業単位を見つける方法。
  • 優先順位がfiberツリー内で追跡され伝達される方法。
  • スケジューラが、作業を休止・再開すべきタイミングを把握する方法。
  • 作業がフラッシュされ、完了したとマークされる方法。
  • 副作用(ライフサイクルメソッドなど)の動作。
  • コルーチンとは何か。コンテキストやレイアウトといった機能を実装するためにコルーチンが使われる方法。