Reactのトランジションの仕組みを理解する
はじめに
こんにちは。株式会社AI Shiftの安井です。
本記事はAI Shift Advent Calendar2024の13日目の記事となります。
今回はReactのトランジションがどのような仕組みで実装されているのかを深掘りし、その過程でReactがisInputPendingというAPIに期待したことを考察していけたらと思います。
構成は以下のとおりです。
- startTransitionのおさらい
- トランジション時のレンダリング処理の違い
- トランジション時の処理を深ぼる(図解)
- isInputPendingで解決しようとしたこと
前提
今回はReact18を前提にして解説をします。そのためRaect19からstartTransition関数に非同期関数も受け付けるようになりますが、本記事ではstartTransitionに渡す関数は同期的であるとします。
startTransitionのおさらい
まずはstartTransitionのおさらいです。react.devをもとに整理しましょう。
startTransitionは、UIをブロックせずにstateを更新するためのReactフックです。
Exampleで比較する
公式のExampleをそのまま掲載しました。このPostsのタブでは重たい計算処理がされており、遷移するまでに数秒の時間かかります。
トランジションなしの場合
トランジションとしてマークされていない場合、「About」->「Posts」->「Contact」と素早くクリックした際に、「Posts」へ遷移が完了されるまでUIがブロックされ、「Contact」のタブをクリックしても何も反応しない時間が発生してしまいます。これではユーザの体験を大幅に損ねてしまいます。
トランジションありの場合
ではstateの更新をトランジションとしてマークした場合はどうでしょうか?
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
先ほどのように「About」->「Posts」->「Contact」と素早くクリックした場合でも、UIがブロックされずにタブの遷移が実現できています。このようにReactのトランジションを使うとUIをブロックせずにstateを更新することができます。
トランジション時のレンダリング処理の違い
ではstateの更新をトランジションとしてマークしたケースとそうでない場合ではどのようなレンダリングにおける違いがあるのでしょうか?
Performanceタブから確認する
ここではChrome Dev ToolsのPerformanceタブからどのような処理が行われているかを確認します。
トランジションとしてマークされていない場合
こちらはトランジションとしてマークされていない場合です。ユーザのクリックイベントを起因に、「performSyncWorkOnRoot」->「renderRootSync」->「workLoopSync」と順に関数が実行されていることがわかります。
Reactのレンダリングの仕組みについて
performSyncWorkOnRootやrenderRootSyncといった処理はReactが内部でFiberTreeを構築するためのものであり、以前Reactの初回マウントでどのような処理がされているかを解説した記事があるので気になる方はぜひご覧ください。
トランジションとしてマークした場合
では比較してトランジションとしてマークした場合はどうなるでしょうか?
先ほどとは違いユーザのクリックイベントを起因に、「PerformConcurrentWorkOnRoot」->「renderRootConcurrent」と実行されています。そして、実行されるタスクが細切れに分割されていることがわかります。
この違いがReactのトランジションによるUIをブロッキングしないための対策です。
双方の違いを図式化する
それぞれを図式化するとこのような形になります。
トランジションとしてマークされていない場合
トランジションとしてマークされていない場合は一連のレンダリングのタスクを同期的に実行します。
また、ユーザからのClickイベントは優先度の高いイベントとして処理されます。これはReactのLaneという仕組みによって管理されています。
トランジションとしてマークした場合
対してトランジションとしてマークした場合は次の図のようにタスクが細切れになりUIのブロッキングを防いでいます。
先ほど紹介したようにユーザのClickイベントなどによって発火されたレンダリングは基本的に優先度の高いタスクとして扱われます。
しかし、トランジションとしてマークするとレンダリングのタスクが発火する際に優先度の低いタスクとして開始することによりタスクを細切れにして割り込みを可能にしています。
トランジション時の処理を深ぼる(図解)
では具体的にトランジション時にはどのような処理が行われているのでしょうか?これはReact Schedulerの仕組みによって成り立っています。
詳細な内容はこちらの記事が大変参考になりました。
またReact Schedulerのソースコードは非常にシンプルな構成になっています。
レンダリングのタスクを発行する
Reactのレンダリングのタスクは基本的にMacroTaskに詰められてから実行されます。MacroTaskに登録する際はsetImmediateなどのAPIを使用してすぐにTaskQueueから取り出すような実装になっています。
この図は同期的なレンダリングのケースであり、一度TaskQueueから取り出されたレンダリングタスクはその処理が完了するまで中断されずに実行されます。
しかし、この同期的なレンダリング処理が原因で重たいstate更新の際にUIがブロッキングされてしまう課題がありました。
トランジションがマークされるとタスクの中断・再開が可能
そこでstateの更新をトランジションとしてマークすると下の図のように一定の間隔でタスクを中断し、中断した時点から再度MacroTaskに登録してタスクを再開します。この作業を繰り返すことによってReactはUIをブロッキングせずにレンダリングを行っています。
実際のコードはこちらです。このshouldYield
というFlagがtrueにならない限りはレンダリングを継続します。そして、一定の間隔でshouldYield
がtrueになることでレンダリングを一時中断しタスクを細かく分割しています。
shouldYield
というFlagの管理は非常にシンプルで、タスクの実行時間がframeYieldMs(ここでは5ms)に達するとFlagがtrueになります。
先ほどのPerformanceタブで確認した実行の処理を再度見てみましょう。すると確かに5msずつにタスクが分割されて実行されていることがわかります。
isInputPendingで解決しようとしたこと
これまででReactがトランジションを用いてどのようにUIをブロッキングせずにレンダリングを実行しているかを解説しました。
整理すると「トランジションとしてマークされたstateの更新は、一定の間隔(例:5ms)でレンダリングのタスクを中断し再開を繰り返すことでUIのブロッキングを防いでいた」という形になります。
しかし、このレンダリングの中断・再開というのは読み込みパフォーマンスとして決して最適なものではありませんでした。そこでMeta社が提案し期待されたAPIがisInputPendingです。
タスクを分割するトレードオフ
レンダリングのタスクを分割しブラウザへ制御を渡すことによってUIのブロッキングを防いでいましたが、当然それはレンダリングが完了するまでのパフォーマンスを低下させてしまいます。
*そのためReactは初回マウントなどの優先度の高いケースではタスクの分割をせずに同期的にレンダリングを実行するようにしています。
https://developer.chrome.com/docs/capabilities/web-apis/isinputpending?hl=ja
isInputPendingとは
isInputPending APIを使用すると、ユーザーがページの操作を試みたかどうかを確認できます。つまり、Reactがタスクを分割してブラウザへ制御を渡していたところが、ブラウザ側からユーザの操作が存在したかを伝えることができるのでより効率的に処理を中断できるようになります。
https://developer.chrome.com/docs/capabilities/web-apis/isinputpending?hl=ja
ReactはこのisInputPending APIを実験的に試してより効率的にReactのスケジュールができないか試みていました。
isInputPendingを使用しない
しかし2024/2ごろのPRによってisInputPending APIを用いたSchedulerの改善は断念しているようです。PRのコメントにはisInputPending APIの処理を削除してよりSchedulerの実装をシンプルにするためと書かれています。
isInputPending is not in use. This PR cleans up the flags controlling its gating and parameters to simplify Scheduler.
別の記事でもisInputPending APIの問題点についていくつか言及されていました。
- isInputPendingは、ユーザーが操作したにもかかわらず、falseを誤って返す場合があります。
- タスクがyieldする必要があるのは入力の場合だけではありません。アニメーションやその他のユーザー インターフェースの定期的な更新は、レスポンシブなウェブページの提供と同様に重要です。
- その後、scheduler.postTaskや scheduler.yieldなど、生成する懸念事項に対処する、より包括的な収益生成APIが導入されました。
まとめ
この記事ではReactのトランジションがどのように実装されているかを整理し、その過程でなぜisInputPending APIへの期待がされたかを解説しました。
Reactはトランジションがマークされたstate更新の場合は細かくレンダリングのタスクを分割しブラウザへ制御を渡すことによってUIのブロッキングを防いでいます。
しかし、タスクを分割して実行することはレンダリングが完了するまでのパフォーマンスとのトレードオフになります。そこで期待されたAPIがisInputPendingであり、ブラウザからユーザの操作が存在したかを知ることができるようになりました。
最終的にはisInputPending自体の不安定さなどから利用可能ではないという判断になったようですが、より効率的なレンダリングを実現するためにReactは改善を試みていました。
最後に
AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
Discussion