一个完整的 React 服务端渲染(Server-Side Rendering)教学项目,展示了如何构建一个生产级的 React SSR 应用,包含路由同构、数据预取、样式处理、热更新等完整功能。
- ✅ 完整的 SSR 实现 - 服务端渲染 HTML,提升首屏加载速度和 SEO
- ✅ 路由同构 - 客户端和服务端共享路由配置
- ✅ 数据预取 - 服务端预先获取数据,实现真正的同构
- ✅ 样式同构 - 支持 CSS Modules 和 Material-UI 的 SSR
- ✅ 热更新 - 客户端和服务端代码热更新,提升开发效率
- ✅ 代码分割 - 基于路由的代码分割和异步加载
- ✅ SEO 优化 - 使用 react-helmet 管理页面 TDK
- ✅ 双渲染模式 - 支持 SSR 和 CSR 模式切换
- ✅ Redux 集成 - 完整的状态管理和同构支持
- ✅ Material-UI - 集成 Material-UI 组件库
- React 16.13.1 - UI 框架
- React Router 5.1.2 - 路由管理
- Redux 4.0.5 - 状态管理
- Material-UI 4.9.12 - UI 组件库
- React Helmet 6.0.0 - HTML 头部管理
- Express 4.17.1 - Node.js Web 框架
- React-DOM Server - 服务端渲染
- Webpack 4.43.0 - 模块打包
- Babel 7 - JavaScript 编译
- Stylus - CSS 预处理器
- webpack-dev-middleware - Webpack 开发中间件
- webpack-hot-middleware - 客户端热更新
- webpack-hot-server-middleware - 服务端热更新
- react-hot-loader - React 组件热更新
- Node.js >= 10.0.0
- npm >= 6.0.0
npm installnpm run devnpm run start这将先清理构建目录,然后进行生产构建并启动服务器。
npm run cleanreact-service-render/
├── client/ # 客户端代码
│ ├── compoents/ # 通用组件
│ │ ├── header.js # 头部组件
│ │ └── sider.js # 侧边栏组件
│ ├── containers/ # 页面容器
│ │ ├── a.js # 页面 A
│ │ ├── b.js # 页面 B (代码分割示例)
│ │ ├── c.js # 页面 C
│ │ └── d.js # 页面 D
│ ├── lib/ # 工具库
│ │ └── index.js # 高阶组件(数据预取 HOC)
│ ├── store/ # Redux 状态管理
│ │ └── index.js # Store 配置
│ ├── app.js # 应用根组件
│ ├── client.js # 客户端入口 (BrowserRouter)
│ ├── server.js # 服务端入口 (StaticRouter)
│ ├── Index.js # 客户端渲染入口 (hydrate)
│ ├── routes.js # 路由配置
│ └── theme.js # Material-UI 主题配置
├── server/ # 服务端代码
│ ├── index.js # Express 服务器
│ └── render.js # SSR 渲染逻辑
├── webpack/ # Webpack 配置
│ ├── client.dev.js # 客户端开发配置
│ ├── client.prod.js # 客户端生产配置
│ ├── server.dev.js # 服务端开发配置
│ └── server.prod.js # 服务端生产配置
├── .babelrc # Babel 配置
├── config.js # 全局配置 (SSR/CSR 模式切换)
├── package.json # 项目依赖
└── README.md # 项目文档
服务端渲染(Server-Side Rendering)是指在服务器端将 React 组件渲染成 HTML 字符串,然后发送给客户端。相比传统的客户端渲染(CSR),SSR 具有以下优势:
- 更快的首屏加载 - 用户可以更快看到页面内容
- 更好的 SEO - 搜索引擎可以直接抓取到页面内容
- 更好的用户体验 - 特别是在移动设备和弱网环境下
同构(Isomorphic)是指同一套代码既可以在服务端运行,也可以在客户端运行。React 通过虚拟 DOM 实现了同构:
// 服务端:将虚拟 DOM 转换为 HTML 字符串
const html = ReactDOMServer.renderToString(<App />);
// 客户端:复用服务端渲染的 HTML,只绑定事件
ReactDOM.hydrate(<App />, document.getElementById('root'));import { renderToString } from 'react-dom/server';
// 将 React 组件渲染为 HTML 字符串
const html = renderToString(<App />);import { hydrate } from 'react-dom';
// 复用服务端渲染的 HTML,只绑定事件处理
hydrate(<App />, document.getElementById('root'));使用 react-router-config 实现静态路由配置,客户端使用 BrowserRouter,服务端使用 StaticRouter:
客户端:
import { BrowserRouter } from 'react-router-dom';
const App = () => (
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
);服务端:
import { StaticRouter } from 'react-router-dom';
const App = ({ req, context }) => (
<StaticRouter location={req.path} context={context}>
{renderRoutes(routes)}
</StaticRouter>
);本项目参考 Next.js 的 getInitialProps 方法实现数据预取:
// 在组件上定义静态方法
class HomePage extends React.Component {
static getInitialProps(store, req, res) {
// 在服务端获取数据
return store.dispatch(fetchData());
}
render() {
return <div>{this.props.data}</div>;
}
}服务端流程:
- 使用
matchRoutes匹配当前路径的组件 - 调用匹配组件的
getInitialProps方法 - 等待所有数据获取完成
- 将数据注入 Redux Store
- 渲染组件为 HTML
- 将 Store 状态序列化到页面(数据注水)
客户端流程:
- 从
window.context读取服务端注入的数据 - 初始化 Redux Store
- 使用
hydrate复用服务端 HTML
import styles from './index.styl';
<div className={styles.container}>内容</div>服务端收集样式:
import { ServerStyleSheets } from '@material-ui/core/styles';
const sheets = new ServerStyleSheets();
const html = renderToString(sheets.collect(<App />));
const css = sheets.toString();客户端移除服务端样式:
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) {
jssStyles.parentElement.removeChild(jssStyles);
}使用 react-helmet 管理页面的 TDK(Title、Description、Keywords):
import { Helmet } from 'react-helmet';
<Helmet>
<title>页面标题</title>
<meta name="description" content="页面描述" />
<meta name="keywords" content="关键词1,关键词2" />
</Helmet>服务端获取 Helmet 数据:
import { Helmet } from 'react-helmet';
const helmet = Helmet.renderStatic();
const html = `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${markup}</div>
</body>
</html>
`;在 config.js 中可以切换 SSR/CSR 模式:
module.exports = {
__IS_SSR__: false // true: SSR 模式, false: CSR 模式
};- 在
client/containers/创建页面组件 - 在
client/routes.js添加路由配置:
{
path: '/new-page',
component: NewPage,
exact: true,
key: 'new-page'
}- 如果需要数据预取,添加
getInitialProps静态方法:
class NewPage extends React.Component {
static getInitialProps(store) {
return store.dispatch(fetchPageData());
}
render() {
// ...
}
}使用 react-loadable 实现路由级别的代码分割:
import Loadable from 'react-loadable';
const AsyncPage = Loadable({
loader: () => import('./containers/page'),
loading: () => <span>Loading...</span>
});
// 在路由配置中使用
{
path: '/async',
component: AsyncPage,
exact: true,
key: 'async'
}本项目使用 Redux 进行状态管理,Store 配置在 client/store/index.js:
import { createStore } from 'redux';
import reducer from './reducer';
// 创建 Store(每个请求创建新的 Store,避免单例问题)
export default (initialState) => {
return createStore(reducer, initialState);
};项目支持双端热更新:
- 客户端热更新:使用
webpack-hot-middleware和react-hot-loader - 服务端热更新:使用
webpack-hot-server-middleware
修改代码后自动刷新,无需手动重启服务器。
开发环境使用 Webpack Dev Middleware 实现热更新:
const compiler = webpack([clientConfig, serverConfig]);
app.use(webpackDevMiddleware(compiler, options));
app.use(webpackHotMiddleware(clientCompiler)); // 客户端热更新
app.use(webpackHotServerMiddleware(compiler)); // 服务端热更新生产环境构建优化:
- 代码压缩 - 使用 UglifyJS 压缩 JavaScript
- 代码分割 - 将第三方库分离到
libs.js - 文件 Hash - 为文件名添加 Hash,利于缓存
- CSS 提取 - 将 CSS 提取到独立文件
- Tree Shaking - 移除未使用的代码
buildClient/- 客户端构建产物buildServer/- 服务端构建产物
- 使用 PM2 管理 Node.js 进程
- 使用 Nginx 作为反向代理
- 启用 GZIP 压缩
- 配置 CDN 加速静态资源
- 添加监控 和日志系统
用户请求 → Nginx → Express Server
↓
路由匹配 (matchRoutes)
↓
数据预取 (getInitialProps)
↓
渲染组件 (renderToString)
↓
生成 HTML (包含数据注水)
↓
返回给客户端
↓
客户端 hydrate
↓
接管页面交互
开发环境:
webpack(configs) → Dev Middleware → Hot Middleware
↓
浏览器接收更新
生产环境:
webpack(configs) → 构建客户端和服务端代码
↓
生成构建产物
↓
启动 Express 服务器
服务端:
请求 → matchRoutes → getInitialProps → dispatch actions
→ Store 更新 → renderToString → 序列化 Store 状态
→ 注入到 HTML (window.context)
客户端:
读取 window.context → 初始化 Store → hydrate
→ 后续交互走客户端渲染
- 避免单例 - 每个请求应创建新的 Store 实例
- 生命周期 -
componentWillMount在服务端也会执行 - 浏览器 API - 服务端无法使用
window、document等 API - 样式闪烁 - 确保正确处理 CSS 的服务端渲染
- 数据预取 - 处理好异步数据的加载状态
- 错误处理 - 添加完善的错误边界和降级方案
A: 使用 Node.js 的 --inspect 参数:
node --inspect server/index.jsA: 使用条件判断:
if (typeof window !== 'undefined') {
// 客户端代码
} else {
// 服务端代码
}A:
- 使用缓存(Redis)
- 流式渲染(renderToNodeStream)
- 减少数据预取
- 使用 CDN
- 启用 HTTP/2
A: hydrate 会复用服务端渲染的 HTML,只添加事件监听,性能更好。render 会完全重新渲染,导致闪烁。
MIT
欢迎提交 Issue 和 Pull Request!
注意:本项目主要用于学习和教学目的,展示 React SSR 的核心原理和实现方式。在生产环境中,建议使用成熟的框架如 Next.js 或 Remix。