Open4

React18

Kazuki MatsudaKazuki Matsuda

https://github.com/reactwg/react-18/discussions/37

SSR とは

サーバーサイド・レンダリング(この記事では「SSR」と略します)は、サーバー上のReactコンポーネントからHTMLを生成し、そのHTMLをユーザーに送信することができます。SSRは、JavaScriptバンドルがロードされて実行される前に、ユーザーにページのコンテンツを見せることができます。

ReactのSSRは、常にいくつかのステップで行われる:

  • サーバー上で、アプリ全体のデータをフェッチする。
  • 次に、サーバー上でアプリ全体をHTMLにレンダリングし、レスポンスとして送信する。
  • 次にクライアントで、アプリ全体のJavaScriptコードをロードする。
  • 次にクライアントで、JavaScriptロジックをアプリ全体のサーバー生成HTMLに接続する(これが「ハイドレーション」だ)。

重要なのは、次のステップを開始する前に、アプリ全体の各ステップが一度に終了しなければならないという点だ。これは、アプリのある部分が他の部分より遅い場合、効率的ではない。

React 18では、<Suspense> 、アプリを小さな独立したユニットに分割することができます。このユニットは、互いに独立してこれらのステップを実行し、アプリの残りの部分をブロックしません。その結果、アプリのユーザーはコンテンツをより早く見ることができ、より早くインタラクションを開始することができます。アプリの最も遅い部分が、高速な部分の足を引っ張ることはありません。これらの改善は自動的に行われるため、特別な調整コードを書く必要はありません。

コンポーネントをレンダリングし、イベント・ハンドラを添付するこのプロセスは、「ハイドレーション」として知られている。乾いた」HTMLに、インタラクティブ性とイベントハンドラという「水」を与えるようなものだ。(少なくとも、私自身はそう説明している)。

SSRは一種の「手品」であることがわかるだろう。アプリを完全にインタラクティブに速くするわけではありません。そうではなく、アプリの非インタラクティブ・バージョンをより早く表示することで、ユーザーがJSのロードを待つ間に静的コンテンツを見ることができるようにするのです。しかし、このトリックは、ネットワーク接続の悪い人々にとって大きな違いをもたらし、全体的な知覚パフォーマンスを向上させます。また、インデックスされやすくなり、速度も向上するため、検索エンジンのランキングにも役立ちます。

SSR の問題点

HTML を返す前に全てのデータ取得が完了している必要がある

現在の SSR の問題のひとつは、コンポーネントが「データを待つ」ことができないことです。現在の API では、HTML にレンダリングするまでに、 コンポーネントに必要なすべてのデータをすでにサーバに 用意しておかなければなりません。つまり、クライアントにHTMLを送信する前に、サーバーですべてのデータを収集しなければならないのです。これは非常に非効率的だ。

全てのコンポーネントの JS が読み込まれるまでハイドレーションできない

JavaScriptコードがロードされたら、ReactにHTMLを「ハイドレート」してインタラクティブにするよう指示する。Reactは、コンポーネントをレンダリングしながら、サーバーが生成したHTMLを「ウォーク」し、そのHTMLにイベント・ハンドラをアタッチする。これが機能するためには、ブラウザ上でコンポーネントが生成するツリーが、サーバーが生成するツリーと一致していなければならない。そうしないと、Reactは "一致させる "ことができないのだ。この非常に残念な結果は、ハイドレーションを開始する前に、クライアント上ですべてのコンポーネントのJavaScriptをロードしなければならないということです。

全てのコンポーネントのハイドレーションが終わるまでインタラクティブにならない

ハイドレーション自体にも同様の問題がある。今日、Reactは1回のパスでツリーをハイドレートする。つまり、いったんハイドレーション(基本的にコンポーネント関数を呼び出すこと)を開始すると、Reactはツリー全体のハイドレーションが終わるまで停止しない。その結果、コンポーネントのいずれかと対話する前に、 すべてのコンポーネントがハイドレートされるのを待つ必要があります。

どう解決するのか?

これらの問題には共通点がある。これらの問題は、何かを早くやるか(しかし、そのために他のすべての作業が妨げられ、UXが損なわれる)、何かを遅くやるか(しかし、時間を無駄にしたためにUXが損なわれる)のどちらかを選ばざるを得ないのだ。

データをフェッチ(サーバー)→HTMLにレンダリング(サーバー)→コードをロード(クライアント)→ハイドレート(クライアント)という「ウォーターフォール」があるからだ。どちらのステージも、アプリの前のステージが終了するまで開始できない。これが非効率的な理由だ。私たちの解決策は、作業を分割して、アプリ全体ではなく画面の一部分に対してこれらの各ステージを実行できるようにすることだ。

Streaming HTML と Selective Hydration

リアクト18には、サスペンスによってアンロックされる2つの主要なSSR機能がある:

  • サーバー上でHTMLをストリーミング。これを利用するには、renderToString から新しいrenderToPipeableStream メソッドに切り替える必要があります。
  • クライアントのSelective Hydration。この機能を利用するには、クライアントで hydrateRoot に切り替え、アプリの一部を<Suspense> でラップする必要があります。
  • ストリーミングHTMLを使えば、好きなだけ早くHTMLの発信を開始することができ、追加コンテンツ用のHTMLを、それらを適切な場所に配置する タグとともにストリーミングすることができる。
  • Selective Hydrationにより、残りのHTMLとJavaScriptコードが完全にダウンロードされる前に、できるだけ早い段階でアプリの水分補給を開始することができます。また、ユーザーが操作している部分の水分補給を優先させることで、即座に水分補給が完了したかのような錯覚をもたらします。

これらの機能は、ReactのSSRに関する3つの長年の問題を解決する:

  • HTMLを送信する前に、すべてのデータがサーバーにロードされるのを待つ必要はもうない。その代わりに、アプリのシェルを表示するのに十分なデータが揃ったらすぐにHTMLの送信を開始し、準備ができ次第残りのHTMLをストリーミングします。
  • ハイドレートを開始するために、すべてのJavaScriptがロードされるのを待つ必要はもうない。その代わりに、サーバーレンダリングと一緒にコード分割を使うことができる。サーバーのHTMLは保存され、関連するコードがロードされたときにReactがハイドレートする。
  • ページとのインタラクションを開始するために、すべてのコンポーネントがハイドレートするのを待つ必要はもうありません。その代わりに、Selective Hydrationを利用して、ユーザーがインタラクトしているコンポーネントに優先順位をつけ、早期にハイドレーションさせることができます。
Kazuki MatsudaKazuki Matsuda

https://github.com/reactwg/react-18/discussions/22

React のサーバー API

  • renderToString:機能し続ける(サスペンスのサポートは限定的)。
  • renderToNodeStream:非推奨(サスペンスをフルサポート、ただしストリーミングはなし)。
  • renderToPipeableStream:新着・おすすめ(サスペンス完全対応・ストリーミングあり)。
Kazuki MatsudaKazuki Matsuda

https://github.com/reactwg/react-18/discussions/21

バッチ処理とは?

バッチ処理とは、Reactがパフォーマンスを向上させるために、複数の状態更新を1回の再レンダーにまとめることである。

例えば、同じクリック・イベントの中に2つの状態更新がある場合、Reactは常にこれらを1つの再レンダーにまとめている。以下のコードを実行すると、状態を2回設定したにもかかわらず、クリックするたびにReactは1回のレンダリングしか実行していないことがわかる

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

しかし、Reactは更新をバッチ処理するタイミングについて一貫していなかった。例えば、上記のhandleClick 、データをフェッチしてから状態を更新する必要がある場合、Reactは更新をバッチ処理せず、2つの独立した更新を実行する。

というのも、Reactはブラウザのイベント(クリックなど)中にのみバッチ更新を行っていたが、ここでは(フェッチ・コールバックで)イベントがすでに処理された後に状態を更新しているからだ:

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

React 18までは、Reactイベントハンドラ内での更新のみがバッチ処理されていた。プロミス、setTimeout、ネイティブイベントハンドラ、またはその他のイベント内の更新は、デフォルトではReactでバッチ処理されなかった。

自動バッチ処理とは?

React 18から createRootを追加することで、すべてのアップデートが自動的にバッチ処理されるようになります。

これは、タイムアウト、プロミス、ネイティブ・イベント・ハンドラ、またはその他のイベント内の更新が、Reactイベント内の更新と同じようにバッチ処理されることを意味します。これにより、レンダリング作業が減り、アプリケーションのパフォーマンスが向上することを期待しています:

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 18 and later DOES batch these:
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

バッチしたくない場合は?

通常、バッチングは安全だが、コードによっては、状態変化直後にDOMから何かを読み取ることに依存する場合がある。そのような場合は、ReactDOM.flushSync() :

import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}

コメント

しかし、ここでは「特定のレンダリングからのスナップショットを見る」(これは同期的に起こっている)と書かれています。つまり、理論的にはスナップショットは更新され、それを見ることができるはずですが、そうなっていません。

すみません、実際よりも複雑に聞こえたかもしれません。私が言いたかったのは、特定のレンダリング中にuseState 。イベントハンドラはそのレンダリングから値を「キャプチャ」するので、何度ステートを設定しても、そのレンダリングのイベントハンドラは永遠にそのレンダリングからの値を「見る」ことになります。それがスナップショットの意味です。その部分を言い直した。