同构应用设计深度解析
同构应用的核心三件事:数据脱水注水、客户端激活、渲染一致性,以及面试最爱问的 Hydration Mismatch。
一句话概括
同构应用(Isomorphic/Universal)指同一套代码既跑在服务端又跑在客户端:服务端先渲染出 HTML 给浏览器(解决 SEO 和首屏白屏),客户端再”接续”这份 HTML 把它变成可交互的 SPA。它背后只有三个核心问题——数据怎么传过去(脱水/注水)、静态 HTML 怎么变活(Hydration)、两端渲染怎么保证一致(Mismatch 处理)。Next.js、Nuxt、Remix 的底层都是这一套,理解它等于掌握了现代全栈框架的内功。
核心知识点
1. 数据脱水与注水:服务端拿到的数据怎么交给客户端
服务端渲染时拿到了用户数据,但客户端 JS 启动后拿不到服务端内存里的东西,于是把数据序列化成 JSON 塞进 HTML,客户端读出来恢复成状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ===== 服务端:脱水 =====
import { renderToString } from 'react-dom/server';
const state = { user: { id: 1, name: '张三' }, posts: [...] };
const appHtml = renderToString(<App state={state} />);
// 关键:转义 < > & 防止 </script> 提前闭合导致 XSS
const safeState = JSON.stringify(state)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
const html = `<!DOCTYPE html>
<html><body>
<div id="root">${appHtml}</div>
<script>window.__INITIAL_STATE__ = ${safeState};</script>
<script src="/bundle.js"></script>
</body></html>`;
// ===== 客户端:注水 =====
const state = window.__INITIAL_STATE__;
delete window.__INITIAL_STATE__; // 读完即删,防止被 XSS 脚本窃取
hydrateRoot(document.getElementById('root'), <App state={state} />);
两个易错点:必须 XSS 转义(否则用户昵称里带 </script><script>... 直接被执行),读完即删(数据里可能有 token,留在全局对象是安全隐患)。
2. 客户端激活(Hydration):静态 DOM 怎么”变活”
Hydration 不是重新渲染,而是复用服务端已有的 DOM 节点,只补上事件绑定和内部状态。它的核心假设是:客户端渲染结果必须和服务端完全一致,否则 React 会逐节点比对发现对不上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 服务端和客户端渲染结果不一致 → Hydration Mismatch
function Clock() {
// 服务端:00:00:00;客户端:真实时间 → 对不上!
return <div>{new Date().toLocaleTimeString()}</div>;
}
// ✅ 正确姿势:初始渲染用两端一致的确定性值,客户端再更新
function Clock() {
const [time, setTime] = useState('');
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const id = setInterval(() => setTime(new Date().toLocaleTimeString()), 1000);
return () => clearInterval(id);
}, []);
return <div>{time || '加载中'}</div>; // 服务端和客户端首次渲染都是"加载中"
}
凡是依赖 window、Date.now()、Math.random()、localStorage 的值,都会导致两端不一致。规则就一条:渲染阶段必须确定性,副作用放 useEffect。
3. 差异处理:三个最常见的 Mismatch 来源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 来源 1:浏览器 API(window / document)在服务端不存在
// ❌ const width = window.innerWidth; // 服务端直接 ReferenceError
// ✅ typeof 守卫 + 默认值
const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
// 来源 2:随机值
// ❌ const id = Math.random(); // 两端结果不同
// ✅ 客户端再生成
const [id, setId] = useState('');
useEffect(() => setId(Math.random().toString(36).slice(2)), []);
// 来源 3:第三方库只在浏览器跑
// Next.js 用 dynamic(..., { ssr: false }),Nuxt 用 <ClientOnly>
// 或者首帧渲染空容器,useEffect 里再挂载
useEffect(() => { initChart(ref.current, data); }, [data]);
return <div ref={ref} />;
React 18 之后遇到 mismatch 会丢弃服务端 DOM 整棵重渲染并打印 warning,代价是首屏性能回退,所以别指望”反正能跑”。
4. 同构状态管理:为什么 Store 必须按请求隔离
服务端是并发处理多个请求的,如果用单例 Store,A 用户的数据会串到 B 用户页面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 模块级单例:多个请求共享同一个 store,数据串号
// let store = createStore(reducer);
// ✅ 工厂函数:每个请求 create 一次,返回独立实例
export function createStore(preloadedState) {
return configureStore({ reducer, preloadedState });
}
// 服务端每次请求:
const store = createStore();
await loadPageData(store.dispatch, req); // 按请求取数据
const state = store.getState(); // 脱水
// 客户端:
const store = createStore(window.__INITIAL_STATE__); // 注水恢复
这也是 Redux 官方一直强调”不要在模块顶层创建 store,要暴露工厂函数”的原因。
5. 渐进式 / 局部水合:只激活需要交互的部分
全量 Hydration 的痛点是:页脚、静态文案这些永远不交互的节点也白绑一遍事件。进阶方案是按需激活:
1
2
3
4
5
6
7
8
9
10
11
// 思路:给需要交互的区块打标记,进入视口才激活
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
hydrateRoot(entry.target, <InteractiveWidget />);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('[data-hydrate]').forEach((el) => observer.observe(el));
React 18 的 <Suspense> + Selective Hydration、Astro 的岛屿架构(Islands)、Qwik 的可恢复性(Resumability)都是这一思想的落地,方向是”能不激活就不激活,能晚激活就晚激活”。
其实你每天都在用
- 刷知乎/小红书:你看到的首屏内容来自服务端直出的 HTML(秒开),往下滚动时触发的点赞、评论是客户端激活后的 JS 在跑
- 电商详情页:商品标题、价格、主图是服务端渲染(利于 SEO 和分享卡片),”加入购物车”按钮是局部水合后才可点的交互区
- 分享链接到微信:点开标题、缩略图、摘要能正确显示,正是因为服务端渲染了完整 HTML,爬虫才能抓到这些 meta
- Next.js/Nuxt 项目里的
window is not defined报错:几乎都是组件顶层直接用了浏览器 API,属于同构思维缺失的典型现场 - 登录后页面刷新不丢数据:服务端从 cookie 认出了你,把用户信息脱水到
__INITIAL_STATE__,客户端注水后直接渲染你的头像昵称
常见误解(FAQ)
❌ 误区一:”SSR 就是服务端渲染,跟 CSR 是互斥的”
同构应用恰恰是两者都要:服务端负责首屏 HTML(SEO + 快),客户端 Hydration 后接管交互(SPA 体验)。SSR 和 CSR 不是二选一,而是同一份代码在两个环境各干一段。纯粹只有 SSR 没有 Hydration 的页面,加载完是”死”的,点了没反应。
❌ 误区二:”Hydration Mismatch 只是控制台警告,不影响功能”
React 18 遇到 mismatch 会放弃复用服务端 DOM,重新在客户端渲染整个子树,等于首屏 SSR 白做了,还会引起页面闪烁。更隐蔽的是 React 16 及以前可能静默留下不一致 DOM,用户看到的是 A、事件绑定的是 B,产生诡异 bug。生产环境应把 mismatch 当错误处理。
❌ 误区三:”脱水数据越多越好,客户端就不用再请求了”
脱水数据会直接增大 HTML 体积,拖慢首字节和解析。正确做法是选择性注水:只序列化客户端渲染必需的最小字段,脱敏字段(密码、token、身份证)一律剔除,敏感数据让客户端登录后再单独请求。注水数据是”必要的最小集”,不是”全部数据”。
❌ 误区四:”同构 = 代码能在两端跑,把 window 用 typeof 包起来就够了”
typeof 守卫只是最浅的一层。真正的同构还要处理:两端时间戳一致性、第三方库 SSR 兼容性、CSS-in-JS 样式两端提取、数据序列化边界(Date、Map、BigInt 会被 JSON.stringify 破坏)。只做 typeof 守卫的项目,上线后还会被各种隐性 mismatch 教做人。
一句话总结
同构应用的本质是让同一份代码在两个执行环境里讲同一个故事——服务端先给结论(HTML),客户端再补上交互,而脱水注水、Hydration、一致性处理这三板斧,就是保证这个故事前后不打架的关键。