banner
Koresamuel

Koresamuel

Sometimes ever, sometimes never.
github
twitter
email

React18はどのようにアプリケーションのパフォーマンスを向上させるのか

React 18 は同時処理機能を導入し、React アプリケーションのレンダリング方法を根本的に変えました。これらの最新機能がアプリケーションのパフォーマンスにどのように影響を与え、向上させるかを探ります。

まず、長いタスクとそれに関連するパフォーマンス測定の基本について少し理解しましょう。

メインスレッドと長いタスク#

ブラウザで JavaScript を実行すると、JavaScript エンジンは通常メインスレッドと呼ばれる単一スレッド環境でコードを実行します。メインスレッドは JavaScript コードの実行に加えて、ユーザーインタラクション(クリックやキーボードイベントなど)の管理、ネットワークイベントの処理、タイマー、アニメーションの更新、ブラウザの再描画や再配置の管理など、他のタスクも処理します。

メインスレッドはタスクを一つずつ処理します

タスクが処理されている間、他のすべてのタスクは待機しなければなりません。ブラウザは小さなタスクをスムーズに実行できますが、長時間のタスクは問題を引き起こす可能性があります。なぜなら、それらが他のタスクの処理をブロックする可能性があるからです。

実行時間が 50 ミリ秒を超えるタスクは「長いタスク」と見なされます。

image

この 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 ミリ秒を超えるタスクの実行にかかる時間の合計であり、ユーザー体験に重大な影響を与える可能性があります。

TBT は 45 ミリ秒です。TTI の前に 50 ミリ秒の閾値を超えた 2 つのタスクがあり、それぞれ 30 ミリ秒と 15 ミリ秒を超えています。総ブロッキング時間はこれらの値の累積です:30 ミリ秒 + 15 ミリ秒 = 45 ミリ秒。

INP(Interaction to Next Paint)#

インタラクションから次の描画(INP)は、ユーザーがページと最初にインタラクションを行った(例えばボタンをクリック)時から、そのインタラクションが画面上で可視化されるまでの時間を測定する新しいコアウェブパフォーマンス指標です;つまり、次の描画です。この指標は、電子商取引サイトやソーシャルメディアプラットフォームのように多くのユーザーインタラクションを持つページにとって特に重要です。これは、現在の訪問中のすべての INP 測定値を累積し、最悪のスコアを返すことで測定されます。

インタラクションから次の描画は 250 ミリ秒で、これは測定された最高の可視遅延です。

React 18 がこれらの測定値に対してどのように最適化され、ユーザー体験を改善したかを理解するためには、従来の React の動作を理解することが重要です。

過去の React レンダリングメカニズム#

React では、視覚的な更新は 2 つの段階に分かれています:レンダリング (render) 段階と コミット (commit) 段階です。React のレンダリング段階は純粋な計算段階であり、この段階では React 要素が既存の DOM と比較されます。この段階では、新しい React ツリー(仮想 DOM とも呼ばれる)が作成され、実際の DOM の軽量なメモリ表現となります。

レンダリング段階では、React は現在の DOM と新しい React コンポーネントツリーの間の差異を計算し、必要な更新を準備します。

image

レンダリング段階の後にはコミット段階があります。この段階では、React はレンダリング段階で計算された更新を実際の DOM に適用します。これには、新しい React コンポーネントツリーにマッピングするために DOM ノードを作成、更新、削除することが含まれます。

従来の同期レンダリングでは、React はコンポーネントツリー内のすべての要素に同じ優先度を与えます。コンポーネントツリーがレンダリングされると、初回レンダリングでも状態更新でも、React はツリー全体をレンダリングし続け、単一の中断不可能なタスクを形成し、その後 DOM にコミットしてコンポーネントを視覚的に更新します。

image

同期レンダリングは「全てか無か」の操作であり、レンダリングを開始したコンポーネントがレンダリングプロセスを完了することを保証します。コンポーネントの複雑さに応じて、レンダリング段階が完了するまでに時間がかかることがあります。この間、メインスレッドはブロックされ、ユーザーがアプリケーションとインタラクションしようとすると、応答しないインターフェースに直面することになります。これは、React がレンダリングを完了し、結果を DOM にコミットするまで続きます。

以下のデモでこの現象を見ることができます。テキスト入力フィールドと、大規模な都市リストがあり、テキスト入力フィールドの現在の値に基づいてフィルタリングされます。同期レンダリングでは、React はキー入力のたびにCitiesListコンポーネントを再レンダリングします。リストには数千の都市が含まれているため、これはかなり高価な計算であり、キー入力とテキスト入力フィールド内での視覚的フィードバックの遅延が顕著に見られます。

Macbook のような高性能デバイスを使用している場合、低性能デバイスの状況をシミュレートするために CPU 性能を 4 倍に下げる必要があるかもしれません。この設定は、開発者ツールの Performance > ⚙️ > CPU で見つけることができます。

Performance タブを確認すると、キー入力のたびに長いタスクが発生していることがわかります。これはあまり理想的な状況ではありません。

赤い角でマークされたタスクは「長いタスク」と見なされます。総ブロッキング時間は 4425.40ms です。

この場合、React の開発者は通常、レンダリングを遅延させるためにサードパーティのライブラリ(例えばDebounce)を使用しますが、組み込みの解決策はありません。

React 18 は新しい同時レンダリングエンジンを導入し、これが裏で動作します。このレンダラーは、特定のレンダリングを緊急でないとマークするためのいくつかの方法を提供します。

低優先度コンポーネント(ピンクの)をレンダリングする際、React はメインスレッドを譲渡し、より重要なタスクがあるかどうかを確認します。

この場合、React は 5 ミリ秒ごとにメインスレッドを譲渡し、ユーザー入力や他の React コンポーネントの状態更新など、現在の状況でユーザー体験にとってより重要なタスクがあるかどうかを確認します。メインスレッドを継続的に譲渡することで、React はこれらのレンダリングを非ブロッキングにし、より重要なタスクを優先的に処理することができます。

各レンダリングで単一の中断不可能なタスクを実行するのとは異なり、この同時レンダラーは低優先度コンポーネントをレンダリングする際に 5 ミリ秒の間隔で制御をメインスレッドに戻します。

さらに、この同時レンダラーはバックグラウンドで「同時に」コンポーネントツリーの複数のバージョンをレンダリングでき、結果を即座にコミットすることはありません。

同期レンダリングが全てか無かの計算プロセスであるのに対し、この同時レンダラーは React が 1 つまたは複数のコンポーネントツリーのレンダリングを一時停止および再開できるようにし、最適なユーザー体験を実現します。

React はユーザーインタラクションに基づいて現在のレンダリングを一時停止し、別の更新のレンダリングを優先します。

同時処理機能を使用することで、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 を効率的に更新できるまで保持されます。このタイミングは、ブラウザがアイドル状態であり、より高い優先度のタスク(ユーザーインタラクションなど)が待機していないときです。

image

CitiesListデモでトランジションを使用するのは非常に理想的です。searchQueryパラメータに渡される値を毎回のキー入力で直接更新するのではなく(その結果、毎回同期レンダリング呼び出しをトリガーします)、状態を 2 つの値に分割し、searchQueryの状態更新をstartTransitionでラップします。

これにより、React に対して、状態更新がユーザーに干渉する視覚的変化を引き起こす可能性があることを伝え、React は新しい状態の準備をバックグラウンドで行いながら、現在のインタラクティブな UI を維持し、更新を即座にコミットしないようにします。

現在、入力フィールドに文字を入力すると、ユーザー入力がスムーズに保たれ、キー入力間の視覚的遅延がありません。これは、text状態が依然として同期的に更新され、入力フィールドがそれをvalueとして使用しているためです。

バックグラウンドで、React は毎回のキー入力で新しいツリー構造のレンダリングを開始します。しかし、全てか無かの同期タスクになるのではなく、React は新しいバージョンのコンポーネントツリーをメモリ内で準備しながら、現在の UI(「古い」状態を表示)をさらなるユーザー入力に応じて応答させ続けます。

Performance タブでは、状態更新をstartTransitionでラップすることで、長時間タスクの数と総ブロッキング時間が大幅に減少したことが示されています。これは、トランジションを使用しなかった場合のパフォーマンスグラフと比較して顕著です。

Performance タブは、長いタスクの数と総ブロッキング時間が大幅に減少したことを示しています。

トランジションは、React のレンダリングモデルにおける基本的な変革であり、React が複数の UI バージョンを同時にレンダリングし、異なるタスク間で優先順位を管理できるようにします。これにより、特に高頻度の更新や CPU 集約型のレンダリングタスクを処理する際に、よりスムーズで応答性の高いユーザー体験が実現します。

React サーバーコンポーネント(RSC)#

Reactサーバーコンポーネント(RSC)は、React 18 の 実験的 特性ですが、すでにフレームワークでの採用の準備が整っています。Next.js を深く掘り下げる前に、これを理解することが重要です。

従来、React はアプリケーションをレンダリングするためのいくつかの主要な方法を提供してきました。すべてのコンテンツをクライアント側で完全にレンダリングする(クライアントレンダリング CSR)か、サーバー上でコンポーネントツリーを HTML としてレンダリングし、この静的 HTML を JavaScript バンドルと共にクライアントに送信して、クライアント側でコンポーネントを統合する(サーバーサイドレンダリング SSR)かのいずれかです。

image

これらの 2 つの方法は、同期的な React レンダラーがクライアント側で提供された JavaScript バンドルを使用してコンポーネントツリーを再構築する必要があることに依存しています。たとえそのコンポーネントツリーがサーバー上で利用可能であってもです。

RSC は、React が実際にシリアル化されたコンポーネントツリーをクライアントに送信できるようにします。クライアントの React レンダラーはこの形式を理解し、HTML ファイルや JavaScript バンドルを送信することなく、効率的に React コンポーネントツリーを再構築します。

image

この新しいレンダリングモードを使用するには、react-server-dom-webpack/serverrenderToPipeableStreamメソッドとreact-dom/clientcreateRootメソッドを組み合わせます。

// 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オブジェクトにアクセスしたり、useStateuseEffectのようなフックを使用したりするなど、クライアントインタラクションを使用すべきではありません。

コンポーネントとそのインポートを JavaScript バンドルに追加してインタラクティブにするには、ファイルの先頭に「use client」ディレクティブを使用します。これにより、Bundlerはこの コンポーネントとそのインポート をクライアントバンドルに追加し、React にクライアントで水和を行い、インタラクティブ性を追加するように指示します。このようなコンポーネントはクライアントコンポーネントと呼ばれます。

注意:フレームワークの実装は異なる場合があります。たとえば、Next.js はサーバー上でクライアントコンポーネントを HTML としてプリレンダリングし、従来の SSR メソッドに似た方法で行います。ただし、デフォルトでは、クライアントコンポーネントのレンダリングは CSR メソッドに似ています。

クライアントコンポーネントを使用する際のバンドルサイズの最適化は開発者に依存します。開発者は以下の方法で実現できます:

  • インタラクティブなコンポーネントの最下層ノードのみが「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 はそのコンポーネントがデータを受け取るのを待っているわけではありません。代わりに、サスペンドされたコンポーネントのレンダリングを一時停止し、他のタスクに注意を移します。

image

この間、React にフォールバック UI をレンダリングさせて、このコンポーネントがまだロード中であることを示すことができます。待機しているデータが利用可能になると、React は中断された方法で以前にサスペンドされたコンポーネントのレンダリングをシームレスに再開できます。これは、以前にトランジションで見たようにです。

React はまた、ユーザーインタラクションに基づいてコンポーネントの優先順位を再調整できます。たとえば、ユーザーが現在レンダリングされていないサスペンドされたコンポーネントとインタラクションを行うと、React は進行中のレンダリングを一時停止し、ユーザーがインタラクションしているコンポーネントを優先します。

image

準備が整うと、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 の自動キャッシュ動作により、グローバルモジュールから単一の関数をエクスポートし、アプリケーション全体で再利用することが可能になります。

image

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を使用する開発者は、この記事で言及されたキャッシュやサーバーコンポーネントなどのフレームワークで利用可能な機能を活用し始めることができます。

参考文献#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。