ホームページ >ウェブフロントエンド >jsチュートリアル >ストリーミングおよび動的データを使用した高度な React SSR テクニック
アプリケーションが成長するにつれて、課題も増加します。先を行くには、シームレスで高性能のユーザー エクスペリエンスを提供するために、高度な SSR テクニックを習得することが不可欠です。
前の記事で React プロジェクトでサーバー側レンダリングの基盤を構築したので、プロジェクトのスケーラビリティを維持し、サーバーからクライアントにデータを効率的にロードし、ハイドレーションの問題を解決するのに役立つ機能を共有できることを嬉しく思います。
サーバーサイド レンダリング (SSR) でのストリーミング は、ページ全体の準備が整うのを待つのではなく、サーバーが HTML ページの一部を生成時に分割してブラウザーに送信する手法です。届ける前に。これにより、ブラウザはコンテンツのレンダリングをすぐに開始できるようになり、読み込み時間とユーザーのパフォーマンスが向上します。
ストリーミングは次の場合に特に効果的です。
ストリーミングは、従来の SSR と最新のクライアント側の対話性の間のギャップを埋め、ユーザーがパフォーマンスを犠牲にすることなく意味のあるコンテンツをより速く表示できるようにします。
遅延読み込み は、実際に必要になるまでコンポーネントまたはモジュールの読み込みを延期し、初期読み込み時間を短縮し、パフォーマンスを向上させる手法です。 SSR と組み合わせると、遅延読み込みはサーバーとクライアントの両方のワークロードを大幅に最適化できます。
遅延読み込みは、コンポーネントを Promise として動的にインポートする React.lazy に依存しています。従来の SSR では、レンダリングは同期的です。つまり、サーバーは完全な HTML を生成してブラウザに送信する前に、すべての Promises を解決する必要があります。
ストリーミングは、コンポーネントがレンダリングされるときにサーバーが HTML をチャンクで送信できるようにすることで、これらの課題を解決します。このアプローチにより、サスペンス フォールバックをブラウザーに即座に送信できるようになり、ユーザーが意味のあるコンテンツを早期に確認できるようになります。遅延ロードされたコンポーネントが解決されると、レンダリングされた HTML が段階的にブラウザにストリーミングされ、フォールバック コンテンツがシームレスに置き換えられます。これにより、レンダリング プロセスのブロックが回避され、遅延が減少し、体感的な読み込み時間が短縮されます。
このガイドは、下部にリンクされている前の記事 本番対応 SSR React アプリケーションの構築 で紹介した概念に基づいて構築されています。 React で SSR を有効にし、遅延読み込みコンポーネントをサポートするために、React コンポーネントとサーバーの両方にいくつかの更新を加えます。
React の renderToString メソッドは SSR によく使用されますが、HTML コンテンツ全体の準備が整うまで待ってからブラウザに送信します。 renderToPipeableStream に切り替えることで、生成された HTML の一部を送信するストリーミングを有効にすることができます。
// ./src/entry-server.tsx import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server' import App from './App' export function render(options?: RenderToPipeableStreamOptions) { return renderToPipeableStream(<app></app>, options) }
この例では、概念を示すために単純な Card コンポーネントを作成します。運用アプリケーションでは、この手法は通常、パフォーマンスを最適化するために、より大きなモジュールまたはページ全体で使用されます。
// ./src/Card.tsx import { useState } from 'react' function Card() { const [count, setCount] = useState(0) return ( <div classname="card"> <button onclick="{()"> setCount((count) => count + 1)}> count is {count} </button> <p> Edit <code>src/App.tsx</code> and save to test HMR </p> </div> ) } export default Card
遅延読み込みコンポーネントを使用するには、React.lazy を使用してコンポーネントを動的にインポートし、Suspense でラップして読み込み中にフォールバック UI を提供します
// ./src/App.tsx import { lazy, Suspense } from 'react' import reactLogo from './assets/react.svg' import viteLogo from '/vite.svg' import './App.css' const Card = lazy(() => import('./Card')) function App() { return ( <div> <a href="https://vite.dev" target="_blank"> <img src="%7BviteLogo%7D" classname="logo" alt="Vite logo"> </a> <a href="https://react.dev" target="_blank"> <img src="%7BreactLogo%7D" classname="logo react" alt="React logo"> </a> </div> <h1>Vite + React</h1> <suspense fallback="Loading..."> <card></card> </suspense> <p classname="read-the-docs"> Click on the Vite and React logos to learn more </p> > ) } export default App
ストリーミングを有効にするには、開発セットアップと運用セットアップの両方が一貫した HTML レンダリング プロセスをサポートする必要があります。どちらの環境でもプロセスは同じであるため、単一の再利用可能な関数を作成して、ストリーミング コンテンツを効果的に処理できます。
// ./server/constants.ts export const ABORT_DELAY = 5000
streamContent 関数は、レンダリング プロセスを開始し、HTML の増分チャンクを応答に書き込み、適切なエラー処理を保証します。
// ./server/streamContent.ts import { Transform } from 'node:stream' import { Request, Response, NextFunction } from 'express' import { ABORT_DELAY, HTML_KEY } from './constants' import type { render } from '../src/entry-server' export type StreamContentArgs = { render: typeof render html: string req: Request res: Response next: NextFunction } export function streamContent({ render, html, res }: StreamContentArgs) { let renderFailed = false // Initiates the streaming process by calling the render function const { pipe, abort } = render({ // Handles errors that occur before the shell is ready onShellError() { res.status(500).set({ 'Content-Type': 'text/html' }).send('<pre class="brush:php;toolbar:false">Something went wrong') }, // Called when the shell (initial HTML) is ready for streaming onShellReady() { res.status(renderFailed ? 500 : 200).set({ 'Content-Type': 'text/html' }) // Split the HTML into two parts using the placeholder const [htmlStart, htmlEnd] = html.split(HTML_KEY) // Write the starting part of the HTML to the response res.write(htmlStart) // Create a transform stream to handle the chunks of HTML from the renderer const transformStream = new Transform({ transform(chunk, encoding, callback) { // Write each chunk to the response res.write(chunk, encoding) callback() }, }) // When the streaming is finished, write the closing part of the HTML transformStream.on('finish', () => { res.end(htmlEnd) }) // Pipe the render output through the transform stream pipe(transformStream) }, onError(error) { // Logs errors encountered during rendering renderFailed = true console.error((error as Error).stack) }, }) // Abort the rendering process after a delay to avoid hanging requests setTimeout(abort, ABORT_DELAY) }
// ./server/dev.ts import { Application } from 'express' import fs from 'fs' import path from 'path' import { StreamContentArgs } from './streamContent' const HTML_PATH = path.resolve(process.cwd(), 'index.html') const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx') // Add to args the streamContent callback export async function setupDev(app: Application, streamContent: (args: StreamContentArgs) => void) { const vite = await ( await import('vite') ).createServer({ root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', }) app.use(vite.middlewares) app.get('*', async (req, res, next) => { try { let html = fs.readFileSync(HTML_PATH, 'utf-8') html = await vite.transformIndexHtml(req.originalUrl, html) const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH) // Use the same callback for production and development process streamContent({ render, html, req, res, next }) } catch (e) { vite.ssrFixStacktrace(e as Error) console.error((e as Error).stack) next(e) } }) }
// ./server/prod.ts import { Application } from 'express' import fs from 'fs' import path from 'path' import compression from 'compression' import sirv from 'sirv' import { StreamContentArgs } from './streamContent' const CLIENT_PATH = path.resolve(process.cwd(), 'dist/client') const HTML_PATH = path.resolve(process.cwd(), 'dist/client/index.html') const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'dist/ssr/entry-server.js') // Add to Args the streamContent callback export async function setupProd(app: Application, streamContent: (args: StreamContentArgs) => void) { app.use(compression()) app.use(sirv(CLIENT_PATH, { extensions: [] })) app.get('*', async (req, res, next) => { try { const html = fs.readFileSync(HTML_PATH, 'utf-8') const { render } = await import(ENTRY_SERVER_PATH) // Use the same callback for production and development process streamContent({ render, html, req, res, next }) } catch (e) { console.error((e as Error).stack) next(e) } }) }
streamContent 関数を各構成に渡します:
// ./server/app.ts import express from 'express' import { PROD, APP_PORT } from './constants' import { setupProd } from './prod' import { setupDev } from './dev' import { streamContent } from './streamContent' export async function createServer() { const app = express() if (PROD) { await setupProd(app, streamContent) } else { await setupDev(app, streamContent) } app.listen(APP_PORT, () => { console.log(`http://localhost:${APP_PORT}`) }) } createServer()
これらの変更を実装すると、サーバーは次のようになります:
HTML をクライアントに送信する前に、サーバーで生成された HTML を完全に制御できます。これにより、必要に応じてタグ、スタイル、リンク、またはその他の要素を追加して、構造を動的に変更できます。
特に強力な手法の 1 つは、<script> を挿入することです。タグを HTML に追加します。このアプローチにより、動的データをクライアントに直接渡すことができます。</script>
この例では、環境変数を渡すことに焦点を当てますが、必要な JavaScript オブジェクトを渡すこともできます。環境変数をクライアントに渡すことで、それらの変数が変更されたときにアプリケーション全体を再構築する必要がなくなります。下部にリンクされているサンプル リポジトリでは、プロファイル データがどのように動的に渡されるかを確認することもできます。
サーバーに API_URL 環境変数を設定します。デフォルトでは、これは jsonplaceholder を指します。 __INITIAL_DATA__ は、グローバル ウィンドウ オブジェクトにデータを保存するためのキーとして機能します。
// ./src/entry-server.tsx import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server' import App from './App' export function render(options?: RenderToPipeableStreamOptions) { return renderToPipeableStream(<app></app>, options) }
クライアントに送信する前に、初期データを HTML 文字列に挿入するユーティリティ関数を作成します。このデータには、API_URL などの環境変数が含まれます。
// ./src/Card.tsx import { useState } from 'react' function Card() { const [count, setCount] = useState(0) return ( <div classname="card"> <button onclick="{()"> setCount((count) => count + 1)}> count is {count} </button> <p> Edit <code>src/App.tsx</code> and save to test HMR </p> </div> ) } export default Card
applyInitialData 関数を使用して、初期データを HTML に挿入し、クライアントに送信します。
// ./src/App.tsx import { lazy, Suspense } from 'react' import reactLogo from './assets/react.svg' import viteLogo from '/vite.svg' import './App.css' const Card = lazy(() => import('./Card')) function App() { return ( <div> <a href="https://vite.dev" target="_blank"> <img src="%7BviteLogo%7D" classname="logo" alt="Vite logo"> </a> <a href="https://react.dev" target="_blank"> <img src="%7BreactLogo%7D" classname="logo react" alt="React logo"> </a> </div> <h1>Vite + React</h1> <suspense fallback="Loading..."> <card></card> </suspense> <p classname="read-the-docs"> Click on the Vite and React logos to learn more </p> > ) } export default App
グローバル型宣言を更新して、__INITIAL_DATA__ キーとその構造を含めます。
// ./server/constants.ts export const ABORT_DELAY = 5000
// ./server/streamContent.ts import { Transform } from 'node:stream' import { Request, Response, NextFunction } from 'express' import { ABORT_DELAY, HTML_KEY } from './constants' import type { render } from '../src/entry-server' export type StreamContentArgs = { render: typeof render html: string req: Request res: Response next: NextFunction } export function streamContent({ render, html, res }: StreamContentArgs) { let renderFailed = false // Initiates the streaming process by calling the render function const { pipe, abort } = render({ // Handles errors that occur before the shell is ready onShellError() { res.status(500).set({ 'Content-Type': 'text/html' }).send('<pre class="brush:php;toolbar:false">Something went wrong') }, // Called when the shell (initial HTML) is ready for streaming onShellReady() { res.status(renderFailed ? 500 : 200).set({ 'Content-Type': 'text/html' }) // Split the HTML into two parts using the placeholder const [htmlStart, htmlEnd] = html.split(HTML_KEY) // Write the starting part of the HTML to the response res.write(htmlStart) // Create a transform stream to handle the chunks of HTML from the renderer const transformStream = new Transform({ transform(chunk, encoding, callback) { // Write each chunk to the response res.write(chunk, encoding) callback() }, }) // When the streaming is finished, write the closing part of the HTML transformStream.on('finish', () => { res.end(htmlEnd) }) // Pipe the render output through the transform stream pipe(transformStream) }, onError(error) { // Logs errors encountered during rendering renderFailed = true console.error((error as Error).stack) }, }) // Abort the rendering process after a delay to avoid hanging requests setTimeout(abort, ABORT_DELAY) }
// ./server/dev.ts import { Application } from 'express' import fs from 'fs' import path from 'path' import { StreamContentArgs } from './streamContent' const HTML_PATH = path.resolve(process.cwd(), 'index.html') const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx') // Add to args the streamContent callback export async function setupDev(app: Application, streamContent: (args: StreamContentArgs) => void) { const vite = await ( await import('vite') ).createServer({ root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', }) app.use(vite.middlewares) app.get('*', async (req, res, next) => { try { let html = fs.readFileSync(HTML_PATH, 'utf-8') html = await vite.transformIndexHtml(req.originalUrl, html) const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH) // Use the same callback for production and development process streamContent({ render, html, req, res, next }) } catch (e) { vite.ssrFixStacktrace(e as Error) console.error((e as Error).stack) next(e) } }) }
これで、クライアント側のコードで動的な環境変数を使用できるようになり、JavaScript バンドルを再構築せずにサーバーからクライアントへのデータを管理できるようになりました。このアプローチにより構成が簡素化され、アプリの柔軟性と拡張性が向上します。
サーバーからクライアントにデータを渡すことができるようになったので、このデータをコンポーネント内で直接使用しようとすると、ハイドレーションの問題が発生する可能性があります。これらのエラーは、サーバーでレンダリングされた HTML がクライアントでの最初の React レンダリングと一致しないために発生します。
コンポーネント内で API_URL を単純な文字列として使用することを検討してください
// ./src/entry-server.tsx import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server' import App from './App' export function render(options?: RenderToPipeableStreamOptions) { return renderToPipeableStream(<app></app>, options) }
この場合、サーバーは API_URL を空の文字列としてコンポーネントをレンダリングしますが、クライアントでは API_URL にすでにウィンドウ オブジェクトの値が含まれています。 React はサーバーでレンダリングされた HTML とクライアントの React ツリーの間の差異を検出するため、この不一致によりハイドレーション エラーが発生します。
ユーザーにはコンテンツがすぐに更新されることがわかりますが、React はコンソールにハイドレーション警告を記録します。この問題を解決するには、サーバーとクライアントが同じ初期 HTML をレンダリングするか、API_URL をサーバーのエントリ ポイントに明示的に渡す必要があります。
エラーを解決するには、サーバー エントリ ポイントを介して、initialData をアプリ コンポーネントに渡します。
// ./src/Card.tsx import { useState } from 'react' function Card() { const [count, setCount] = useState(0) return ( <div classname="card"> <button onclick="{()"> setCount((count) => count + 1)}> count is {count} </button> <p> Edit <code>src/App.tsx</code> and save to test HMR </p> </div> ) } export default Card
// ./src/App.tsx import { lazy, Suspense } from 'react' import reactLogo from './assets/react.svg' import viteLogo from '/vite.svg' import './App.css' const Card = lazy(() => import('./Card')) function App() { return ( <div> <a href="https://vite.dev" target="_blank"> <img src="%7BviteLogo%7D" classname="logo" alt="Vite logo"> </a> <a href="https://react.dev" target="_blank"> <img src="%7BreactLogo%7D" classname="logo react" alt="React logo"> </a> </div> <h1>Vite + React</h1> <suspense fallback="Loading..."> <card></card> </suspense> <p classname="read-the-docs"> Click on the Vite and React logos to learn more </p> > ) } export default App
// ./server/constants.ts export const ABORT_DELAY = 5000
これで、サーバーでレンダリングされた HTML がクライアントでの最初の React レンダリングと一致し、ハイドレーション エラーがなくなりました。 React はサーバーとクライアントのツリーを正しく調整し、シームレスなエクスペリエンスを保証します。
API_URL のような動的データの場合は、React Context を使用してサーバーとクライアントの間でデフォルト値を管理し、渡すことを検討してください。このアプローチにより、コンポーネント間での共有データの管理が簡素化されます。実装例は、リンクされたリポジトリの下部にあります。
この記事では、ストリーミングの実装、サーバーからクライアントへのデータの管理、ハイドレーションの問題の解決に焦点を当てて、React の高度な SSR テクニックを検討しました。これらの方法により、アプリケーションのスケーラビリティとパフォーマンスが向上し、シームレスなユーザー エクスペリエンスが実現されます。
これは、React を使用した SSR に関するシリーズの一部です。他の記事もお楽しみに!
フィードバック、コラボレーション、技術的なアイデアについての議論はいつでも受け付けています。お気軽にご連絡ください。
以上がストリーミングおよび動的データを使用した高度な React SSR テクニックの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。