服务端渲染(Server-Side Rendering, SSR)是一种常用的技术,它能让单页应用加载更快、更容易被搜索引擎找到。本文会通过实际代码,讲清楚在 React 18 中怎么配合 Redux 实现服务端渲染,并说明它的基本原理和具体做法。
服务端渲染的价值与动因
跟只在浏览器里渲染页面的方式比起来,服务端渲染有几个明显的好处:首先,搜索引擎的爬虫可以直接看到完整的 HTML 内容,这样网页内容更容易被收录;其次,用户不用等 JavaScript 文件下载完、解析完再执行,就能马上看到页面的主要内容,首屏速度更快;最后,在网络不好或者手机比较旧的情况下,页面也能更流畅地显示,因为大部分工作是在服务器上完成的。
技术实现机理
服务端渲染的基本流程是这样的:当服务器收到一个请求后,它会先创建一个 Redux 的状态容器(store),然后提前把页面需要的数据加载好;接着,它用 ReactDOMServer.renderToString() 这个方法把 React 组件转换成一段 HTML 字符串;之后,它会把当前 store 里的所有状态变成 JSON 格式,并塞进这段 HTML 里面;最后,当用户的浏览器拿到这个 HTML 并加载完 JavaScript 后,客户端代码会读取之前塞进去的状态,重新创建一个一模一样的 store,并“接上”已经存在的 HTML,让页面变得可以交互。
工程化代码实现
项目目录结构设计
src/
├── client/ # 客户端入口与配置
│ ├── index.js
│ └── store.js
├── server/ # 服务端逻辑
│ ├── index.js
│ └── store.js
├── shared/ # 前后端共享模块
│ ├── App.js
│ ├── store/
│ │ ├── index.js
│ │ └── userSlice.js
│ └── api.js
└── template.js # 动态 HTML 模板生成器共享状态管理模块
// src/shared/store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 定义异步操作:获取用户信息
export const fetchUserData = createAsyncThunk(
'user/fetchUserData',
async (userId) => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
return response.json();
}
);
const userSlice = createSlice({
name: 'user',
initialState: {
profile: null,
isLoading: false,
fetchError: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserData.pending, (state) => {
state.isLoading = true;
state.fetchError = null;
})
.addCase(fetchUserData.fulfilled, (state, action) => {
state.isLoading = false;
state.profile = action.payload;
})
.addCase(fetchUserData.rejected, (state, action) => {
state.isLoading = false;
state.fetchError = action.error.message;
});
}
});
export default userSlice.reducer;// src/shared/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
// 用来创建带初始状态的 store
export const instantiateStore = (initialState = {}) => {
return configureStore({
reducer: {
user: userReducer
},
preloadedState: initialState
});
};主应用组件定义
// src/shared/App.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUserData } from './store/userSlice';
const UserProfileView = ({ userIdFromProps }) => {
const dispatch = useDispatch();
const { profile, isLoading, fetchError } = useSelector(state => state.user);
// 客户端加载后,如果没数据就再请求一次
useEffect(() => {
if (userIdFromProps && !profile) {
dispatch(fetchUserData(userIdFromProps));
}
}, [dispatch, userIdFromProps, profile]);
if (isLoading) return <div>正在加载用户资料...</div>;
if (fetchError) return <div>加载失败:{fetchError}</div>;
return (
<div>
<h1>用户档案</h1>
{profile && (
<div>
<p>姓名:{profile.name}</p>
<p>电子邮箱:{profile.email}</p>
<p>所属企业:{profile.company?.name}</p>
</div>
)}
</div>
);
};
export default UserProfileView;服务端逻辑实现
// src/server/store.js
import { instantiateStore } from '../shared/store';
import { fetchUserData } from '../shared/store/userSlice';
// 每次请求都新建一个 store
export const prepareServerStore = async (requestedUserId) => {
const freshStore = instantiateStore();
if (requestedUserId) {
// 等数据加载完再继续
await freshStore.dispatch(fetchUserData(requestedUserId)).unwrap();
}
return freshStore;
};// src/server/index.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import UserProfileView from '../shared/App';
import { prepareServerStore } from './store';
import { generateHtmlDocument } from '../template';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.static('dist'));
app.get('/user/:id', async (req, res) => {
try {
const targetUserId = req.params.id;
// 第一步:建 store 并加载数据
const serverStore = await prepareServerStore(targetUserId);
const serializedState = serverStore.getState();
// 第二步:把组件转成 HTML
const renderedApp = renderToString(
<Provider store={serverStore}>
<UserProfileView userIdFromProps={targetUserId} />
</Provider>
);
// 第三步:拼完整的 HTML 页面
const finalHtml = generateHtmlDocument(renderedApp, serializedState);
res.send(finalHtml);
} catch (err) {
console.error('服务端渲染异常:', err);
res.status(500).send('服务器内部错误');
}
});
// 兜底路由:其他路径都返回基础 HTML
app.get('*', (req, res) => {
const fallbackHtml = generateHtmlDocument('<div id="root"></div>', {});
res.send(fallbackHtml);
});
app.listen(PORT, () => {
console.log(`服务已启动,监听端口 ${PORT}`);
});// src/template.js
export const generateHtmlDocument = (appContent, reduxState) => {
// 防止 XSS:把 < 转成安全字符
const safeStateString = JSON.stringify(reduxState).replace(/</g, '\\u003c');
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于 Redux 的 SSR 应用</title>
</head>
<body>
<div id="root">${appContent}</div>
<script>
window.__PRELOADED_REDUX_STATE__ = ${safeStateString};
</script>
<script src="/client.bundle.js"></script>
</body>
</html>
`;
};客户端初始化流程
// src/client/store.js
import { instantiateStore } from '../shared/store';
// 从 window 里拿服务器给的状态
const initialStateFromServer = window.__PRELOADED_REDUX_STATE__ || {};
// 创建客户端的 store
export const clientSideStore = instantiateStore(initialStateFromServer);
// 清掉 window 上的变量,避免内存问题
delete window.__PRELOADED_REDUX_STATE__;// src/client/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import UserProfileView from '../shared/App';
import { clientSideStore } from './store';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<Provider store={clientSideStore}>
<UserProfileView />
</Provider>
);关键技术挑战与应对策略
状态一致性保障
为了让客户端顺利“接上”页面,前后端的状态必须完全一样,具体做法是:服务端先把 store 里的状态用 JSON.stringify 转成字符串,然后把它写进 HTML 的 <script> 标签里;客户端加载后,从 window 对象里读出这个字符串,再用它来初始化自己的 store,这样两边的状态就对上了。
数据预取完整性
所有首屏要用到的数据都必须在服务端提前加载好,否则客户端水合时会发现内容对不上。我们通常会用 await store.dispatch(action).unwrap() 来确保异步操作真正完成后再进行下一步。
水合过程注意事项
水合成功的关键在于服务端输出的 HTML 和客户端第一次渲染的结果必须一字不差,因此不能在组件的渲染逻辑里直接使用 window、document 这些只在浏览器里才有的对象,也不能用 Math.random() 或 new Date() 这类会产生不同结果的函数;所有只在客户端运行的代码,比如绑定事件或启动定时器,都应该放在 useEffect 里面。
性能调优建议
如果用了 React.lazy 做代码分割,要注意它在服务端不会生效,因为服务端没法动态加载模块;这时候可以加一个状态判断,只在组件挂载到页面后才显示懒加载的内容,避免服务端和客户端输出不一致。
典型问题诊断与解决方案
水合不匹配异常
当控制台报 “Text content did not match” 错误时,说明服务端和客户端生成的 HTML 有差别;解决办法包括避免在渲染函数中使用随机数或当前时间,统一时间格式,或者在确实无法避免差异的地方加上 suppressHydrationWarning={true} 属性临时跳过检查。
异步依赖管理
处理多个数据请求时,如果它们彼此无关,可以用 Promise.all() 并行发起以节省时间;如果有依赖关系,就按顺序 await;更规范的做法是在路由层面声明每个页面需要哪些数据,由框架统一协调加载。
样式渲染闪烁
如果用了 CSS-in-JS 库,但样式在服务端没被收集,就会导致页面先无样式再突然变样;解决方法是选用支持 SSR 的样式方案,并在服务端调用对应的样式提取 API,把关键 CSS 内联到 HTML 的 <style> 标签中。
现代化替代架构探讨
虽然自己从头实现 SSR 能帮助理解底层原理,但在真实项目中,通常建议直接使用 Next.js、Remix 或 Astro 这类成熟的全栈框架,因为它们已经内置了 SSR 支持、数据加载、路由、静态生成等能力,能大幅减少重复工作并避免常见陷阱。
// Next.js 里用 Redux 的例子
export async function getServerSideProps({ params }) {
const store = instantiateStore();
await store.dispatch(fetchUserData(params.id));
return {
props: {
initialReduxState: store.getState()
}
};
}结论
通过完整可运行的代码,展示了如何在 React 和 Redux 项目中实现服务端渲染。
要让 SSR 正常工作,核心是保证状态同步、数据预加载完整、HTML 输出一致,并做好错误处理。
尽管手动实现提供了最大灵活性,但考虑到开发效率和长期维护,大多数团队更适合选择 Next.js 这样的现代框架,它既简化了 SSR 的使用,又保留了足够的控制力。