- 経営企画 IR
- サーバーサイドエンジニア※テッ
- 英語コンサルタント
- Other occupations (18)
- Development
- Business
- Other
こんにちは。フロントリードエンジニアの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 provides a declarative API so that you don’t have to worry about exactly what changes on every update. This makes writing applications a lot easier..
リコンサイラとはReactの宣言型APIを保持するためにDOM要素の更新を最適化する方法を気にしなくて良いようにReact内部で行っていることのようです(思い返せば、jQuery時代はstateを更新したあとに自分で.text("値")
を呼ばないといけないのでリコンサイラはこのあたりをいい感じにしてくれてるやつっぽいです)。
ではどうやってstate変更後に反映させるかを更に見てみると、
When you use React, at a single point in time you can think of the render() function as creating a tree of React elements. On the next state or props update, that render() function will return a different tree of React elements.
1. Two elements of different types will produce different trees.
2. The developer can hint at which child elements may be stable across different renders with a key prop.
一度ツリーを生成して新旧比較するんですね。
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年前の記事なので一部変わっている箇所もあります。)にはこう書かれています。
Newer browsers (and React Native) implement APIs that help address this exact problem: requestIdleCallback schedules a low priority function to be called during an idle period, and requestAnimationFrame schedules a high priority function to be called on the next animation frame. The problem is that, in order to use those APIs, you need a way to break rendering work into incremental units. If you rely only on the call stack, it will keep doing work until the stack is empty.
Wouldn't it be great if we could customize the behavior of the call stack to optimize for rendering UIs? Wouldn't it be great if we could interrupt the call stack at will and manipulate stack frames manually?
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フェーズと呼ばれています)
リコンシリエーションフェーズ
Reference: https://www.youtube.com/watch?v=ZCuYPiUIONs
リコンシリエーションフェーズでは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ノードを作成してからレンダリングフェーズに進みます。
レンダリングフェーズ
Reference: https://blog.logrocket.com/deep-dive-into-react-fiber-internals/
setState
などが実行されworkInProgressツリー
内の更新作業が完了するとReactは画面反映される準備ができたことになり、そのworkInProgress
が画面に描画されるとcurrent
ツリーのポインターがworkInProgressに移動
されます。
これらのリコンシリエーションフェーズとレンダリングフェーズをrequestIdleCallbackやrequestAnimationFrameを使ってフレームドロップアウトを防止を目的とした中断可能プロセスの中で行っています。(実際のところ、requestIdleCallbackやrequestAnimationFrameは使われていないのですが、それは話が長くなるので別記事にしたいと思います…)
最後に
この記事では、Reactの内部アーキテクチャからフレームの話などを解説しました。アーキテクチャ以外にもUnitテストの仕方などなど技術的なテクニックが豊富にあります。(私もまだまだ把握できていないので引き続き勉強していきます!)この記事をきっかけにReactのソースコードを見てみようという気持ちになってくれると嬉しいです。
次回はスケジューラー(requestIdleCallbackやrequestAnimationFrameは使われていないという部分)をソースコードをベースに解説したいと思います。