从原理到实现,全面理解 Server-Side Rendering
Next.js 虽好,但在大型项目中面临问题:
同一套 React 代码在服务端和客户端运行:
服务端:StaticRouter +
renderToPipeableStream
客户端:BrowserRouter +
hydrateRoot
matchRoutes()
prefetchQuery
dehydrate()
renderToPipeableStream
hydrateRoot()
| 配置 | 值 |
|---|---|
| 入口 | app/server/server.ts |
| target | node |
| output | commonjs |
| 样式处理 | ignore-loader |
| 配置 | 值 |
|---|---|
| 入口 | app/client/index.tsx |
| target | browserslist |
| 代码分割 | @loadable/component |
| 样式处理 | MiniCssExtractPlugin |
// 服务端配置 server: (env) => ({ target: "node", entry: { server: resolve("app/server/server.ts") }, externalsPresets: { node: true }, output: { libraryTarget: "commonjs" }, module: { rules: [{ test: /\.(less|css|svg)$/, loader: "ignore-loader" }] } }) // 客户端配置 client: (env) => ({ target: "browserslist", entry: { client: resolve("app/client/index.tsx") }, plugins: [ new LoadablePlugin(), new MiniCssExtractPlugin() ] })
import { renderToPipeableStream } from "react-dom/server"; export const response = (ctx, jsx, startTemplate, endTemplate) => { return new Promise((resolve, reject) => { const { pipe } = renderToPipeableStream(jsx, { // 1️⃣ Shell 准备就绪 - 发送 HTML 头部 onShellReady() { ctx.res.write(getStartTemplate(startTemplate)); }, // 2️⃣ 所有内容就绪 - 流式发送 + 结尾 onAllReady() { pipe(ctx.res); ctx.res.end(getEndTemplate(endTemplate)); }, // ❌ 错误处理 onShellError(error) { ctx.status = 500; reject(error); } }); }); };
TTFB 更快:用户在数据加载完成前就能看到页面骨架
渐进式加载:内容逐步显示,无需等待全部完成
// 服务端渲染时注入标志 `<script id="__APP_FLAG__"> {"isSSR": true} </script>`
const getTradeFlag = () => { const el = document.querySelector("#__APP_FLAG__"); if (!el) return { isSSR: false }; return JSON.parse(el.textContent); };
const renderWithErrorBoundary = () => { try { if (tradeFlag.isSSR) { // ✅ SSR 模式:水合已有 DOM loadableReady(() => { hydrateRoot(root, <ClientApp />); }); } else { // ⚡ CSR 模式:完整客户端渲染 createRoot(root).render(<ClientApp />); } } catch (error) { // 🆘 水合失败也降级 createRoot(root).render(<ClientApp />); } };
| 场景 | 触发 | 结果 |
|---|---|---|
| 正常 SSR | isSSR: true | hydrateRoot() |
| Node.js 挂掉 | 标志不存在 | createRoot() |
| 水合报错 | catch 捕获 | createRoot() |
router.get("(.*)", async (ctx) => { const extractor = new ChunkExtractor({ statsFile }); const queryClient = new QueryClient(); // 数据预取 const { dehydratedState } = await prefetch(ctx, queryClient); // 渲染 const { jsx, helmetContext } = renderApp(ctx, queryClient, extractor, dehydratedState); // 流式响应 await response(ctx, jsx, { helmetContext, extractor }, { dehydratedState }); });
const routes = [ { path: "/", element: <Index />, children: [ { path: ":locales/home", element: <Home />, queryKey: [PrefetchKeys.HOME], loadData: HomeService.getList } ] } ];
| 层级 | 技术 | 用途 |
|---|---|---|
| 前端 | React 19 | UI 渲染 |
| 服务端 | Koa.js | HTTP 服务 |
| 数据 | React Query | 预取/缓存 |
| 构建 | Webpack 5 | 双入口打包 |
同一份代码双端运行
Dehydrate/Hydrate
renderToPipeableStream
SSR→CSR 自动切换