Wenn Ihre Anwendung wächst, wachsen auch die Herausforderungen. Um an der Spitze zu bleiben, ist die Beherrschung fortschrittlicher SSR-Techniken für die Bereitstellung eines nahtlosen und leistungsstarken Benutzererlebnisses unerlässlich.
Nachdem ich im vorherigen Artikel eine Grundlage für serverseitiges Rendering in React-Projekten geschaffen habe, freue ich mich, Ihnen Funktionen vorstellen zu können, die Ihnen dabei helfen können, die Skalierbarkeit von Projekten aufrechtzuerhalten, Daten effizient vom Server auf den Client zu laden und Hydratationsprobleme zu lösen.
Streaming beim serverseitigen Rendering (SSR) ist eine Technik, bei der der Server Teile der HTML-Seite in Blöcken an den Browser sendet, während sie generiert werden, anstatt darauf zu warten, dass die gesamte Seite fertig ist bevor Sie es ausliefern. Dadurch kann der Browser sofort mit dem Rendern von Inhalten beginnen, was die Ladezeiten und die Leistung des Benutzers verbessert.
Streaming ist besonders effektiv für:
Streaming schließt die Lücke zwischen traditionellem SSR und moderner clientseitiger Interaktivität und stellt sicher, dass Benutzer aussagekräftige Inhalte schneller sehen, ohne Kompromisse bei der Leistung einzugehen.
Lazy Loading ist eine Technik, die das Laden von Komponenten oder Modulen verzögert, bis sie tatsächlich benötigt werden, wodurch die anfängliche Ladezeit verkürzt und die Leistung verbessert wird. In Kombination mit SSR kann Lazy Loading sowohl die Server- als auch die Client-Arbeitslast erheblich optimieren.
Lazy Loading basiert auf React.lazy, das Komponenten dynamisch als Promises importiert. Im herkömmlichen SSR erfolgt das Rendering synchron, was bedeutet, dass der Server alle Promises auflösen muss, bevor er den vollständigen HTML-Code generiert und an den Browser sendet.
Streaming löst diese Herausforderungen, indem es dem Server ermöglicht, HTML in Blöcken zu senden, während Komponenten gerendert werden. Durch diesen Ansatz kann der Suspense-Fallback sofort an den Browser gesendet werden, sodass Benutzer frühzeitig aussagekräftige Inhalte sehen. Wenn verzögert geladene Komponenten aufgelöst werden, wird ihr gerenderter HTML-Code schrittweise an den Browser gestreamt und ersetzt so nahtlos den Fallback-Inhalt. Dadurch wird eine Blockierung des Rendering-Prozesses vermieden, Verzögerungen reduziert und die wahrgenommene Ladezeit verbessert.
Dieser Leitfaden baut auf Konzepten auf, die im vorherigen Artikel Erstellung produktionsbereiter SSR-React-Anwendungen vorgestellt wurden, den Sie unten verlinkt finden. Um SSR mit React zu ermöglichen und verzögert geladene Komponenten zu unterstützen, werden wir mehrere Updates sowohl an den React-Komponenten als auch am Server vornehmen.
Die renderToString-Methode von React wird üblicherweise für SSR verwendet, sie wartet jedoch, bis der gesamte HTML-Inhalt fertig ist, bevor sie ihn an den Browser sendet. Durch den Wechsel zu renderToPipeableStream können wir Streaming aktivieren, das Teile des HTML sendet, während sie generiert werden.
// ./src/entry-server.tsx import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server' import App from './App' export function render(options?: RenderToPipeableStreamOptions) { return renderToPipeableStream(<App />, options) }
In diesem Beispiel erstellen wir eine einfache Kartenkomponente, um das Konzept zu demonstrieren. In Produktionsanwendungen wird diese Technik typischerweise bei größeren Modulen oder ganzen Seiten verwendet, um die Leistung zu optimieren.
// ./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
Um die Lazy-Loaded-Komponente zu verwenden, importieren Sie sie dynamisch mit React.lazy und umschließen Sie sie mit Suspense, um während des Ladens eine Fallback-Benutzeroberfläche bereitzustellen
// ./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
Um Streaming zu ermöglichen, müssen sowohl die Entwicklungs- als auch die Produktionseinstellungen einen konsistenten HTML-Rendering-Prozess unterstützen. Da der Prozess für beide Umgebungen derselbe ist, können Sie eine einzige wiederverwendbare Funktion erstellen, um Streaming-Inhalte effektiv zu verarbeiten.
// ./server/constants.ts export const ABORT_DELAY = 5000
Die streamContent-Funktion initiiert den Rendering-Prozess, schreibt inkrementelle HTML-Blöcke in die Antwort und stellt eine ordnungsgemäße Fehlerbehandlung sicher.
// ./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) } }) }
Übergeben Sie die streamContent-Funktion an jede Konfiguration:
// ./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()
Nach der Implementierung dieser Änderungen wird Ihr Server:
Bevor Sie HTML an den Client senden, haben Sie die volle Kontrolle über das vom Server generierte HTML. Dadurch können Sie die Struktur dynamisch ändern, indem Sie nach Bedarf Tags, Stile, Links oder andere Elemente hinzufügen.
Eine besonders wirkungsvolle Technik ist das Einfügen eines