SPA(单页应用)给前端带来了极佳的交互体验,但它天生有一个短板:服务端返回的只是一个几乎空白的HTML壳子,页面内容完全依赖浏览器下载并执行JavaScript之后才能渲染出来。这直接导致首屏白屏时间较长,搜索引擎爬虫也难以抓取有效内容。服务端渲染(SSR)正是为了解决这些问题而存在的方案:让服务器直接输出渲染完成的HTML,浏览器拿到就能展示,之后再由客户端接管交互。本文将从原理到实践,完整讲解如何构建一个支持SSR的React应用。

一、先弄清楚SSR与CSR的本质区别
客户端渲染(CSR)的流程是这样的:浏览器请求页面,服务器返回一个只包含<div id="root"></div>和一个<script>标签的HTML文件,浏览器解析后下载JS bundle,执行React代码,由ReactDOM.createRoot把组件树渲染到真实DOM上。也就是说,用户在JS下载执行完成之前,看到的是一片空白。
服务端渲染(SSR)则把组件渲染这一步提前到了服务器上。服务器执行React组件代码,调用renderToString把组件树转成HTML字符串,直接拼进响应体返回给浏览器。浏览器收到的是带着完整内容的HTML,可以立即绘制,SEO爬虫也能直接读到内容。
但SSR并不是终点。服务器输出的HTML是静态的,没有事件绑定,没有交互能力。所以浏览器侧还需要再执行一次React,把事件和状态附加到已有的DOM上,这个过程叫做水合。理解“服务端渲染字符串 + 客户端水合”这两阶段,是掌握React SSR的关键。
二、方案选型:Next.js还是自己搭
如果项目从零开始,且希望快速获得SSR能力,Next.js几乎是默认选择。它内置了路由系统(基于文件系统的pages或app目录)、数据预取、代码分割、图片优化等能力,开发者几乎不需要关心SSR的底层细节。安装也非常简单:
npx create-next-app@latest my-ssr-app cd my-ssr-app npm run dev
在app目录下写一个页面组件,Next.js会自动对它做服务端渲染。如果某些页面需要客户端交互,只需在组件顶部加上'use client'指令声明即可,框架会处理好边界。
但Next.js的约定式路由和内部机制也带来一定学习成本和黑盒感。如果你的需求是给已有项目加SSR,或者希望完全掌控渲染流程,自己用Express加React手写一套SSR服务也是可行的,接下来就动手实现一个最小可用版本。
三、手写一个Express加React的SSR服务
首先搭建项目结构。核心思路是代码同构:同一份组件代码,在服务端被renderToString执行,在客户端被hydrateRoot执行。项目通常分三个入口:共享的App组件、服务端入口、客户端入口。
共享的App组件保持纯净,不要在组件顶层直接访问window或document,因为服务器上没有这些对象。组件示例如下:
import React from 'react';
export default function App({ data }) {
return (
<div>
<h1>Hello SSR</h1>
<p>服务端传入的数据:{data}</p>
</div>
);
}服务端入口使用Express,把渲染出的HTML字符串拼接到模板中返回。注意把初始数据序列化后注入window.__INITIAL_DATA__,供客户端水合时复用,避免二次请求:
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
const app = express();
app.get('/', (req, res) => {
const data = '来自服务端的数据';
const html = renderToString(React.createElement(App, { data }));
res.send(`
<!DOCTYPE html>
<html>
<head><title>React SSR</title></head>
<body>
<div id="root">${html}</div>
<script>window.__INITIAL_DATA__ = ${JSON.stringify({ data })};</script>
<script src="/bundle.js"></script>
</body>
<html>
`);
});
app.listen(3000, () => console.log('SSR server on 3000'));客户端入口则调用hydrateRoot而不是createRoot。hydrateRoot会复用服务端已经生成的DOM节点,只附加事件监听,而不是销毁重建:
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const initialData = window.__INITIAL_DATA__ || { data: '' };
hydrateRoot(
document.getElementById('root'),
React.createElement(App, initialData)
);构建方面需要用Webpack(或Vite)分别打包客户端和两端共用的代码,通常配置两个入口,服务端产物以CommonJS格式输出给Node直接require,客户端产物输出给浏览器加载。
四、路由与数据预取的服务端处理
SSR下的路由不能只依赖浏览器地址栏。服务端需要根据请求的URL决定渲染哪个组件,常用做法是配合StaticRouter。服务端读取req.url传入路由上下文,客户端则继续使用BrowserRouter:
import { StaticRouter } from 'react-router-dom/server';
const html = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);数据预取是SSR中另一个重点。常见模式是在路由配置上挂一个静态方法loadData,服务端匹配到路由后先调用它获取数据,拿到结果再传给renderToString。这样首屏HTML里就带有真实数据,无需等待客户端请求返回。
同时要注意生命周期差异:服务端只执行到componentDidMount之前的部分,useEffect在服务端完全不执行。所有依赖浏览器API的逻辑,要么放进useEffect,要么通过typeof window !== 'undefined'做环境判断。
五、水合常见坑与性能建议
水合失败最常见的原因是服务端输出与客户端首次渲染结果不一致。比如代码里写了Date.now()或随机数,两端渲染结果必然不同,React会在控制台报水合警告,甚至整棵树重新渲染。解决办法是把这类动态值放到useEffect中生成,保证两端首渲一致。
另一个坑是HTML结构错误,例如在<p>标签内嵌套了<div>,浏览器会自动纠正结构,导致客户端看到的DOM与服务端字符串对不上。这类问题排查起来比较隐蔽,建议使用validateDOMNesting相关提示仔细检查。
性能方面有几点建议:第一,对组件使用React.memo减少水合阶段的重复渲染开销;第二,利用流式渲染(renderToPipeableStream)让HTML分块尽早到达浏览器;第三,把首屏必需的CSS内联进HTML,避免渲染阻塞;第四,对非首屏组件做懒加载,减小客户端bundle体积。
总结一下,构建React SSR应用的核心是理解同构思想:一份代码,两端执行,服务端负责出HTML,客户端负责水合接管交互。如果追求开发效率直接选Next.js,如果想深度定制就用Express手搭。无论哪种路线,处理好数据预取和水合一致性,SSR就能稳定地为你带来更快的首屏和更好的SEO表现。