React 18 は同時処理機能を導入し、React アプリケーションのレンダリング方法を根本的に変えました。これらの最新機能がアプリケーションのパフォーマンスにどのように影響を与え、向上させるかを探ります。
まず、長いタスクとそれに関連するパフォーマンス測定の基本について少し理解しましょう。
メインスレッドと長いタスク#
ブラウザで JavaScript を実行すると、JavaScript エンジンは通常メインスレッドと呼ばれる単一スレッド環境でコードを実行します。JavaScript コードを実行するだけでなく、メインスレッドはユーザーインタラクション(クリックやキーボードイベントなど)の管理、ネットワークイベントの処理、タイマー、アニメーションの更新、ブラウザの再描画や再配置の管理など、他のタスクも処理します。
タスクが処理されている間、他のすべてのタスクは待機しなければなりません。ブラウザは小さなタスクをスムーズに実行してシームレスなユーザー体験を提供できますが、長時間のタスクは問題を引き起こす可能性があります。なぜなら、それらは他のタスクの処理をブロックする可能性があるからです。
実行時間が 50 ミリ秒を超えるタスクは「長いタスク」と見なされます。
この 50 ミリ秒の基準は、デバイスがスムーズな視覚体験を維持するために、毎 16 ミリ秒(60fps)ごとに新しいフレームを作成する必要があるという事実に基づいています。しかし、デバイスはユーザー入力に応答したり、JavaScript コードを実行したりするなど、他のタスクも実行する必要があります。
この 50 ミリ秒の基準により、デバイスはレンダリングフレームと他のタスクの両方にリソースを同時に割り当てることができ、スムーズな視覚体験を維持しながら、他のタスクを実行するために約 33.33 ミリ秒の余裕を提供します。この 50 ミリ秒の基準については、RAIL モデルを扱ったブログ記事で詳しく知ることができます。
最適なパフォーマンスを維持するためには、長いタスクの数を最小限に抑えることが重要です。ウェブサイトのパフォーマンスを測定するために、長いタスクがアプリケーションのパフォーマンスに与える影響を測定する 2 つの指標があります:TBT(Total Blocking Time)と INP(Interaction to Next Paint)。
TBT(Total Blocking Time)#
総ブロッキング時間(TBT)は、最初のコンテンツ描画(FCP)とインタラクティブ時間(Time to Interactive, TTI)の間の時間を測定する重要な指標です。TBT は、50 ミリ秒を超えるタスクの実行にかかる時間の合計であり、ユーザー体験に重大な影響を与える可能性があります。
INP(Interaction to Next Paint)#
インタラクションから次の描画(INP)は、ユーザーがページと最初にインタラクションを行った(例えばボタンをクリック)時から、そのインタラクションが画面上に表示されるまでの時間を測定する新しいコアウェブパフォーマンス指標です;つまり、次の描画です。この指標は、電子商取引サイトやソーシャルメディアプラットフォームのように多くのユーザーインタラクションを持つページにとって特に重要です。これは、現在の訪問中のすべての INP 測定値を累積し、最も悪いスコアを返すことで測定されます。
React 18 がこれらの測定値に対してどのように最適化を行い、ユーザー体験を改善したかを理解するためには、従来の React の動作を理解することが重要です。
過去の React レンダリングメカニズム#
React では、視覚的更新は 2 つの段階に分かれています:レンダリング(render) 段階と コミット(commit) 段階です。React のレンダリング段階は純粋な計算段階であり、この段階では React 要素が既存の DOM と比較されます。この段階では、新しい React ツリー(仮想 DOM とも呼ばれる)が作成され、実際の DOM の軽量なメモリ表現となります。
レンダリング段階では、React は現在の DOM と新しい React コンポーネントツリーの間の差異を計算し、必要な更新を準備します。
レンダリング段階の後に続くのがコミット段階です。この段階では、React はレンダリング段階で計算された更新を実際の DOM に適用します。これには、新しい React コンポーネントツリーにマッピングするための DOM ノードの作成、更新、削除が含まれます。
従来の同期レンダリングでは、React はコンポーネントツリー内のすべての要素に同じ優先度を与えます。コンポーネントツリーがレンダリングされると、初期レンダリングでも状態更新でも、React はツリー全体をレンダリングし続け、単一の中断不可能なタスクを形成し、その後 DOM にコミットしてコンポーネントを視覚的に更新します。
同期レンダリングは「全てか無か」の操作であり、レンダリングを開始したコンポーネントがレンダリングプロセスを完了することを保証します。コンポーネントの複雑さに応じて、レンダリング段階が完了するまでに時間がかかる場合があります。この間、メインスレッドはブロックされ、ユーザーがアプリケーションとインタラクションを試みると、応答しないインターフェースに直面することになります。これは、React がレンダリングを完了し、結果を DOM にコミットするまで続きます。
以下のデモでこの現象を見ることができます。テキスト入力フィールドと、大規模な都市リストがあり、テキスト入力フィールドの現在の値に基づいてフィルタリングされます。同期レンダリングでは、React は毎回キーを押すたびにCitiesList
コンポーネントを再レンダリングします。リストには数千の都市が含まれているため、これは非常にコストのかかる計算であり、キーを押す際にテキスト入力フィールドで明らかな視覚的フィードバックの遅延が見られます。
Macbook のような高性能デバイスを使用している場合、低性能デバイスの状況をシミュレートするために CPU 性能を 4 倍に下げる必要があるかもしれません。この設定は、開発者ツールの Performance > ⚙️ > CPU で見つけることができます。
Performance タブを確認すると、キーを押すたびに長いタスクが発生していることがわかります。これはあまり理想的な状況ではありません。
この場合、React の開発者は通常、レンダリングを遅延させるためにサードパーティのライブラリ(例えばDebounce
)を使用しますが、組み込みの解決策はありません。
React 18 は、新しい同時レンダリングエンジンを導入しました。これはバックグラウンドで動作します。このレンダラーは、特定のレンダリングを緊急でないものとしてマークするためのいくつかの方法を提供します。
この場合、React は 5 ミリ秒ごとにメインスレッドを譲り、ユーザー入力のようなより重要なタスクを処理する必要があるかどうかを確認します。現在の状況では、別の React コンポーネントの状態更新をレンダリングすることが、ユーザー体験にとってより重要です。メインスレッドを継続的に譲ることで、React はこれらのレンダリングを非ブロッキングにし、より重要なタスクを優先的に処理できるようになります。
さらに、この同時レンダリングエンジンは、結果を即座にコミットすることなく、バックグラウンドで複数のバージョンのコンポーネントツリーを「同時」にレンダリングすることができます。
同期レンダリングは全てか無かの計算プロセスですが、この同時レンダリングエンジンは React が 1 つまたは複数のコンポーネントツリーのレンダリングを一時停止および再開できるようにし、最適なユーザー体験を実現します。
同時処理機能を使用することで、React はユーザーインタラクションなどの外部イベントに基づいてコンポーネントのレンダリングを一時停止および再開できます。ユーザーがComponentTwo
とインタラクションを開始すると、React は現在のレンダリングを一時停止し、ComponentTwo
を優先的にレンダリングし、その後ComponentOne
のレンダリングを再開します。
トランジション#
useTransition
フックによって提供されるstartTransition
関数を使用して、更新を非緊急状態としてマークできます。これは強力な新機能であり、特定の状態更新を「トランジション」としてマークすることができ、これにより視覚的変化を引き起こす可能性があり、同期的にレンダリングされるとユーザー体験に干渉する可能性があります。
状態更新をstartTransition
でラップすることで、React に対して、より重要なタスクを優先するためにレンダリングを遅延または中断できることを伝え、現在のユーザーインターフェースのインタラクティブ性を維持できます。
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}
トランジションが開始されると、同時レンダリングエンジンはバックグラウンドで新しいツリー構造を準備します。レンダリングが完了すると、結果はメモリに保持され、React スケジューラーが新しい状態を反映するために DOM を効率的に更新できるまで保持されます。このタイミングは、ブラウザがアイドル状態であり、より高い優先度のタスク(ユーザーインタラクションなど)が待機していないときです。
CitiesList
デモでトランジションを使用するのは非常に理想的です。searchQuery
パラメータに渡される値を毎回のキー入力時に直接更新するのではなく(そのため、毎回のキー入力時に同期レンダリング呼び出しをトリガーします)、状態を 2 つの値に分割し、searchQuery
の状態更新をstartTransition
でラップします。
これにより、状態更新がユーザーに干渉する視覚的変化を引き起こす可能性があることを React に伝え、React は新しい状態の準備をバックグラウンドで行いながら、現在のインタラクティブな UI を維持し、更新を即座にコミットしないようにします。
現在、入力フィールドに入力すると、ユーザー入力がスムーズに保たれ、キー入力間の視覚的遅延がありません。これは、text
状態が依然として同期的に更新され、入力フィールドがそれをvalue
として使用しているためです。
バックグラウンドで、React は毎回のキー入力時に新しいツリー構造のレンダリングを開始します。しかし、全てか無かの同期タスクになるのではなく、React はメモリ内で新しいバージョンのコンポーネントツリーを準備しながら、現在の UI(「古い」状態を表示)をさらなるユーザー入力に応じて応答させ続けます。
Performance タブでは、状態更新をstartTransition
でラップすることで、長いタスクの数と総ブロッキング時間が大幅に減少したことが確認できます。これは、トランジションを使用しなかった場合のパフォーマンスチャートと比較して顕著です。
トランジション
は、React のレンダリングモデルにおける基本的な変革であり、React が複数のバージョンの UI を同時にレンダリングし、異なるタスク間で優先度を管理できるようにします。これにより、特に高頻度の更新や CPU 集約型のレンダリングタスクを処理する際に、よりスムーズで応答性の高いユーザー体験が実現されます。
React サーバーコンポーネント(RSC)#
Reactサーバーコンポーネント(RSC)
は、React 18 の 実験的 特性ですが、すでにフレームワークでの採用の準備が整っています。Next.js を深く掘り下げる前に、これを理解することが重要です。
従来、React はアプリケーションをレンダリングするためのいくつかの主要な方法を提供してきました。すべてをクライアント側で完全にレンダリングする(クライアントレンダリング CSR)か、サーバー上でコンポーネントツリーを HTML としてレンダリングし、この静的 HTML を JavaScript バンドルと一緒にクライアントに送信して、クライアント側でコンポーネントを統合する(サーバーサイドレンダリング SSR)ことができます。
これらの 2 つの方法は、同期の React レンダラーが提供された JavaScript バンドルを使用してクライアント側でコンポーネントツリーを再構築する必要があることに依存しています。たとえそのコンポーネントツリーがサーバー上で利用可能であってもです。
RSC は、React が実際にシリアル化されたコンポーネントツリーをクライアントに送信できるようにします。クライアントの React レンダラーはこの形式を理解し、それを使用して HTML ファイルや JavaScript バンドルを送信することなく、効率的に React コンポーネントツリーを再構築します。
この新しいレンダリングモードを使用するには、react-server-dom-webpack/server
のrenderToPipeableStream
メソッドとreact-dom/client
のcreateRoot
メソッドを組み合わせます。
// server/index.js
import App from '../src/App.js'
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(React.createElement(App));
return pipe(res);
});
---
// src/index.js
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';
export function Index() {
...
return createFromFetch(fetch('/rsc'));
}
const root = createRoot(document.getElementById('root'));
root.render(<Index />);
完全なコードとデモはこちらをクリックしてご覧ください。次のセクションでは、より詳細な例を紹介します。
デフォルトでは、React はRSC
の水和(hydration)を行いません。これらのコンポーネントは、window
オブジェクトにアクセスしたり、useState
やuseEffect
のようなフックを使用したりするなど、クライアントインタラクションを使用するべきではありません。
コンポーネントとそのインポートを JavaScript バンドルに追加してインタラクティブにするには、ファイルの先頭に「use client」指令を使用します。これにより、Bundler
はこの コンポーネントとそのインポート をクライアントバンドルに追加し、React にクライアントで水和を行い、インタラクティブ性を追加するように指示します。このようなコンポーネントはクライアントコンポーネント
と呼ばれます。
クライアントコンポーネント
を使用する際、バンドルサイズの最適化は開発者に依存します。開発者は以下の方法で実現できます:
- インタラクティブなコンポーネントの最下層ノードのみが
「use client」
指令を定義していることを確認します。これには、いくつかのコンポーネントのデカップリングが必要になる場合があります。 - コンポーネントツリーを直接インポートするのではなく、props として渡します。これにより、React は子コンポーネントを RSC としてレンダリングでき、クライアントバンドルに追加する必要がなくなります。
サスペンス#
もう一つの重要な新しい同時処理機能はサスペンス
です。これは React 16 でReact.lazy
のコード分割に使用されていましたが、React 18 で新たにデータ取得に拡張されました。
サスペンス
を使用すると、リモートソースからデータをロードするなどの特定の条件が満たされるまで、コンポーネントのレンダリングを遅延させることができます。その間、コンポーネントがまだ読み込まれていることを示すフォールバックコンポーネントをレンダリングできます。
読み込み状態を宣言的に定義することで、条件付きレンダリングロジックの必要性を減らします。サスペンス
をRSC
と組み合わせることで、データベースやファイルシステムのような個別の API エンドポイントを必要とせずに、サーバー側のデータソースに直接アクセスできます。
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
サスペンス
の真の力は、React の同時処理機能との深い統合から来ています。たとえば、コンポーネントがサスペンドされ、データのロードを待っている間、React はそのコンポーネントがデータを受け取るのを待ってじっとしているわけではありません。代わりに、サスペンドされたコンポーネントのレンダリングを一時停止し、他のタスクに注意を移します。
この間、React にフォールバック UI をレンダリングさせて、このコンポーネントがまだ読み込まれていることを示すことができます。待機しているデータが利用可能になると、React は中断された方法で以前にサスペンドされたコンポーネントのレンダリングをシームレスに再開できます。これは、以前のトランジションで見たようにです。
React はまた、ユーザーインタラクションに基づいてコンポーネントの優先度を再調整できます。たとえば、ユーザーが現在レンダリングされていないサスペンドされたコンポーネントとインタラクションを行うと、React は進行中のレンダリングを一時停止し、ユーザーがインタラクションしているコンポーネントを優先します。
準備が整うと、React はそれを DOM にコミットし、以前のレンダリングを再開します。これにより、ユーザーインタラクションの優先度が確保され、UI が応答性を保ち、ユーザー入力に最新の状態を維持します。
サスペンス
とRSC
のストリーミング形式の組み合わせにより、高優先度の更新が準備が整い次第、すぐにクライアントに送信され、低優先度のレンダリングタスクが完了するのを待つ必要がなくなります。これにより、クライアントはデータの処理を早期に開始し、非ブロッキングの方法でコンテンツを段階的に表示し、ユーザーによりスムーズな体験を提供します。
この中断可能なレンダリングメカニズムとサスペンス
が非同期操作を処理する能力が組み合わさることで、特に大量のデータ取得を必要とする複雑なアプリケーションに対して、よりスムーズでユーザー中心の体験が提供されます。
データ取得#
レンダリング更新に加えて、React 18 はデータを効率的に取得し、メモ化するための新しい API を導入しました。
React 18 は現在、キャッシュ関数を持っており、これはラップされた関数呼び出しの結果を記憶します。同じレンダリングプロセス内で 同じパラメータ を使用して同じ関数を再度呼び出すと、関数を再実行することなくメモ化された値を使用します。
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // 同じレンダーパス内で呼び出されました:メモ化された結果を返します。
fetch 呼び出しでは、React 18 は現在、cache に似たキャッシュメカニズムをデフォルトで含んでおり、これを使用する必要はありません。これにより、単一のレンダリングプロセス内でのネットワークリクエストの回数が減少し、アプリケーションのパフォーマンスが向上し、API コストが削減されます。
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // 同じレンダーパス内で呼び出されました:メモ化された結果を返します。
React サーバーコンポーネントを使用する際、これらの機能は非常に便利です。なぜなら、これらは Context API にアクセスできないからです。cache と fetch の自動キャッシュ動作により、グローバルモジュールから単一の関数をエクスポートし、アプリケーション全体で再利用することが可能になります。
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}
async function BlogPostContent() {
const post = await fetchBlogPost('123'); // メモ化された値を返します
return '...'
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}
結論#
要するに、React 18 の最新機能は多くの面でパフォーマンスを向上させました。
- 同時処理 React により、レンダリングプロセスは一時停止し、後で再開したり、放棄したりすることができます。これにより、大きなレンダリングタスクが進行中でも、UI はユーザー入力に即座に応答できます。
- トランジション API は、データ取得や画面切り替えの間によりスムーズな遷移を実現し、ユーザー入力をブロックしません。
- React サーバーコンポーネント は、開発者がサーバーとクライアントの両方で機能するコンポーネントを構築できるようにし、クライアントアプリのインタラクティブ性と従来のサーバーレンダリングのパフォーマンスを組み合わせ、補水を必要としません。
- 拡張された
サスペンス
機能は、アプリケーションの一部を他の部分よりも先にレンダリングできるようにし、データ取得に時間がかかる部分のロードパフォーマンスを向上させます。
Next.js の App Routerを使用する開発者は、この記事で言及されたキャッシュやサーバーコンポーネントなどのフレームワークで利用可能な機能を活用し始めることができます。