随着应用程序的增长,挑战也会随之增加。为了保持领先地位,掌握先进的 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中文网其他相关文章!