React Deep Dive
こんにちは。フロントリードエンジニアのkokiです。
弊社で作成している一番大きなプロダクトはVue(Nuxt)を使っていますが、私がジョインしてからの新規プロダクトに関してはReact×Typescriptを採用しています。
そんなReactを普段の開発では内部アーキテクチャまで気にする必要はありませんが、フックAPIが出てからのアーキテクチャがとても面白いので共有したいと思います。
太字は強調したい部分
アンダースコアはリンク付き文字
()は主に著者の気持ち
React16.8から登場したフックAPI
Reactをクラスコンポーネント時代から開発していた人もそうでない人もReactフックAPIに関する基礎知識は必須となりデファクトスタンダードになってきたのではないでしょうか?(Vue3もCompositionAPIというReactフックに似たものが出ましたね)
そんな便利なフックAPIを何も考えずに使っていましたが、ふと、クラスコンポーネント時代はthis.setStateと書いていたのでインスタンスの存在を感じていましたが、React.useStateってstaticな関数に見えるけど、インスタンスをどうやって管理している?という疑問に思ったことが調べるきっかけでした。
一新された内部アーキテクチャについて
そんなフックAPIの裏では内部アーキテクチャが一新されたようです。大きな機能はFiber Reconciler(ファイバー リコンサイラ)とScheduler(スケジューラー)と呼ばれるものが実装され、高パフォーマンスを求められるアプリケーションにも対応したようです。この記事ではFiber Reconciler(以下、Fiberリコンサイラと記載します。Reconcilerって読みにくいですよね!)を中心に解説していきたいと思います。
Reconciler(リコンサイラ)とは
公式サイトによると
リコンサイラとはReactの宣言型APIを保持するためにDOM要素の更新を最適化する方法を気にしなくて良いようにReact内部で行っていることのようです(思い返せば、jQuery時代はstateを更新したあとに自分で.text("値")を呼ばないといけないのでリコンサイラはこのあたりをいい感じにしてくれてるやつっぽいです)。
ではどうやってstate変更後に反映させるかを更に見てみると、
一度ツリーを生成して新旧比較するんですね。
1つ目の Two elements of different types will produce different trees. は下記の様に親要素が変わったら子要素は完全に作り直すという意味になります。
<div>
<Counter/>
</div>
<span>
<Counter/>
</span>
2つ目の The developer can hint at which child elements may be stable across different renders with a key prop. は
// 末尾に追加するパターン
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
末尾に追加するパターンならthird だけが差分検出されますが、
// 先頭に追加するパターン
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
先頭に追加するパターンだとConnecticutだけを差分検出出来ないようです。このようにリコンサイラはあるツリーを別のツリーと比較して、変更する必要のある部分を決定するという役割のようです。
これらはReact16以前はStackという概念でReact16からはFiberという新しい概念で下記が実装されています。
リコンサイラ作業の一時停止と再開
リコンサイラ作業の優先順位付け
リコンサイラ作業のキャッシュ化
リコンサイラ作業のキャンセル
これらの機能が実装された背景として、フレームドロップアウトという問題です。
React16以前はStackを用いてリコンサイラ作業を行っていました が、デスクトップPCだけでなくスマートフォンなどでも画面に表示されているすべてのものは、画面またはフレームで構成されているため多くの作業を一度に行うと1フレーム内に収まらないという問題が発生します。
もう少し詳しく解説します。以下は各FPSでの違いです。
最近のデバイスでは60FPSで画面を更新しています。つまり、1/60 = 16.66msでフレームを切り替えます。(実際はハウスキーピングを行うために10ms以内である必要があるそうですが…)Reactレンダラーが画面で何かをレンダリングし10ms以上かかった場合、ブラウザはそのフレームをドロップするため画面全体がカクついた印象になります。言わずもがなUXの低下につながるため、リコンサイラが10msという予算の中で作業を一時停止、再開できることが求められてきました。
仮想スタックフレーム(Fiber)
Reactコントリビューター、Andrew Clarkさんの記事(Reactリコンシリエーションに関しては詳細に書かれている記事になりますが、3年前の記事なので一部変わっている箇所もあります。)にはこう書かれています。
requestIdleCallbackやrequestAnimationFrameなどのブラウザAPIを使ってReactレンダリング作業を最適にスケジュールできれば最高だよね!つまり、FiberはJavascriptスタックを使わずに仮想スタックを再実装したものだと説明しています。そもそも、Javascriptはスタックとキューの概念のもとシングルスレッドで実行されます。
FetchなどHTTP通信などを行った場合、並列実行されているように見えますが実際に処理されるタイミングは非同期ではなく同期的です。上図を見てみるとHTTPレスポンスをキューからポップするのは5番目、つまりStackリコンサイラ時代はReactがツリーとトラバースし終えるまでHTTPレスポンス取得後の処理⑥は待ち状態となります。
Fiberはこのスタック待ち状態を解消するために一時停止、再開などのReactコンポーネントに特化した仮想スタックフレームと理解する事ができます。
Stackリコンサイラ→Fiberリコンサイラに再実装され、Reactレンダリングの一時停止、再実行を行えるようになりました。では、フレームドロップアウトを防ぐために再実装されたリコンサイラは具体的にどのようなアーキテクチャになっているのでしょうか?
Fiberアーキテクチャの主要処理フェーズ
ReactDOM.renderやsetState後はリコンシリエーションフェーズとレンダリングフェーズに分かれます。(ソース内ではリコンシリエーション->Renderフェーズ、レンダリングフェーズ->Commitフェーズと呼ばれています)
リコンシリエーションフェーズ
リコンシリエーションフェーズではcurrentとworkInProgressという2つのFiberツリーインスタンスを作成します。currentは最初にレンダリングしたツリーインスタンス、workInProgresは作業用インスタンスになります。renderが呼び出されるとReactはすべての子ノードと兄弟ノードをトラバースしながらFiberオブジェクトを作成していき最下層に行くと、逆に親に返っていきルートノードに到達することで、Fiberツリーを完成させます。このFiberオブジェクトはReactコンポーネントごとにあるため、React.useStateなどのstaticな関数もスコープを持つことが出来ましたし、FiberオブジェクトごとにsetStateなどのDispatcherも登録されています。
Fiberインスタンスの構成要素
リコンシ
リエーションフェーズで生成されるFiberオブジェクトのデータ構造について解説します。
child、sibling
render関数によって返される子Fiberオブジェクト
return
親Fiber要素。概念的にはスタックフレームのリターンアドレスと同じ(だそうです…)
他にも
key
list-itemにつけるkey属性と関連しています。
type
関連づけられたコンポーネントやDOM情報(上の例でいうとCounterというクラス名やdivといったHTMLタグ名のこと)
pendingProps、memorizedProps
僕らが普段使っているpropsのことです
memorizedPropsは前のレンダリング時のものです。
pendiingPropsはworkInProgress上でのpropsです
alternate
対応するノードへの参照を保持し、current <-> workInProgressという関係を構築しています。
これらの情報を持つFiberノードを作成してからレンダリングフェーズに進みます。
レンダリングフェーズ
setState などが実行されworkInProgressツリー内の更新作業が完了するとReactは画面反映される準備ができたことになり、そのworkInProgress が画面に描画されるとcurrentツリーのポインターがworkInProgressに移動されます。
これらのリコンシリエーションフェーズとレンダリングフェーズをrequestIdleCallbackやrequestAnimationFrameを使ってフレームドロップアウトを防止を目的とした中断可能プロセスの中で行っています。(実際のところ、requestIdleCallbackやrequestAnimationFrameは使われていないのですが、それは話が長くなるので別記事にしたいと思います…)
最後に
この記事では、Reactの内部アーキテクチャからフレームの話などを解説しました。アーキテクチャ以外にもUnitテストの仕方などなど技術的なテクニックが豊富にあります。(私もまだまだ把握できていないので引き続き勉強していきます!)この記事をきっかけにReactのソースコードを見てみようという気持ちになってくれると嬉しいです。