辛口情報 | Ctrip Business Travel のフロントエンド React Streaming の探索の道

著者について

Ctrip のシニア フロントエンド開発エンジニアである Group 19 Qingfeng は、出張向けのフロントエンド公共インフラストラクチャ プラットフォームの構築を担当しており、NodeJ に重点を置き、パフォーマンス分野を研究しています。

ZZR はシートリップ ビジネス トラベルのシニア フロントエンド開発エンジニアで、ビジネス トラベル パブリック プラットフォームの基本プラットフォーム構築を担当し、高効率かつ高パフォーマンスの開発に取り組んでいます。

I.はじめに

React 18.2.0 のリリースからあっという間に 1 年以上が経過し、徐々に React18 の新機能を開発/運用に組み込む開発者が増えてきました もちろん作者はここにいます チームはいません例外。

今日の記事では、React 18 のストリーミングについて簡単に説明します。

二、Streaming

いわゆるストリーミング (ストリーミング レンダリング) の概念は、単純に、HTML スクリプト ファイル全体を細かく分割してクライアントに返し、クライアントが各コンテンツをバッチでレンダリングすることを意味します。

c2d5094f46c6f8111b2c7ed54250a764.png

従来のサーバー側で HTML コンテンツ全体を一度にレンダリングして返す方法と比較して、この方法は TTFB と FP の時間を視覚的に大幅に短縮し、より優れたユーザー エクスペリエンスを提供します。

HTTP/1.1 で使用できるチャンク転送エンコーディング メカニズムは、このプロセスを実装します。

HTTP/2.0 では、送信内容がデータ フレームに基づいているため、デフォルトの内容は常に「チャンク化」されます。

次に、NextJs と Remix でこの機能を初めて体験します。

同時に、記事の 3 番目の部分では、フレームワークを使用せずにこのプロセスを実装して、よりよく理解できるようにします。

3. NextJs

ここでは、npx [email protected] を使用して初期プロジェクトを作成し、簡単な変更を加えました。

新しいバージョンでは、NextJs はサーバー側コンポーネント (RSF) に基づいて構築された新しいアプリ ディレクトリを導入し、このディレクトリ内のすべてのコンポーネントはデフォルトでReact サーバー コンポーネントになります。

簡単に言うと、React18 での RSF の登場により、サーバー側でコンポーネント データを取得し、サーバー側でコンポーネントをレンダリングできるようになりました。

上記のコードでは、app/page.tsx 内の元のテンプレート コードを製品表示用のビジネス コードに変更しました。

// 获取商品评论信息(延迟3s)
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Home() {
  // 获取评论数据
  const comments = await getComments();
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <p>评论</p>
          <input />
          <div>
            {comments.map((comment) => {
              return <p key={comment}>{comment}</p>;
            })}
          </div>
        </div>
      </div>
    </main>
  );
}

プロジェクトを開始してページを開くと、3 秒の遅延後にページにすべてのコンテンツが表示されます。

ea192bff78509106291ccf984791154f.gif

製品レビューなどの重要ではないデータの場合、ページを開くとレビュー データを取得する必要があるため、ページ上に 3 秒間の白い画面が表示され、間違いなく悪いエクスペリエンスになります。

NextJs では、組み込みのサーバー コンポーネントとストリーミング機能を簡単に使用して、いくつかの変更を加えるだけでこの問題を完全に解決できます。

// components/Comment.tsx
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Comments() {
  const comments = await getComments();
  return (
    <div>
      <p>评论</p>
      <input />
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}
// app/page.tsx
import Comment from '@/components/Comments';
import { Suspense } from 'react';
export default async function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          {/* Suspense 包裹携带数据请求的 Comment Server 组件 */}
          <Suspense fallback={<div>Loading...</div>}>
            <Comment />
          </Suspense>
        </div>
      </div>
    </main>
  );
}

データリクエストを伝送するサーバー側コンポーネントにコメントコンテンツを抽出し、それを親コンポーネントの <Suspense /> でラップすることで、RSF とストリーミングの機能を使用して、ページレンダリングをブロックするコメントデータの取得の問題を解決できます。

29d7bca0564dbbb3813f9a21a19c7cc1.gif

ここをクリックすると、コード ウェアハウスのアドレスが表示されます。

Web ページのアドレスを開くと、Loading... を使用するコメント セクションを除いて、ページ全体がすぐにレンダリングされます。

3 秒後、コメント コンポーネントのコンテンツがページ上の読み込み中のコンテンツを置き換えてユーザーに表示されます。これは非常にクールだと思いませんか?

次に、インタラクティブなコンテンツをコードに追加して、ユーザーが <input /> にコンテンツを入力して送信できるようにします。

// components/Comment.tsx
import { useRef } from 'react';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Comments() {
  const comments = await getComments();
  const inputRef = useRef<HTMLInputElement>(null);
  const onSubmit = () => {
    alert(`您提交的评论内容:${inputRef.current?.value}`);
  };
  return (
    <div>
      <p>评论</p>
      <input ref={inputRef} />
      <button onClick={onSubmit}>提交评论</button>
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}

ここでページを更新すると、次のようなエラーが表示されます。

e759dfa87c2669d9f2cec25627c86305.png

これは、React サーバー側コンポーネントがサーバー上で完全にレンダリングされ、フック API、ブラウザ API、イベント バインディングなどを使用できないためです。

また、Next では、この問題を解決するためのソリューションがネストされたコンポーネントの形式で提供されます。

各コンポーネントに独自の役割を実行させ、サーバー コンポーネントの Suspense と連携してデータを動的に取得し、対話型ロジックでデータをクライアント コンポーネントに渡し、クライアント コンポーネントを RSF のサブコンポーネントとしてラップする必要があります。

// components/Comment.tsx
import EditableComments from './EditableComments';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function Comments() {
  const comments = await getComments();
  return (
    <div>
      <p>评论</p>
      {/* RFC 中包裹客户端组件 */}
      <EditableComments comments={comments} />
      {comments.map((comment) => {
        return <p key={comment}>{comment}</p>;
      })}
    </div>
  );
}
// components/EditableComments.tsx
'use client';
import { useRef } from 'react';
export default function EditableComments(props: { comments: string[] }) {
  const inputRef = useRef<HTMLInputElement>(null);
  const onSubmit = () => {
    // 限制评论内容
    if (props.comments.length < 10) {
      alert(`您提交的评论内容为:${inputRef.current?.value}`);
    }
  };
  return (
    <>
      <input ref={inputRef} />
      <button onClick={onSubmit}>提交评论</button>
    </>
  );
}

完全なコードはここにあります。

上記のコードからわかるように、クライアント対話ロジック部分を EditableComments.tsx コンポーネントに抽出しました。

オリジナルの Comment.tsx サーバー コンポーネントでデータを取得すると、データを取得したときに、そのデータがクライアント コンポーネントに渡されて表示されます。

9e5f983bf531efb9834fe58551e1428c.gif

NextJs では、デフォルトのアプリ ディレクトリ内のコンポーネントはすべてサーバー側コンポーネントです。

クライアント側ロジックを追加する必要がある場合は、ファイルの最上位で「use client」を使用して、これがクライアント側コンポーネントであることを明示的に宣言し、対話型ロジックを追加してブラウザー API を使用する必要があります。

同時に、サーバー側コンポーネントとクライアント側コンポーネントは、ネストされた関係を通じてのみ相互に存在できることを忘れないでください (クライアント側コンポーネントがサーバー側データを必要とする場合、そのデータは、外側のサーバー側コンポーネント)。

3c27cc7d9cff85a7e1e380ccebb38e9e.png

上の図は、NextJs のクライアント側コンポーネントとサーバー側コンポーネントのいくつかの異なる使用例をまとめたものです。

4.リミックス

NextJs のストリーミング機能でサーバー側コンポーネントを使用する方法を理解した後、このプロセスがRemixでどのように処理されるかを見てみましょう。

Remix では、各ルーティング ページがローダーという名前の関数をエクスポートして、レンダリング用のデータを提供できることが規定されています。

例えば:

import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export const loader: LoaderFunction = () => {
  return json({
    name: '19Qingfeng',
  });
};
export default function Index() {
  const { name } = useLoaderData();
  return (
    <div style={
    
    { fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <h3>Hello {name}!</h3>
    </div>
  );
}

上記は、npx create-remix@latest を使用して作成したテンプレート プロジェクトです。ソース コードはここで見ることができます

  • まず、const ローダーのエクスポートとは、サーバー側でページ データを取得するために使用されるローダーという名前のメソッドをページがエクスポートすることを意味します。

このメソッドはサーバー上でのみ実行されることに注意してください。ページが最初に開かれると、HTML ドキュメントにデータが提供されます。SPA モードに切り替えるときも、Remix はブラウザからこの関数を呼び出します。

このメソッドはサーバー上でのみ実行され、ページがコンポーネントをロードする前に実行されます。

  • 次に、エクスポートのデフォルト関数 Index の使用法は NextJs と同じです。

Remix は、指定されたディレクトリ内の定義ファイルのデフォルトのエクスポートがそのパス内の HTML ページにレンダリングされることを指定します。

同時に、Remix が提供する useLoaderData フックをどこでも使用して、ページ上で定義されたloaderFunction の戻り値を取得できます。

ccdb93048458bb73aea41b8dd2e69a69.gif

このように、NextJsのサーバーサイドコンポーネントを通じてデータを取得しますが、RemixのLoaderFunctionに配置してデータを取得することもできます。

上記の NextJS コードを移行し、コメントを取得するロジックを Remix プロジェクトに移行しましょう。

import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export const loader: LoaderFunction = async () => {
  const comments = await getComments();
  return json({
    comments,
  });
};
export default function Index() {
  const { comments } = useLoaderData<{ comments: string[] }>();
  return (
    <div style={
    
    { fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <div>
            <p>评论</p>
            {comments.map((comment) => {
              return <p key={comment}>{comment}</p>;
            })}
          </div>
        </div>
      </div>
    </div>
  );
}

コードはここからダウンロードできます

49a21d38d3f5589581b2b639c998e41d.gif

URL を入力すると、ページは 3 秒以内にすべてのデータをロードしてレンダリングしますが、それでも 3 秒間ブロックされているようです。

これは、loaderFunction でロードをブロックしているためです。 

export const loader: LoaderFunction = async () => {
  const comments = await getComments();
  return json({
    comments,
  });
};

ローダー メソッドは getComments を同期的に呼び出しているようで、取得したコメントの内容を返す前に getComments によって返される Promise<resolved> を待ちます。

ページのレンダリングは、定義されたloaderFunctionによって返されたコンテンツに依存します。当然のことながら、ページを開いた後は、サーバー側のデータ取得のブロック特性により、ページの読み込みがブロックされます。

では、NextJs のように Remix でコメントなどの重要ではないデータを断片的に「返す」にはどうすればよいでしょうか?

Remix は、このシナリオを処理するためのより便利な API も提供します。

4.1 延期 

Remix は、このプロセスを実装するためにサーバー側で defer と呼ばれるメソッドを提供します。

その定義にあるように、Remix でストリーミング レンダリング (デフォルトの動作) を有効にすると、ローダーで defer メソッドを使用して戻り値をラップできます。その動作は json() とまったく同じです。唯一の違いは、このメソッド Promises です。次のような UI コンポーネントに転送できます。

export const loader: LoaderFunction = async () => {
  const comments = getComments();
  // 使用 defer 传输 getComments 返回的 Promise 
  return defer({
    comments,
  });
};
export default function Index() {
  // 使用 loaderFunction 获取中传递的 Promise 
  const { comments } = useLoaderData<{ comments: Promise<string[]> }>();


  // ...
}

defer メソッドの実装については、興味のある友人は @remix-server-runtime/responses.ts を確認してください。

4.2 <待つ>

同時に、Remix は、loaderFunction から返された Promise を解析する役割を担う <Await /> コンポーネントを提供します。

これは React Error Boundaries の単純なラッパーに似ています。このコンポーネントは <Suspense /> と連携して動作し、受信した Promise が完了するまで待機します。受信した Promise が完了するまで常に <Suspense /> を使用してその場所を保持します。実際の内容が表示されます。

これら 2 つの API を使用していくつかの変更を加えてみましょう。

import type { LoaderFunction } from '@remix-run/node';
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
function getComments(): Promise<string[]> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export const loader: LoaderFunction = async () => {
  const comments = getComments();
  return defer({
    comments,
  });
};
export default function Index() {
  const { comments } = useLoaderData<{ comments: string[] }>();
  return (
    <div style={
    
    { fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <div>
          <div>
            <p>评论</p>
            <Suspense fallback={<div>Loading...</div>}>
              <Await<string[]> resolve={comments}>
                {(comments) => {
                  return comments.map((comment) => {
                    return <p key={comment}>{comment}</p>;
                  });
                }}
              </Await>
            </Suspense>
          </div>
        </div>
      </div>
    </div>
  );
}

このコードはここからダウンロードできます

2afebadb6552f90098485cb2c420c320.gif

NextJs で表示されるエフェクトと全く同じですよね? Streaming 機能を使用して Remix でデータを取得する方法です。

NextJs と Remix に関しては、どちらのフレームワークもすぐにストリーミング データを処理できるようになっています。

NextJs はストリーミング レンダリングを実装するためのサーバー コンポーネント メカニズムに基づいているため、コード構成の制限はわずかに制限されているようです。

Remix のこのプロセスの内部実装は RSC とは何の関係もないため、そのコーディング スタイルは NextJs よりも従来のフロントエンド コードの記述習慣に近いものになっています。

個人的には、Remix の精神的負担のないコード編成スタイルが好みです。

5. マニュアル

Next と、Remix でのデータ リクエストに Streaming を使用する方法について説明した後、このプロセスを自分で実装してみましょう。

また、Next、Remix、その他のフレームワークに関係なく、各フレームワークの実装の考え方が異なることは上で述べましたが、Remix がローダー関数を介してサーバーからクライアントに Promise を渡す方法については、後ほど別途説明します。 Reactの提案からいくつかの方法で。

6. テンプレートの構築

労働者が仕事をうまくやりたいなら、まず道具を研ぐ必要があります。まず、トラブルを避けるために簡単な SSR プロジェクトを作成します。この基本コードはここからダウンロードできます。

プロジェクトのディレクトリは以下のとおりです。

.
├── README.md                   描述文件,如何安装和启动
├── build                       客户端产物存放文件
│   └── index.js
├── package.json
├── pnpm-lock.yaml
├── public                      静态资源存放目录
│   └── index.css
├── rollup.config.mjs rollup    配置文件
├── server 
│   └── render.js               服务端渲染方法
├── server.entry.js             服务端入口文件
└── src
    ├── App.jsx                 页面入口组件
    ├── html.jsx                页面 HTML 组件,用于 Server Side 生生成 HTML
    └── index.jsx               客户端入口文件

プロジェクト全体は非常にシンプルで、package.jsonには次の 2 つのスクリプトがあります。

{
  ...
  "scripts": {
      "dev": "npm-run-all --parallel \"dev:*\"",
      "dev:server": "cross-env NODE_ENV=development babel-node server.entry.js",
      "dev:client": "cross-env NODE_ENV=development rollup -c -w"
  }
}

"dev:server" は babel-node を使用してサーバー側スクリプトを実行します。初めてリクエストが来たときに、server.entry.js が実行されます。 

const express = require('express');
const render = require('./server/render').default;
const app = express();
app.use(express.static('build'));
app.use(express.static('public'));
app.get('/', (req, res) => {
  render(res);
});
app.listen(3000, () => {
  console.log(`Server on Port: 3000`);
});

server.entry.js は、express を通じて NodeServer を開始します。localhost:3000 からのメソッドをリッスンすると、server/render にエクスポートされたメソッドを呼び出します。

import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToString } from 'react-dom/server';
function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function render(res) {
  const comments = await getComments();
  res.send(
    renderToString(
      <HTML comments={comments}>
        <App comments={comments} />
      </HTML>
    )
  );
}

server.js でエクスポートされた render メソッドで行われる処理も非常に簡単です。

a. サーバー上のコメント データをリクエストします。このメソッドも 3 秒後に戻ります。

b. データを取得した後、`renderToString` メソッドを呼び出し、それを `response` に渡して、サーバー側のレンダリングを実現します。

次に、src/App.jsx と src/HTML.jsx がいくつかありますが、非常に単純なので、コードを直接リストします。

// src/html.jsx
import React from 'react';
export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
    </body>
  </html>
}
// src/App.jsx
import React, { useRef } from "react";
export default function Index({comments}) {
  const inputRef = useRef(null)
  const onSubmit = () => {
    if(inputRef.current) {
      alert(`添加评论内容:${inputRef.current?.value}`)
    }
  }
  return (
    <div style={
    
    { fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <input ref={inputRef} />
        <button onClick={onSubmit}>添加评论</button>
        <div>
          <div>
            <p>评论</p>
            {
              Array.isArray(comments) && comments.map(comment => {
                return  <p key={comment}>{comment}</p>;
              })
            }
          </div>
        </div>
      </div>
    </div>
  );
}

クライアントのエントリ ファイルとして src/index.js にあることに注意してください。つまり、最終的に src/index.js のコンテンツをブラウザが実行できるコードにパッケージ化し、それを返す必要があります。水分補給のプロセス:

import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App />)
})

前述の「dev:client」コマンドも、このファイルをエントリ ファイルとして build/index.js にビルドします。

次に、npm run dev を実行してページを開き、レンダリングされたページを確認します。

66886a63a06d62d33bd6004b0a6747d9.gif

注意深い人は、ページ上のコメントをクリックしてもインタラクティブな効果がないことに気づくでしょう。これは、サーバー上の HTML リターンに js スクリプトが埋め込まれていないためです。

この時点で、基本的なプロジェクト構造は完成しました。続行しましょう。

7. クライアントデータのやり取り

前のステップで、基本的なプロジェクト構造を作成しましたが、プロジェクトには JavaScript スクリプトを追加していません。

次に、src/html.jsx に移動し、構築されたクライアント JS スクリプトを HTML コンポーネントに追加します。

import React from 'react';
export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
       {/* 添加 JS 脚本注入 */}
      <script src="/index.js"></script>
    </body>
  </html>
}

次に、npm run dev を再実行します。

1dec7277e47f8f2b856dfcc28510dfcf.gif

この時点で、ページ ボタンをクリックすると、クライアント ロジックが通常どおり実行されます。

しかし、ブラウザ コンソールで多数のエラーが発生したことに加えて、サーバー上で取得されたコメント データがレンダリングのためにクライアントに同期されていないことがわかりました。

クライアント側レンダリングを同期しない理由は非常に単純です。ブラウザがサーバーから取得したコメント データを取得できないからです

import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
  // 客户端发生 hydrate 的 <App /> 组件并没有任何 comments 传入
  hydrateRoot(document.getElementById('root'),<App />)
})

簡単に言うと、サーバー上で renderToString を呼び出し、コメント インターフェイスが返されるのを待ちます。レンダリングされた HTML テンプレートにはコメントの HTML コンテンツが含まれており、サーバーはこのデータをクライアントに返します。

その後、クライアントは返された HTML をロードします。いわゆるハイドレート プロセスを動的に実行する必要があるため、サーバーから返されるテンプレートにはイベント インタラクションと補足ステータスが追加されます。

このとき、クライアントはここでsrc/index.jsの HydrodeRoot のロジックを実行し、ルートコンポーネントを呼び出して VDom を取得し、サーバーから送信されたテンプレートと比較します (タグが同じ場合はタグを再利用します)イベント インタラクションを追加します。それらが異なる場合、Dom はクライアント上で再レンダリングされます)。

したがって、上記のプロセスを注意深く観察すると、実際にはページの読み込みプロセス中にちらつきが発生します。

1 つのレンダリングでは、サーバーがコメント データを含む HTML テンプレートを配信し、もう 1 つはクライアントがハイドレートしてクライアントにフォールバックして、コメント データなしでページをレンダリングします。

4945351b5851126c40d5db9bb27d1a24.png

左側はサーバーから送信されたレンダリング、右側はクライアントが JS を実行して再レンダリングした後のページです。

当然のことながら、ページ上で報告されるエラーは、クライアント hydeRoot の実行時に両端の HTML 構造が一致しないことによって発生するエラーです。 

では、この問題をどうやって解決すればいいのでしょうか?まず、この問題の本質は、サーバーがテンプレートをレンダリングするときに取得したコメントデータを、クライアントのブラウザーの JS スクリプトにどのように渡すかです。

最も単純かつ直接的な方法で実装してみましょう。サーバーはデータを取得した後、ウィンドウを通じて取得したコンテンツを送信された HTML に挿入します。これにより、クライアント JS の実行時にデータのこの部分が動的に取得できるようになります。

このとき、クライアント JS は通常、実行時にレンダリング用のデータのこの部分を取得できます。

上記のserver/render.jsでは、取得したコメント情報が<Html comments={comments} />を通じてサーバー側のHTMLコンポーネントに渡されています。

その後、src/html.jsxと入力して配信されたHTMLコンテンツを修正し、クライアントJSが実行される前にwindow.__diy_ssr_contextをscriptタグの形式でウィンドウに追加します。

import React from 'react';
export default ({children,comments}) => {
  return <html>
    <head>
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script dangerouslySetInnerHTML={
    
    {
        __html: `window.__diy_ssr_context=${JSON.stringify(comments)}`
      }}></script>
      <script src="/index.js">
</script>
    </body>
  </html>
}

その後、クライアントのエントリ ファイルに戻り、クライアント ロジックの実行時に window.__diy_ssr_context を取得してサーバーから要求されたデータを取得し、それを渡します。

import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App comments={window.__diy_ssr_context} />)
})

f7dde34c821ecac0813c46440134acde.gif

この時点でコンソールのエラーメッセージは全て消え、サーバーから取得したコメントデータがページ上に正常に表示されるようになりました。

窓から直接注射するというのは非常に原始的だと思いませんか?

ただし、現段階では、Next であろうと Remix であろうと、どのフレームワークもこの方法でサーバー データとクライアント データを同期します。

8、renderToPipeableStream 

React18 は renderToPipeableStream API を提供します。

これは、受信した ReactTree を HTML に変換し、NodeStream 経由でクライアントに返す以前の renderToString メソッドを置き換えます。この API は、ストリーミング レンダリング (ストリーミング) の実装の中核です。

import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';
function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function render(res) {
  const comments = await getComments();


  // renderToPipeableStream replace renderToString
  const { pipe } = renderToPipeableStream(
    <HTML comments={comments}>
      <App comments={comments} />
    </HTML>,
    {
      onShellReady() {
        pipe(res);
      },
    }
  );
}

0a2f161736544ec3676ec0d25de43249.gif

この効果を実現するには、server/render.js で renderToString を renderToPipeableStream に置き換えるだけです。

ただし、HTML は実際には Stream を介してセグメントに送信されます。しかし、コメント インターフェイスのせいで、このページでは依然として 3 秒の白いスクリーンタイムが発生します。

次に、サーバーが要求した Promise とストリーミングを組み合わせてストリーミング レンダリングを行う方法を解決してみます。

九、フックを使用する

React には、将来のバージョンでフックを使用するという提案があります: R FC: Promise と async/await のファースト クラス サポート

React は特別な用途のフックを提供します。これを React-Query と同様のソリューションとして使用することを考えることができます。

ほとんどの場合、データリクエストを取得するために React で次のようなコードを作成します。

import React, { useEffect, useState } from 'react';
function getSomeData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 3000);
  });
}
function Demo() {
  const [data, setData] = useState();
  useEffect(() => {
    getSomeData().then((data) => setData(data));
  });
  return (
    <div>
      <h3>Title</h3>
      {data && <div>{data}</div>}
    </div>
  );
}

リモートインターフェースから返された Promise ステータスが完了した後にページデータを更新したい場合、ほとんどの場合、クライアント上の then メソッドで useEffect を使用してデータを更新します。

この場合、通常、コード内でさまざまな状態の Promise を処理して、テンプレート内でさまざまなレンダリングを実行する必要があります。

次期 React バージョンでは、React チームはフックを使用する、より便利な処理方法を提供します。

use を使用すると、完了した Promise の値を読み取ることができ、ロード時のステータスとエラー処理を最も近い Suspense に委任します。

このアーキテクチャの利点は明らかです。コンポーネントを、すべてのコンポーネントがデータをロードしたときにのみレンダリングできるコンテキストにグループ化できるようになります。

import React, { Suspense, use } from 'react';
function getSomeData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('hello demo');
    }, 3000);
  });
}
export default function Demo() {
  // 使用 use hook 传递需要等待的 Promise,并且同步的方式直接获取数据
  const data = use(getSomeData());
  return (
    <div>
      <h3>Title</h3>
      <>{data}</>
    </div>
  );
}
export function DemoWrapper() {
  return  <Suspense fallback={<div>Loading Demo</div>}>
    {/* 调用 Suspense 直接包裹 Demo 组件  */}
    <Demo />
  </Suspense>
}

上記のコードでは、Promise ステータスを待機する必要がある箇所をフックを使用して簡単に処理しています。

<Demo /> コンポーネント内のデータは、受信した getSomeData() によって返された Promise ステータスに基づいて、最も外側の <Suspense /> のステータスを決定します。

Promise がまだ保留状態にある場合、フォールバックはプレースホルダーとしてレンダリングされ、コンポーネント内の Promise が満たされると、Demo コンポーネントが自然にレンダリングされます。

鉄が熱いうちに打つために、 use を使って先ほどの例を変形してみましょう。

// server/render.ts
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';
function getComments() {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve(['This is Great.', 'Worthy of recommendation!']);
    }, 3000)
  );
}
export default async function render(res) {
  const comments = getComments();
  // server 端
  const stream = renderToPipeableStream(
    <HTML comments={comments}>
      <App comments={comments} />
    </HTML>,
    {
      onShellReady() {
        stream.pipe(res);
      },
    }
  );
}

まず、サーバー側のロジックを少し変更しました。もともと await getComments() を必要としていた非同期ブロック ロジックは await を停止し、返された Promise を <HTML /> と <App /> に直接渡しました。

import React from 'react';
export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      {/* <script src="/index.js" /> */}
    </body>
  </html>
}

次に、サーバー側で getCommones のみを処理するため、最初に <Html /> コンポーネントに挿入されたクライアント スクリプトをコメント アウトします。

// src/App.tsx
import React, { useRef, use, Suspense } from "react";
function Comments({ comments }) {
  const commentsResult = use(comments)
  return Array.isArray(commentsResult) && commentsResult.map(comment => {
      return  <p key={comment}>{comment}</p>;
    })
}
export default function Index({comments}) {
  const inputRef = useRef(null)
  const onSubmit = () => {
    if(inputRef.current) {
      alert(`添加评论内容:${inputRef.current?.value}`)
    }
  }
  return (
    <div style={
    
    { fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
      <div>
        <div>商品</div>
        <p>价格</p>
        <input ref={inputRef} />
        <button onClick={onSubmit}>添加评论</button>
        <div>
          <div>
            <p>评论</p>
            <Suspense fallback={<div>Loading</div>}>
              <Comments comments={comments} />
          </Suspense>
          </div>
        </div>
      </div>
    </div>
  );
}

最後に、src/App.jsx にわずかな変更を加えます。

元のコメント コンテンツを別のコンポーネントに抽出し、コメント コンポーネント内で use を使用して、受信した getComments() によって返される Promise オブジェクトをラップします。

外側の <Index /> コンポーネントで Suspense を使用すると、 use を使用して内側の <Comments /> コンポーネントがラップされます。

a014b378bf952c9cfe7d8d4cacf1a847.gif

ここでページを更新すると、データを取得するときにコメントの内容によってページのレンダリングがブロックされなくなります。3 秒後、getCommonets() によって返される Promise ステータスが変化すると、ページには製品レビュー コンテンツが通常どおり表示されます。

今後の React 18.3 では、より便利なクライアント側の Promise 処理を作成するフック機能が提供されます。

では、use を使用してクライアントと対話するにはどうすればよいでしょうか?

上で述べたように、サーバーでレンダリングされたページでサーバーから取得されたデータは、現在、クライアントに提供されるときにグローバル変数の形式でのみ利用可能です。

今回はサーバー側のブラウザにPromiseを渡してその状態を記録する必要がありますが、サーバー側でPromiseをシリアライズしてクライアントに渡すことは当然不可能です。

// src/html.jsx
import React from 'react';
export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script src="/index.js" />
    </body>
  </html>
}

この時点で、サーバーサイド HTML の client/index.js を手放します。

ページを再度更新します。3 秒後に、ページに多数のエラーが表示されます。

8956898fab6df1fb301c527399cf8622.png

エラーの理由は次のように推測できます。

サーバー側でレンダリングする場合、props.comments は <Comments /> に渡され、正しいテンプレートがレンダリングされて返されます。

クライアントのハイドレート ロジックが再度実行されると、クライアントは再び <Comments /> を呼び出すときにコンテンツを渡さないため、当然エラーが発生します。

したがって、重要な質問は、サーバー上のステートフルな Promise をクライアントにどのように渡すかということです。

現在の Promise をサーバーからクライアントにシリアル化するという解決策は明らかに機能しません。次に、クライアント側でいわゆる Promise を作成する必要があります。

// src/index.tsx
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
// 目前看来永远不会被 resolve 的 Promise
const clientPromise = new Promise((resolve) => {
  window.__setComments_data = (comments) => resolve(comments)
})
startTransition(() => {
  hydrateRoot(document.getElementById('root'),<App comments={clientPromise} />)
})

クライアント スクリプトが実行される前に、ウィンドウ上で clientPromise と __setComments_data メソッドを構築します。

clientPromise は、__setComments_data メソッドが呼び出されたときにのみ完全に設定されます。

その後、クライアントによって構築された clientPromise を、クライアント側でレンダリングを実行する必要がある <App /> コンポーネントに渡します。

このステップでは、**<App comments={clientPromise} /> のコメント プロパティが実際の Promise を受け取ることを確認できます。

その後は、サーバーの commentPromise が完了したときに window.__setComments_data を呼び出してクライアントの commentPromise を完了するようにクライアントに通知するだけです。

// src/html.jsx
import React, { Suspense , use} from 'react';
function CommentsScript({ comments: commentsPromise }) {
  const comments = use(commentsPromise)
  return <script dangerouslySetInnerHTML={
    
    {
    __html: `window.__setComments_data(${JSON.stringify(comments)})`
  }}></script>
}
export default ({children,comments}) => {
  return <html>
    <head>
      <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
      <link ref="stylesheet" href="/index.css"></link>
    </head>
    <body>
      <div id='root'>{children}</div>
      <script src="/index.js" />
      <Suspense>
        <CommentsScript comments={comments}></CommentsScript>
      </Suspense>
    </body>
  </html>
}

<Html /> はサーバー側でレンダリングされるため、<Html /> を少し変更しました。

a. 純粋にサーバー側でレンダリングされた <Html /> コンポーネントは、コメント リクエストによって返された Promise を受け取ります。

b. <Html /> では、いわゆる <CommentsScript /> を追加で定義します。

use フックと Suspense を使用すると、サーバー上で要求されたコメント インターフェイスが返されると、スクリプトに置き換えられます。

サーバー上のコメントのステータスがいっぱいの場合: 

<Suspense>
        <CommentsScript comments={comments}></CommentsScript>
</Suspense>

<CommentsScript /> がレンダリングされ、それによって window.__setComments_data メソッドが実行され、Promise が完了したことをクライアントに通知し、対応するコメント データをサーバーから取得します。

bc7e6169b34c9d070d693566d0de5720.gif

この時点で、サーバー側とクライアント側のロジックの両方がニーズを満たし、カスタム ストリーミング データ レンダリングを実装できます。

もちろん、このメカニズムを実装する唯一の方法はありません。

たとえば、React 18.2 に use フックが存在しない場合でも、非同期データ ストリーミングを Remix で実装できることを上で説明しました。興味のある学生は今後の記事を参照して、Remix でストリーミング データがどのように処理されるかについて詳しく説明します。道。

10. 実装メカニズム

ストリーミングを使用して HTML の「ストリーム レンダリング」を実装する方法に興味があるかもしれません。この部分について少し説明しましょう。

いわゆる「ストリーミング レンダリング」 ストリーミングは、HTML スクリプト コンテンツのセグメント化された送信をネットワーク レベルで実現するだけであり、HTML コンテンツを動的に変更する魔法はありません。

通常、ページの HTML を変更する最も直接的な方法は、JavaScript を使用して DOM を動的に操作することです。当然のことながら、非常に高度に見える「ストリーミング レンダリング」も、ページのプログレッシブ読み込みを実現するための JavaScript スクリプトの助けと切り離すことができません。

先ほどの DIY デモを例として見てみましょう。

curl --no-buffer localhost:3000 を実行すると、HTML コンテンツの前半がすぐにコンソールに返されることがわかりました。

3 秒後に、コンソールは残りのコンテンツをここに再度出力します。当然のことながら、この 3 秒は、前に定義した CommentsPromise コメント インターフェイスによって返される時間差とまったく同じです。

<!-- 3s 前,上半段返回内容 -->
<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
  <link href="/index.css" />
</head>
<body>
  <div id="root">
    <div style="font-family:system-ui, sans-serif;line-height:1.8">
      <div>
        <div>商品</div>
        <p>价格</p><input /><button>添加评论</button>
        <div>
          <div>
            <p>评论</p><!--$?--><template id="B:0"></template>
            <div>Loading</div><!--/$-->
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->

前半 (3 秒前) で返された HTML コンテンツは、いくつかの静的リソースと静的テンプレートを含む HTML スクリプトのみであることがわかります。

ページにはコメントアウトされている 2 つの重要なノードがあります。

  • <Suspense /> でラップされた <Comments /> コンポーネントを利用します。

コンテンツのこの部分は、読み込み時にフォールバック属性を使用してコメント コンテンツを配置し、<!--$?--> コメント ノードを使用してコンテンツの 2 つの部分 (フォールバック プレースホルダー) をラップする読み込みコンテンツを示しています。 HTML ノードと <template id= "B:0"></template> 。

  • 通常返されるクライアント スクリプトのindex.js に加えて、追加の <template id="B:1"></template> ノードが返されます。

各 <template> タグには一意の id 属性があることに注意してください。

異なる ID 文字プレフィックスは、異なるノード タイプを表します。たとえば、ここでの B: 1 の B は境界 (サスペンス) を表し、S はセグメント (挿入される有効なセグメント) を表します: S:、通常は div、table、数式の場合、SVG対応する要素を使用します。

同時に、異なるプレースホルダー アノテーション ノードも異なる状態を表し、上のノード <!--$?--> はロード (保留) 状態を表します。

ページ全体が読み込まれると、ブラウザ コンソールを再度開くと、読み込みが完了したことを意味する <!--$--> に変わります (Completed)。

3 秒後、残りの HTML スクリプトのコンテンツがコンソールに返されます。

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
  <link href="/index.css" />
</head>
<body>
  <div id="root">
    <div style="font-family:system-ui, sans-serif;line-height:1.8">
      <div>
        <div>商品</div>
        <p>价格</p><input /><button>添加评论</button>
        <div>
          <div>
            <p>评论</p><!--$?--><template id="B:0"></template>
            <div>Loading</div><!--/$-->
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->
  <div hidden id="S:0">
    <p>This is Great.</p>
    <p>Worthy of recommendation!</p>
  </div>
  <script>$RC = function (b, c, e) { c = document.getElementById(c); c.parentNode.removeChild(c); var a = document.getElementById(b); if (a) { b = a.previousSibling; if (e) b.data = "$!", a.setAttribute("data-dgst", e); else { e = b.parentNode; a = b.nextSibling; var f = 0; do { if (a && 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d && "$?" !== d && "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry && b._reactRetry() } }; $RC("B:0", "S:0")</script>
  <div hidden id="S:1">
    <script>window.__setComments_data(["This is Great.", "Worthy of recommendation!"])</script>
  </div>
  <script>$RC("B:1", "S:1")</script>
</body>
</html>

まず、3 秒後にデータ要求が完了し、すべてのコメントが通常どおりサーバー スクリプトに返されます。

<div hidden id="S:0">
    <p>This is Great.</p>
    <p>Worthy of recommendation!</p>
 </div>

React は、通常返されるすべてのスクリプト コンテンツを、非表示とマークされた div でラップします。

要素にhidden属性が設定されている場合は表示されません。

同時に、サーバーから返された隠し属性を保持する各 HTML フラグメントには、一意の id 属性も保持されます。

次に、自然な次のステップは、サーバーから返されたこの HTML フラグメントを使用して、<Suspense /> 内のフォールバックの HTML コンテンツを置き換えることです。

$RC = function (b, c, e) {
  c = document.getElementById(c);
  c.parentNode.removeChild(c);
  var a = document.getElementById(b);
  if (a) {
    b = a.previousSibling;
    if (e) (b.data = '$!'), a.setAttribute('data-dgst', e);
    else {
      e = b.parentNode;
      a = b.nextSibling;
      var f = 0;
      do {
        if (a && 8 === a.nodeType) {
          var d = a.data;
          if ('/$' === d)
            if (0 === f) break;
            else f--;
          else ('$' !== d && '$?' !== d && '$!' !== d) || f++;
        }
        d = a.nextSibling;
        e.removeChild(a);
        a = d;
      } while (a);
      for (; c.firstChild; ) e.insertBefore(c.firstChild, a);
      b.data = '$';
    }
    b._reactRetry && b._reactRetry();
  }
};

3 秒後、ページ データ リクエスト全体が終了し、サーバーはこのスクリプトをクライアントに返します。

コア置換スクリプトは、上記の $RC の埋め込み JS スクリプト内にあります。このスクリプトは、$RC グローバル メソッドを定義します。メソッドの定義が完了したら、$RC("B:0", "S:0" を呼び出すことがわかります)返された HTML コンテンツは、JavaScript を使用して元の HTML プレースホルダー ノードを置き換え、リージョン ハイドレートを実行します。

上記の $RC メソッドを 1 行ずつ詳しく説明することはしませんが、このメソッドの中心となるアイデアは、Suspense の前後の要素を置き換えて、いわゆる「プログレッシブ HTML」効果を実現することです。

もちろん、 $RX 、 $RX 、 $RC という同様のメソッドもあります。この領域に関する内容は比較的簡単なので、この記事では詳しく説明しません。

11. エンディング

たまたま、著者が現在勤務している出張の大規模なフロントエンド部門では、ほとんどのフロントエンド アプリケーションが Remix に統合されているため、Remix をベースにいくつかの変更を加え、既存の出張ビジネスに適応させました。すぐに使える結果を達成します。

React18 の Steaming 機能と組み合わせるだけで、ページのパフォーマンスとユーザー エクスペリエンスの点で大きなメリットが得られます。

もちろん、Remix に切り替える際に遭遇する技術的な問題や、Remix がもたらすパフォーマンス上の利点やユーザー エクスペリエンスについても、後ほど共有し、話し合う予定です。

最後に、この記事は、他の人にインスピレーションを与える方法で、Steam の基本原理について説明することを目的としています。この記事の内容が、あなたの日々のビジネスにインスピレーションを与え、役立つことを願っています。

【おすすめの読書】

144ba4fc10320e282e2fea35bb31babd.jpeg

 「Ctrip Technology」公開アカウント

  共有、コミュニケーション、成長

おすすめ

転載: blog.csdn.net/ctrip_tech/article/details/131714108