React 18の新機能によるパフォーマンス改善について実際に動かして確かめる
こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。
ちょっと前の記事になりますが、Vercelより「How React 18 Improves Application Performance」という記事の投稿がありました。
React 18で導入された最新機能が、アプリケーションのパフォーマンスをどのように改善するかという内容です。
こちらの内容が興味深く、自分自身でも試してみることにしました。
概要
まず、記事の概要について説明します。自分で読んで理解したいよという方は元記事をご覧ください。
※間違った解釈をしている可能性があります。ご了承ください。
ざっくり分けるとこんか感じかと思います。
- Javascriptのシングルスレッドとロングタスクの問題
- Reactの従来のレンダリング手法
- React 18で導入された新機能による解決方法
1. Javascriptのシングルスレッドとロングタスクの問題
1つ目は、ブラウザでJavascriptを動かす場合、シングルスレッドで実行するため、時間のかかるタスクを実行すると終了していないタスクが待機状態になり、例えば、クリックやキーボード入力といったユーザーインタラクションが行えない、無反応という問題が発生します。
Tipsですが、この時間のかかるタスクのことをロングタスクと呼び、処理時間に50ms以上かかる場合、ロングタスクとなるそうです。
2. Reactの従来のレンダリング手法
2つ目は、Reactのレンダリングの説明で、Reactのレンダリングの更新には、RenderingフェーズとCommitフェーズの2つのフェーズがあります。
前者では、現在のDOMと変更箇所を比較し、必要な箇所だけ更新した新しいDOMを作成します。
後者では、新しく作成したDOMを実際のDOMに適用します。
このレンダリング手法と、先ほどのシングススレッドとロングタスク問題が大きく関わってきます。
コンポーネントの複雑さによってレンダリングに時間がかかると(=ロングタスク)、メインスレッドがブロックされ、新規のDOMがコミットされるまで、ユーザインタラクションが行えない、無反応になってしまうという問題です。
これは、ユーザエクスペリエンス的には最悪です。
3. React 18で導入された新機能による解決方法
上記の問題を解決する手段として、React 18のアップデートのうち、以下の機能が活用できるようです。
- Transitions
- React Server Components
- Suspense
- Data Fetching
こちらのうち、「Transitions」と「Data Fetching」について、次の章で、デモを通じて勉強していきます。
Transitions
React 18で追加されたuseTransition
を用いることで、優先度の低い(緊急でない)コンポーネントのレンダリングを後回しにし、それ以外のタスクを優先して実行することができます。
これを導入するメリットは、ロングタスクによるユーザインタラクションが行えないという問題を改善し、クリックやキーボード入力を先に実行し、その後にロングタスクを実行することができます。
具体的に、どういうことかをデモを通じて見ていきます。
デモの説明
元記事とほぼ同じですが、大量のGitHubのリポジトリ情報から、特定のキーワードでリポジトリ名を絞り込むというデモを作成しました。
大量のリポジトリ情報(JSON)は、以下のリポジトリからお借りしました。
ソースコード
Transitionsを適用するのは非常に簡単で、startTransition
関数で、優先度の低い処理囲ってあげるだけでOKです。
// ... export default function Children() { const [text, setText] = useState(""); const [keyword, setKeyword] = useState(text); const [isPending, startTransition] = useTransition(); return ( <main> <input type="text" value={text} onChange={(e) => { setText(e.target.value) startTransition(() => { // ここ setKeyword(e.target.value) }) }} /> <List keyword={keyword} /> </main> ); };
全ソースはこちら
動作検証
それでは、startTransition
を導入することによってどう変化があるかを、動作を見ながら確認していきます。
また、元記事にもありますが、検証する場合、Chromeの開発者ツールにて、Performanceタブを開いてCPUを「4× slowdown」にします。
これをしないとPCスペックでカバーされ、変化に気づきにくくなります。
デモ画面は簡素ですがこんな感じで、
テキストボックスにキーワードを入力すると、キーワードが含まれるリポジトリが表示されるというものです。
このデモは、startTransition
を使わないパターンで、「google」と入力されるまでに遅延があります。
この時、テキストボックスの値が更新されたためonChangeイベントが発火するわけですが、setKeyword
でkeywordも更新れているのでListコンポーネントが再レンダリングされます。
Listコンポーネントでは、keywordでJSONのデータをフィルタリングしており、
データが膨大なため処理に時間がかかり(=ロングタスク)、テキストボックスが無反応になっているということになります。
一方、こちらはstartTransition
を使っているパターンです。
「google」と入力すると1文字目まで入力されます。
これは、startTransition
関数でkeywordの更新setKeyword
が後回しになり、テキストボックスの値の更新setText
が優先されているため起きています。
所感
startTransition
を導入前後で、大きな変化がないなと思いましたが、
このユーザの動作に一瞬反応するorしないは、ユーザエクスペリエンスにおいて、ユーザを混乱させない一つの要因になるので、
ロングタスクになりやすい箇所に保険で入れておくのは良いのではないかと思いました。
Data Fetching
続いて、Data Fetchingについて見ていきます。
React 18ではfetch
を用いてAPIリクエストをすると、レスポンスをキャッシュするようになりました。
ただ、使用上の注意があり、React Server Componentであること(サーバサイドレンダリングであること)と、同一リクエストパラメータであることです。
私は、ちゃんと記事を読まずに、CSRでデモを作成し、全然キャッシュされず毎回APIリクエストを送っていたので、困惑しました。
それから、同一リクエストパラメータについてです。
元記事からソースコードを引用すると、fetchPost(1)
が2回呼び出されていますが、このように同じ引数のリクエストの場合、2回目はキャッシュされたデータを返すということです。
export const fetchPost = (id) => { const res = await fetch(`https://.../posts/${id}`); const data = await res.json(); return { post: data.post } } fetchPost(1) fetchPost(1) // Called within same render pass: returns memoized result.
デモの説明
実際にはどのように動くかをデモで検証していきます。
まずは、APIです。クエリパラメータname
に指定した値とレスポンス時間を返すだけの簡単なAPIを作りました。
$ curl 'http://localhost:3001/hello?name=hoge' {"message":"Hello hoge","date":"2023-08-23T02:29:40.258Z"}
リクエスト時にログを出力します。
::ffff:127.0.0.1 - - [23/Aug/2023:05:14:18 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0" ::ffff:127.0.0.1 - - [23/Aug/2023:05:14:18 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0" ::ffff:127.0.0.1 - - [23/Aug/2023:05:14:21 +0000] "GET /hello?name=Kobayashi HTTP/1.1" 200 63 "-" "axios/1.4.0"
フロントエンドでは、そのAPIを同じパラメータで、2回リクエストします。
このように、Request1とResuest2で結果が同じになっています。
ソースコード
// api.ts export const getLocalApi = async(name: string) => { const res = await fetch(`http://localhost:3001/hello?name=${name}`); const data = await res.json(); return data }
// page.tsx import { getLocalApi } from "./api" export default async function Home() { const data1 = await getLocalApi("Kobayashi") // リクエスト1回目 const data2 = await getLocalApi("Kobayashi") //リクエスト2回目 return ( <main> <div> <h3>Request1</h3> <div>{data1.message}</div> <div>{data1.date}</div> </div> <div> <h3>Request2</h3> <div>{data2.message}</div> <div>{data2.date}</div> </div> </main> ) }
全ソースはこちら
GitHub - nandemo3/react-18-performance-demo at data-fetching
動作検証
上記の画面では、Request1とRequest2でどちらも同じ日時が表示されていましたが、本当にキャッシュされたデータなのか見ていきます。
ブラウザのキャッシュを削除するとAPIがリクエストされ、それ以降はキャッシュされたデータを表示しています。
ただ、初回のリクエストは2回ともAPIをコールしていることがわかります。
元記事を読む限り、2回目はキャッシュされるのでは?と思ったのですが、この検証を見る限り、初回レンダリングは全てAPIをコールするようです。
(設定が間違えているなどあるかもしれません。)
では、比較検証のため、Axiosを用いて、同じデモを行います。
// api.ts import axios from 'axios'; export const getAxiosLocalApi = async(name: string) => { const res = await axios.get(`http://localhost:3001/hello?name=${name}`); const data = res.data; return data }
レンダリングのたびにAPIがリクエストされ、レスポンス時間も更新されていることがわかります。
やはり、fetchは同一リクエストをキャッシュすることが分かります。
所感
同一リクエストでもレスポンス時間を取得したいというニーズがあった時、
fech
だとキャッシュしたデータが返ってきてしまうので、こういったときはAxiosを使うなどの切り分けが必要になりそうだなと思いました。
ただ、同一リクエストをサーバサイドで行うというケースは、静的データの取得かもしれないので、良いのかもしれません。(そんなことないと思う・・・)
まとめ
React 18の新機能によるパフォーマンス改善について、Vercelの記事を元に調べて見ました。
こういった記事が定期的に投稿されると最新動向が追いやすいため非常にありがたいですね。
また、フロントエンドにおけるパフォーマンスというのは、ユーザ体験の向上という目的が大きいなと思いました。
時間のかかる処理は仕方ないとし、まずユーザインタラクションを優先して処理するという強い意図を感じました。
ここでは触れていませんが、パフォーマンス改善に関しては、Suspenseが最も強力な機能だなと思いました。
最後まで読んでいただきありがとうございました。