🫧

React Forget は何を「忘れ」させてくれるのか

2024/03/04に公開

はじめに

こんにちは、株式会社TERASSでエンジニアをしている myrear です。
先日 React Blog にて公開された React Labs: 私達のこれまでの取り組み - 2024年2月版 という記事に React Compiler に関する記述があります。
この React Compiler とは React コードを自動的に最適化し、それにより開発者はメモ化について考える必要がなくなる(忘れることができる)というものです。
まるで魔法のようですが、一体どのような方法でコードの最適化を実現しているのでしょうか?
本記事では2023年秋の講演の動画を自動翻訳字幕で追いながら要所要所をかいつまんで解説していきます。

https://www.youtube.com/watch?v=qOQClO3g8-Y

React Forget とは?

先述の通り React コードを自動的に最適化してくれるコンパイラです。
具体的には JavaScript と React のルールを理解し、ルールに沿ったコードのみを最適化し、これまで useMemo()React.memo() で行っていたような手動でのメモ化を自動的に行ってくれます。
まだオープンソースにはなっていませんが instagram.com の本番環境ではすでに動作しているそうです。
リリースまであとちょっと、という感じですね。

Understanding Idiomatic React

ここから講演内容を探っていきます。

手動でのメモ化は妥協策である

[1:14~]
例として以下の Meta 社の内部で使用されているコンポーネントを簡略化したコードが示されます。

function VideoTab({ heading, videos, filter }) {
  const filteredVideos = [];
  for (const video of videos) {
    if (applyFilter(video, filter)) {
      filteredVideos.push(video);
    }
  }
  if (filteredVideos.length === 0) {
    return <NoVideos />;
  }
  return (
    <>
      <Heading heading={heading} count={filteredVideos.length} />
      <VideoList videos={filteredVideos} />
    </>
  );
}

この VideoTab コンポーネントは、見出し heading と動画リスト videos とフィルター filter を受け取り、そのフィルターに一致する見出しと動画リストをレンダリングします。
標準的な JavaScript の構文を使用しており、ごく一般的なコンポーネントです。

しかしこのコードには、アプリを不必要に再レンダリングしパフォーマンスに影響を与える可能性のある潜在的な問題点があります。
例えば VideoTab コンポーネントを呼び出している親コンポーネントが heading だけを変更したとします。

VideoTab コンポーネントは heading の変更により再レンダリングされ、 Heading コンポーネントは期待通りに更新されます。

一方で videos (と filter )が変更されていないからといって VideoList が再レンダリングされないかというとそうではなく、 VideoList も再レンダリングされます。
これは不要な再レンダリングです。

これを防ぐには useMemo()React.memo() によりメモ化を行う必要があります。

function VideoTab({ heading, videos, filter }) {
- const filteredVideos = [];
- for (const video of videos) {
-   if (applyFilter(video, filter)) {
-     filteredVideos.push(video);
-   }
- }
+ const filteredVideos = useMemo(() => {
+   const filteredVideos = [];
+   for (const video of videos) {
+     if (applyFilter(video, filter)) {
+       filteredVideos.push(video);
+     }
+   }
+   return filteredVideos;
+ }, [videos, filter]);
  if (filteredVideos.length === 0) {
    return <NoVideos />;
  }
  return (
    // ...
  );
}
VideoList.jsx
function VideoList(props) {
  // ...
}

export default React.memo(VideoList)

以上の変更によって、 heading のみが変更された場合に VideoList コンポーネントが再レンダリングされることはなくなりました。
一般的にアプリケーションにパフォーマンス上の問題がある場合、コードベース全体で同様のメモ化を行うことでより良いパフォーマンスを得ることができます。

しかしこの種のメモ化はコードの簡潔さを大幅に低下させ、 UI に何を表示すべきかという情報以上の意味を持ってしまいます。
コードレビューという観点でも適切にメモ化できているか、メモ化が少なすぎない/多すぎないかを確認しなければならず負担となってしまいます。
つまり useMemo() などの手動によるメモ化は、パフォーマンスの向上と引き換えにきれいで簡潔なもとのロジックの一部を犠牲にするという妥協策と言えます。

そもそもなぜ開発者が手動でメモ化を行う必要があったのかというと、 VideoList コンポーネントが heading に依存しないことを React に伝えるためでした。
我々開発者は JavaScript については理解しているはずで、メモ化を行う前のコードを読めば VideoList コンポーネントは heading に依存しないということはわかります。
開発者目線ではこの依存関係を理解できているのに、なぜそれをもう一度 React にまで教えなければならないのでしょうか?もし React がアプリケーションのビルド時にこれを理解し判断してくれるようになるとどうでしょうか?
そこで登場するのが React Forget です。

React Forget

[6:42~]
このコンパイラを使用することで開発者は手動でのメモ化を行う必要がなくなり、場合によっては手動でメモ化をする以上のパフォーマンスを得ることができます。
まずは一旦メモ化を施す前の最初の VideoTab コンポーネントに戻ります。 VideoList コンポーネントの React.memo() もないものとして考えてください。

function VideoTab({ heading, videos, filter }) {
  const filteredVideos = [];
  for (const video of videos) {
    if (applyFilter(video, filter)) {
      filteredVideos.push(video);
    }
  }
  if (filteredVideos.length === 0) {
    return <NoVideos />;
  }
  return (
    <>
      <Heading heading={heading} count={filteredVideos.length} />
      <VideoList videos={filteredVideos} />
    </>
  );
}

このコードがコンパイラによってコンパイルされると次のような出力が得られます。

function VideoTab(t36) {
  const $ = useMemoCache(12);
  const { heading, videos, filter } = t36;
  let filteredVideos;

  // "useMemo" for filteredVideos:
  // check if videos or filter changed
  if ($[0] !== videos || $[1] !== filter) {
    // Inputs changed, recompute
    filteredVideos = [];

    for (const video of videos) {
      if (applyFilter(video, filter)) {
        filteredVideos.push(video);
      }
    }

    $[0] = videos;
    $[1] = filter;
    $[2] = filteredVideos;
  } else {
    // Inputs did not change, use cached value
    filteredVideos = $[2];
  }
  // ...
  let t2;

  // "useMemo" for t2:
  // check if filteredVideos changed
  if ($[7] !== filteredVideos) {
    // Inputs changed, recompute
    t2 = <VideoList videos={filteredVideos} />;
    $[7] = filteredVideos;
    $[8] = t2;
  } else {
    // Inputs did not change, use cached value
    t2 = $[8];
  }
  // ...
}
React Forget によりコンパイルされたコードのほぼ全体
function VideoTab(t36) {
  const $ = useMemoCache(12);
  const { heading, videos, filter } = t36;
  let filteredVideos;

  // "useMemo" for filteredVideos:
  // check if videos or filter changed
  if ($[0] !== videos || $[1] !== filter) {
    // Inputs changed, recompute
    filteredVideos = [];

    for (const video of videos) {
      if (applyFilter(video, filter)) {
        filteredVideos.push(video);
      }
    }

    $[0] = videos;
    $[1] = filter;
    $[2] = filteredVideos;
  } else {
    // Inputs did not change, use cached value
    filteredVideos = $[2];
  }

  if (filteredVideos.length === 0) {
    let t0;

    // "useMemo" for t0:
    // cache value with no dependencies
    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      // Inputs changed, recompute
      t0 = <NoVideos />;
      $[3] = t0;
    } else {
      // Inputs did not change, use cached value
      t0 = $[3];
    }

    return t0;
  }

  let t1;

  // "useMemo" for t1:
  // check if heading or filteredVideos.length changed
  if ($[4] !== heading || $[5] !== filteredVideos.length) {
    // Inputs changed, recompute
    t1 = <Heading heading={heading} count={filteredVideos.length} />;
    $[4] = heading;
    $[5] = filteredVideos.length;
    $[6] = t1;
  } else {
    // Inputs did not change, use cached value
    t1 = $[6];
  }

  let t2;

  // "useMemo" for t2:
  // check if filteredVideos changed
  if ($[7] !== filteredVideos) {
    // Inputs changed, recompute
    t2 = <VideoList videos={filteredVideos} />;
    $[7] = filteredVideos;
    $[8] = t2;
  } else {
    // Inputs did not change, use cached value
    t2 = $[8];
  }

  let t3;

  // "useMemo" for t3:
  // check if t1 or t2 changed
  if ($[9] !== t1 || $[10] !== t2) {
    // Inputs changed, recompute
    t3 = (
      <>
        {t1}
        {t2}
      </>
    );
    $[9] = t1;
    $[10] = t2;
    $[11] = t3;
  } else {
    // Inputs did not change, use cached value
    t3 = $[11];
  }

  // 動画内ではここまでしか確認できませんでした
  // おそらく t3 を return して終了だと思います
}

先ほどメモ化が必要だった filteredVideos に関するブロックは以下の部分です。

  let filteredVideos;

  // "useMemo" for filteredVideos:
  // check if videos or filter changed
  if ($[0] !== videos || $[1] !== filter) {
    // Inputs changed, recompute
    filteredVideos = [];

    for (const video of videos) {
      if (applyFilter(video, filter)) {
        filteredVideos.push(video);
      }
    }

    $[0] = videos;
    $[1] = filter;
    $[2] = filteredVideos;
  } else {
    // Inputs did not change, use cached value
    filteredVideos = $[2];
  }

まずは if 文によって video または filter が変更されたかどうかを確認します。

  if ($[0] !== videos || $[1] !== filter) {

変更されていればもとのロジックにあったコードがそのまま実行されます。
この部分は記憶ブロック( memorization block )と呼ぶそうです。
ソースマップも保持しているためコードの参照も可能なようです。

    // Inputs changed, recompute
    filteredVideos = [];

    for (const video of videos) {
      if (applyFilter(video, filter)) {
        filteredVideos.push(video);
      }
    }

次回のレンダリング時に比較できるよう依存している値の videosfilter 、最終的な filteredVideos を保存します。

    $[0] = videos;
    $[1] = filter;
    $[2] = filteredVideos;

VideoTabheading だけが変更されて再レンダリングされると、 else に入ってきます。
この場合は前回計算した値を返すだけです。

  } else {
    // Inputs did not change, use cached value
    filteredVideos = $[2];
  }

小難しいことをやっているように見えますが、依存している値を保持しておき、レンダリングのたびに値を比較し、異なれば計算し保存する、と、やっていることは useMemo() と全く同じです。
更に useMemo() と違って関数式が不要なためわずかに効率的ですらあります。

filteredVideos がコンパイラによってメモ化される様子は確認できました。
ではそれを利用する VideoList コンポーネントはどのように最適化されるでしょうか。
以下の部分で行われています。

  let t2;

  // "useMemo" for t2:
  // check if filteredVideos changed
  if ($[7] !== filteredVideos) {
    // Inputs changed, recompute
    t2 = <VideoList videos={filteredVideos} />;
    $[7] = filteredVideos;
    $[8] = t2;
  } else {
    // Inputs did not change, use cached value
    t2 = $[8];
  }

VideoList コンポーネントの jsx 要素自体をメモ化していることがわかります。
つまり従来の useMemo() で表すなら以下のようなことをやっています。

useMemo(() => <VideoList videos={filteredVideos} />, [filteredVideos])

filteredVideos が変わらなければ VideoList コンポーネントは再レンダリングされないというわけです。

コンパイラが出力するコード全体を見てみるとこれ以外にもいくつかの最適化を行っていることを確認できます。
React Forget の威力は十分伝わったのではないでしょうか。

パフォーマンスへの影響度

ここまでで React Forget コンパイラがコードをどのように最適化するのかを見てきました。
では実際にこの最適化はアプリケーションのパフォーマンスにどの程度の影響を与えるのでしょうか。
Quest Store というアプリケーションを利用した調査結果が示されます。

コンポーネントの再レンダリング数

アプリ左側にあるナビゲーションバーをクリックしたときの再レンダリングの様子を React Compiler を用いて記録し、コンパイラ適用前後で比較します。

コンパイラ適用前の再レンダリング

ちょっと見づらいかもしれませんが、四角いボックスはコンポーネントを表し、再レンダリングされたコンポーネントは緑色、されていないものは灰色となっています。
つまり緑色は少なく、灰色が多いほうが再レンダリングされるコンポーネントが少なくパフォーマンスが向上しているということです。
ではコンパイラを適用したものを見てみます。

コンパイラ適用後の再レンダリング

赤枠で囲われた部分は緑色から灰色に変わった部分です。
この変化が大きいのか小さいのかは判断できかねますが、少なくとも再レンダリングされたコンポーネントが減っていることは明らかです。

その他

再レンダリングだけでなく視覚的なメトリクスもいくつか計測されています。
それによるとタブの切替は 150% 、ページロードの時間は 4~12% 早くなったそうです。

おわりに

React Forget がどのようにコードを最適化するのか、またその威力を見てきました。
メモ化についてはエンジニア間でも意見が分かれるところであり、そこが解決されると開発体験も間違いなく向上するので非常に楽しみです。
ここまでお読みいただきありがとうございました。

Terass Tech Blog

Discussion