隨著應用程式的成長,挑戰也會隨之增加。為了保持領先地位,掌握先進的 SSR 技術對於提供無縫且高效能的使用者體驗至關重要。
在上一篇文章中為React 專案中的伺服器端渲染奠定了基礎,我很高興與您分享可以幫助您保持專案可擴展性、有效地將資料從伺服器載入到客戶端以及解決水合問題的功能。
伺服器端渲染(SSR) 中的串流 是一種技術,伺服器在產生HTML 頁面的各個部分時將其以區塊的形式傳送到瀏覽器,而不是等待整個頁面準備好在交付之前。這允許瀏覽器立即開始渲染內容,從而縮短載入時間並提高使用者的效能。
串流媒體對於以下方面特別有效:
串流媒體彌合了傳統 SSR 和現代客戶端互動性之間的差距,確保使用者在不影響效能的情況下更快地看到有意義的內容。
延遲載入是一種將元件或模組的載入延遲到實際需要時才載入的技術,從而減少初始載入時間並提高效能。與 SSR 結合使用時,延遲載入可以顯著優化伺服器和用戶端工作負載。
延遲載入依賴 React.lazy,它動態地將元件匯入為 Promises。在傳統的 SSR 中,渲染是同步的,這意味著伺服器必須解析所有 Promise,然後才能產生完整的 HTML 並將其發送到瀏覽器。
串流處理允許伺服器在渲染元件時以區塊的形式發送 HTML,從而解決了這些挑戰。這種方法使 Suspense 回退能夠立即發送到瀏覽器,確保用戶儘早看到有意義的內容。當延遲載入的元件被解析時,它們渲染的 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 />, 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={viteLogo} className="logo" alt="Vite logo" /> </a> <a href="https://react.dev" target="_blank"> <img src={reactLogo} className="logo react" alt="React logo" /> </a> </div> <h1>Vite + React</h1> <Suspense fallback='Loading...'> <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。這允許您根據需要添加標籤、樣式、連結或任何其他元素來動態修改結構。
一種特別強大的技術是注入一個<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 />, 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={viteLogo} className="logo" alt="Vite logo" /> </a> <a href="https://react.dev" target="_blank"> <img src={reactLogo} className="logo react" alt="React logo" /> </a> </div> <h1>Vite + React</h1> <Suspense fallback='Loading...'> <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 />, options) }
在這種情況下,伺服器會將 API_URL 的元件渲染為空字串,但在用戶端上,API_URL 已經具有來自 window 物件的值。這種不匹配會導致水合錯誤,因為 React 偵測到伺服器渲染的 HTML 和用戶端的 React 樹之間存在差異。
雖然使用者可能會看到內容快速更新,但 React 在控制台中記錄了水合警告。要解決此問題,您需要確保伺服器和用戶端呈現相同的初始 HTML 或將 API_URL 明確傳遞到伺服器入口點。
要解決錯誤,請透過伺服器入口點將initialData傳遞給App元件。
// ./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={viteLogo} className="logo" alt="Vite logo" /> </a> <a href="https://react.dev" target="_blank"> <img src={reactLogo} className="logo react" alt="React logo" /> </a> </div> <h1>Vite + React</h1> <Suspense fallback='Loading...'> <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中文網其他相關文章!