POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSXFacebook
Christian Ekrem

本記事は、原著者の許諾のもとに翻訳・掲載しております。

差分検出エンジン

以前投稿した記事(12)で、React.memoの仕組みと、コンポジションを通じてよりスマートにパフォーマンスを最適化する方法について説明しました。しかし、Reactのパフォーマンスを完全にマスターするには、すべてを動かすエンジンである、Reactの差分検出アルゴリズムを理解する必要があります。 差分検出は、ReactがDOMをアップデートし、コンポーネントツリーと一致させるプロセスです。Reactの宣言的プログラミングを可能にしているのが差分検出です。ユーザーが求めるものを説明すると、Reactがそれを効率的に実現する方法を探します。

コンポーネントの識別情報とstateの永続性

技術的な内容について話し始める前に、Reactがコンポーネントの識別情報(そのコンポーネントらしさを形成する独自性)についてどのように考えているかを示す驚くべき動作を見てみましょう。 以下の簡単なテキスト入力のトグル例をご覧ください。

const UserInfoForm = () => {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <div className="form-container">
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "Cancel" : "Edit"}
      </button>

      {isEditing ? (
        <input
          type="text"
          placeholder="Enter your name"
          className="edit-input"
        />
      ) : (
        <input
          type="text"
          placeholder="Enter your name"
          disabled
          className="view-input"
        />
      )}
    </div>
  );
};

このフォームを操作すると、興味深い動作がみられます。編集画面で入力欄に任意のテキストを入力して「キャンセル」ボタンをクリックすると、再度「編集」ボタンをクリックしたとき、入力したテキストが残った状態になります。この現象は、2つのinput要素が異なるprops(1つは無効化され、クラスが異なります)を持っているにもかかわらず起こります。

ReactはDOM要素とそのstateを保持しますが、これはどちらの要素も同じタイプ(input)であり、要素ツリーの同じ位置にあるためです。Reactは要素を作り直すのではなく、単純に既存の要素のpropsを更新します。

しかし、実装を以下のように変更するとどうでしょうか。

{
  isEditing ? (
    <input type="text" placeholder="Enter your name" className="edit-input" />
  ) : (
    <div className="view-only-display">Name will appear here</div>
  );
}

そうすると、編集モードを切り替えることで全く異なる要素がマウントおよびアンマウントされ、ユーザーの入力は失われます。

この動作は、Reactの差分検出の基本的側面に焦点を当てます。すなわち、要素タイプは識別情報を決める主要な要因であるということです。この概念を理解することが、Reactのパフォーマンスをマスターする上でカギとなります。

仮想DOMではなく要素ツリー

Reactがアップデートを最適化するために「仮想DOM」を使用するということはおそらくご存知でしょう。これは有益なメンタルモデルではありますが、Reactの内部を要素ツリー(画面上に表示される内容を簡単に表したもの)として考えた方が正確です。

以下のようなJSXを書くとします。

const Component = () => {
  return (
    <div>
      <h1>Hello</h1>
      <p>World</p>
    </div>
  );
};

ReactはこれをJavaScriptのプレーンオブジェクトからなるツリー構造に変換します。

{
  type: 'div',
  props: {
    children: [
      {
        type: 'h1',
        props: {
          children: 'Hello'
        }
      },
      {
        type: 'p',
        props: {
          children: 'World'
        }
      }
    ]
  }
}

divinputのようなDOM要素では、「type」は文字列です。一方、カスタムReactコンポーネントでは、「タイプ」は実際の関数への参照です。

{
  type: Input, // Reference to the Input function itself
  props: {
    id: "company-tax-id",
    placeholder: "Enter company Tax ID"
  }
}

差分検出の仕組み

ReactがUIをアップデートする必要がある場合(stateの変更または再レンダリング後)以下のことを行います。

  1. コンポーネントを呼び出し、新しい要素ツリーを作成する
  2. 以前のツリーと新しいツリーを比較する
  3. 実際のDOMを新しいツリーと一致させるために必要なDOM操作を割り出す
  4. それらの操作を効率的に実行する 比較アルゴリズムは以下の基本原則に従います。

1. 要素タイプが識別情報を決める

Reactはまず要素の「タイプ」を確認します。タイプが変わる場合、Reactはサブツリー全体を再構築します。

// From this (first render)
<div>
  <Counter />
</div>

// To this (second render)
<span>
  <Counter />
</span>

divspanに変わったため、Reactは古いツリーの全体(Counterを含む)を破壊し、新しいツリーをゼロから構築します。

2. ツリー内の位置は重要

Reactの差分検出アルゴリズムは、ツリー構造内におけるコンポーネントの位置に大きく依存します。位置は、差分を比較する際に識別情報を示す主な要因となります。

// Let's pretend showDetails is true: Render UserProfile
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

// Let's pretend showDetails is false: Render LoginPrompt instead
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

この条件式の例では、Reactはフラグメントの最初の子の位置を一つの「スロット」として扱います。showDetailstrueからfalseに変わると、Reactはそれぞれのレンダー結果の同じ位置にある情報を比較し、そこには異なるコンポーネントタイプ(UserProfile vs LoginPrompt)があります。ポジション1のコンポーネントタイプが変わったため、Reactは古いコンポーネントをすべて(stateも含めて)アンマウントし、新しいコンポーネントをマウントします。

このポジションベースの識別情報は、もっとシンプルなケースでコンポーネントがstateを保持する理由でもあります。

// Before
<>
  {isPrimary ? (
    <UserProfile userId={123} role="primary" />
  ) : (
    <UserProfile userId={456} role="secondary" />
  )}
</>

この例では、isPrimaryの値にかかわらず、同じ位置に同じコンポーネントタイプ(UserProfile)があります。Reactはコンポーネントを再マウントするのではなく、シンプルにpropsを更新し、インスタンスを保持します。

このポジションベースのアプローチは、ほとんどのシナリオで有効ですが、以下の場合には問題が生じます。

  1. コンポーネントの位置が動的に移動する(リストのソート時など)
  2. コンポーネントの位置が変わる場合にstateを保持する必要がある
  3. コンポーネントを再マウントするタイミングを正確に制御したい

ここで活躍するのがReactのkeyシステムです。

3. keyはポジションベースの比較をオーバーライドする

デベロッパーは、key属性によりコンポーネントの識別情報を明確に制御し、Reactのデフォルト動作である位置に基づく識別をオーバーライドすることができます。

const TabContent = ({ activeTab, tabs }) => {
  return (
    <div className="tab-container">
      {tabs.map((tab) => (
        // Key overrides position-based comparison
        <div key={tab.id} className="tab-content">
          {activeTab === tab.id ? (
            <UserProfile
              key="active-profile"
              userId={tab.userId}
              role={tab.role}
            />
          ) : (
            <div key="placeholder" className="placeholder">
              Select this tab to view {tab.userId}'s profile
            </div>
          )}
        </div>
      ))}
    </div>
  );
};

条件式のレンダー結果としてUserProfileコンポーネントが異なる位置にくる場合でも、Reactは同じkeyを持つコンポーネントを同じコンポーネントとして扱います。タブが有効になると、「active-profile」keyが変わらないためReactはコンポーネントのstateを保持し、その結果、タブの切り替えをスムーズに行うことができます。

この例は、レンダーツリー構造上の位置にかかわらず、keyを用いることでコンポーネントの識別情報をいかにして維持できるかを示しています。keyは、Reactによるコンポーネント階層構造の差分検出を制御する強力な手段を与えてくれます。

keyの魔法

keyは主にリストにおける役割で知られていますが、Reactの差分検出プロセスに与える影響はそれだけにとどまりません。

keyがリストに必要である理由

リストをレンダリングする際、Reactはkeyを用いて項目の追加、削除、並べ替えを把握しています。

<ul>
  {items.map((item) => (
    <li key={item.id}>{item.text}</li>
  ))}
</ul>

keyがなければ、Reactは配列上の要素の位置だけに頼らなくてはなりません。先頭に新しい項目を挿入した場合、Reactはすべての要素の位置が変わったとみなし、リスト全体を再レンダリングします。

keyがあれば、Reactは位置にかかわらずレンダー間の要素を照合することができます。

配列以外のkey?

Reactは、静的要素に対してkeyの追加を強要しません。

// No keys needed
<>
  <Input />
  <Input />
</>

これは、Reactがこれらの要素が静的であることを知っているからです。つまり、ツリー上の要素の位置が予測可能だということです。

しかし、keyはリストの外でも強力なツールになり得ます。以下の例をご覧ください。

const Component = () => {
  const [isReverse, setIsReverse] = useState(false);

  return (
    <>
      <Input key={isReverse ? "some-key" : null} />
      <Input key={!isReverse ? "some-key" : null} />	
    </>
  );
};

isReverseが切り替わると、2つの入力の間で'some-key'が移動するため、Reactはコンポーネントのstateを2つの位置の間で「移動」させることができます。

動的要素と静的要素を混在させる

動的リストに項目を追加することで、リストの後の静的要素の識別情報が変わるのではないかという懸念がよく聞かれます。

<>
  {items.map((item) => (
    <ListItem key={item.id} />
  ))}
  <StaticElement /> {/* Will this re-mount if items change? */}
</>

Reactはこの問題にスマートに対処します。動的リスト全体を最初の位置でひとまとまりとして扱うため、リストに変更があってもStaticElementは常に同じ位置と識別情報が保たれます。

React内部では以下のように表現されます。

[
  // The entire dynamic array becomes a single child
  [
    { type: ListItem, key: "1" },
    { type: ListItem, key: "2" },
  ],
  { type: StaticElement }, // Always maintains its second position
];

リストに項目を追加または削除しても、StaticElementは親配列のポジション2のまま変わりません。つまり、リストに変更が加えられても再マウントされないということです。このスマートな仕組みにより、隣接する動的リストの変更によって静的要素が不必要に再マウントされることがなく、処理が最適化されます。

3. DOMを戦略的に制御するためのkey

keyの用途はリストに限りません。React上でコンポーネントやDOM要素の識別情報を制御するのに有益なツールです。Reactコンポーネントのstateを異なるビュー上で保持する際、keyとコンポーネントタイプが使われるということを覚えておきましょう。つまり、keyは同じでもタイプが異なれば、コンポーネントはアンマウントおよび再マウントされます。これらの場合、一般的にはstateのリフトアップの方が良い方法です。

// State lifting approach for preserving state across different views (keys are no good here...)
const TabContent = ({ activeTab }) => {
  // State that needs to be preserved across tab changes
  const [sharedState, setSharedState] = useState({
    /* initial state */
  });

  return (
    <div>
      {activeTab === "profile" && (
        <ProfileTab state={sharedState} onStateChange={setSharedState} />
      )}
      {activeTab === "settings" && (
        <SettingsTab state={sharedState} onStateChange={setSharedState} />
      )}
      {/* Other tabs */}
    </div>
  );
};

この場合、タブ間でタイプ(および参照先)が異なるため、keyを保持するだけでは不十分です。

しかし、keyと非制御コンポーネントを用いた以下の例をご覧ください。

const UserForm = ({ userId }) => {
  // No React state here - using uncontrolled inputs

  return (
    <form>
      <input
        key={userId}
        name="username"
        // Uncontrolled input with defaultValue instead of value
        defaultValue=""
      />
      {/* Other form inputs */}
    </form>
  );
};

userIdに基づくkeyを非制御入力に与えることで、ReactはuserIdが変わるたびに全く新しいDOM要素を作成するようになります。非制御入力のstateがReactのstateではなくDOM自体の中に存在するため、ユーザーを切り替える際に入力が効果的にリセットされます。この場合、必要なのはkeyだけです。

非常に秀逸です。

stateコロケーション:強力なパフォーマンスパターン

stateコロケーションは、stateを使用場所のできるだけ近くにとどめておくパターンです。このアプローチでは、state変更の影響を直接受けるコンポーネントだけがアップデートされるようにすることで、不要な再レンダリングを最小限にとどめます。

以下の例をご覧ください。

// Poor performance - entire app re-renders when filter changes
const App = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <>
      <SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
      <ExpensiveComponent />
    </>
  );
};

filterTextが変わると、フィルターの影響を受けないExpensiveComponentも含めてAppコンポーネント全体が再レンダリングされます。 では、フィルターのstateを、それを使用するコンポーネントとだけコロケーションしてみましょう。

const UserSection = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <>
      <SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
    </>
  );
};

const App = () => {
  return (
    <>
      <UserSection />
      <ExpensiveComponent />
    </>
  );
};

そうすると、フィルターが変わっても、UserSectionだけが再レンダリングされます。このパターンは、パフォーマンスを改善するだけでなく、各コンポーネントが保持するstateだけを管理するようにすることで、より良いコンポーネント設計を可能にします。

コンポーネント設計:変更の最適化

パフォーマンスの最適化は、コンポーネント設計においてしばしば課題となります。コンポーネントの機能が多すぎると、不要な再レンダリングが行われる可能性が高くなります。

React.memoに頼る前に、以下の点を検討してみましょう。

  1. コンポーネントに複数の責任が与えられていないか。複数の関心事を処理するコンポーネントは、頻繁に再レンダリングする可能性が高くなります。
  2. stateをリフトアップしすぎていないか。stateをツリー上で必要以上にリフトアップすると、より多くのコンポーネントが再レンダリングされるようになります。

以下の例をご覧ください。

// Problematic design - mixed concerns
const ProductPage = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");
  const [reviews, setReviews] = useState([]);

  // Fetches both product details and reviews
  useEffect(() => {
    fetchProductDetails(productId);
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return (
    <div>
      <ProductInfo
        selectedSize={selectedSize}
        onSizeChange={setSelectedSize}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
      <Reviews reviews={reviews} />
    </div>
  );
};

サイズ、数量、または配送オプションが変わるたびに、無関係なレビューセクションも含めてページ全体が再レンダリングされます。

より良い設計は、以下のようにこれらの関心事を分けます。

const ProductPage = ({ productId }) => {
  return (
    <div>
      <ProductConfig productId={productId} />
      <ReviewsSection productId={productId} />
    </div>
  );
};

const ProductConfig = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");

  // Product-specific logic

  return (
    <>
      <ProductInfo
        selectedSize={selectedSize}
        onSizeChange={setSelectedSize}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
    </>
  );
};

const ReviewsSection = ({ productId }) => {
  const [reviews, setReviews] = useState([]);

  useEffect(() => {
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return <Reviews reviews={reviews} />;
};

この構造では、製品サイズを変えてもレビューが再レンダリングされることはありません。メモ化も不要です。コンポーネントの境界を明確にするだけです。

差分検出とクリーンアーキテクチャ

差分検出に関するこうした理解は、クリーンアーキテクチャの原則とも完全に一致します。

  1. 単一責任の原則:コンポーネントを変更する理由は1つに限定します。各コンポーネントが1つの責任に徹することで、不要な再レンダリングがトリガーされる可能性が低くなります。
  2. 依存関係逆転:コンポーネントは具体的な実装ではなく、抽象化に依存するべきです。そうすることで、コンポジションを通じてパフォーマンスを最適化しやすくなります。
  3. インターフェース分離:コンポーネントには、最低限の特化したインターフェースを与えます。そうすることで、propsの変更が不要な再レンダリングをトリガーする可能性が低くなります。

実践的ガイドライン

差分検出に関する深掘りに基づく実践的なアドバイスをいくつか紹介します。

  1. コンポーネントの定義は親コンポーネントの外に置き、再マウントを回避する。
  2. stateをリフトダウンし、再レンダリングの境界を分離する。
  3. 同じ位置のコンポーネントタイプの一貫性を保ち、アンマウントを回避する。
  4. keyを戦略的に利用する。リストに限らず、コンポーネントの識別情報を制御したい場合に使用する。
  5. 再レンダリング問題をデバッグする際、要素ツリーとコンポーネントの識別情報の観点で考える。
  6. React.memoは差分検出の制約の範囲内で有効なツールにすぎないことを念頭に置く。基本的なアルゴリズムは変わらない。

おわりに

Reactの差分検出アルゴリズムを理解すると、Reactのパフォーマンスに関するさまざまなパターンの仕組みが見えてきます。コンポジションがなぜこれほどまでに有効なのかや、リストにkeyが必要である理由、他のコンポーネント内でコンポーネントを定義するのがなぜ問題なのかがわかります。

この知識を身につけることで、アーキテクチャに関してより良い判断ができるようになり、Reactアプリケーションのパフォーマンスも自然と向上します。Reactの差分検出アルゴリズムに過剰なメモ化で対抗するのではなく、Reactがコンポーネントを識別し、更新する方法に合わせたコンポーネント構造を設計することで、うまくReactを使いこなせるようになるはずです。

次にReactアプリケーションを最適化する際には、コンポーネント構造が差分検出プロセスにどのような影響を及ぼしているのかを考えてみてください。Reactがコンポーネントを識別し、更新する方法を踏まえた、よりシンプルで特化したコンポーネントツリーが最も効果的な最適化である場合もあります。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。