React SSR 与 Hydration:Next.js 中的实践与坑点

在浏览器里直接 createRoot 渲染是一种方式,把 HTML 先在服务端渲染好,再在客户端接管,是另一种。
后者能带来更好的首屏和 SEO,但也多出一整套需要小心的细节。

先把几个常混在一起的名词拆开:

  • SSR(Server-Side Rendering)

    • 每次请求都在服务端跑一遍 React 渲染,返回完整 HTML;
    • 客户端再做一次 Hydration,把事件和状态接上。
  • SSG(Static Site Generation)

    • 在构建阶段就把页面预先渲染成静态 HTML 文件;
    • 部署后直接从 CDN/静态服务器返回,客户端照样做 Hydration。
  • ISR(Incremental Static Regeneration)

    • 本质是「带缓存和过期时间的 SSG」;
    • 页面第一次访问时按 SSG 的方式生成,后续在一定时间内复用,过期再增量刷新。

这三种方式可以混用:有的页面需要实时数据(SSR),有的完全可以静态化(SSG/ISR)。

Hydration 做的事不是「重新渲染 HTML」,而是:

  • 以现有的 HTML 结构为基础;
  • 在客户端构建对应的 Fiber 树;
  • 把事件监听器等挂上去;
  • 让 React 控制这块 DOM 子树。

如果服务端输出的 HTML 和客户端首次渲染结果不一致,就会出现「水合不匹配」的警告,严重时还会导致布局跳动或事件行为异常。

常见导致不一致的原因包括:

  • 使用了 Date.now()Math.random() 一类在服务端和客户端结果不同的表达式;
  • 依赖 windowdocument 等浏览器对象的逻辑在服务端无法执行;
  • 条件渲染依赖「只在浏览器才知道」的信息(如 viewport 尺寸、localStorage)。

比较稳妥的做法是:

  • 服务端渲染阶段,尽量让首屏结构稳定可预测;
  • 把「和浏览器环境强绑定」的逻辑延后到客户端 effect 里。

以 Next.js(老的 pages 目录模式)为例,常见的几种方式:

  • getServerSideProps:每次请求都跑一次,典型 SSR;
  • getStaticProps:构建时生成,典型 SSG;
  • getStaticProps + revalidate:带过期时间的 SSG,即 ISR。

简单例子:

 1// pages/posts/[id].tsx
 2type Post = { id: string; title: string; content: string };
 3
 4export async function getServerSideProps(context: any) {
 5  const { id } = context.params;
 6  const res = await fetch(`https://api.example.com/posts/${id}`);
 7  const post: Post = await res.json();
 8
 9  return { props: { post } };
10}
11
12export default function PostPage({ post }: { post: Post }) {
13  return (
14    <article>
15      <h1>{post.title}</h1>
16      <div>{post.content}</div>
17    </article>
18  );
19}

这个页面每次打开都会从服务端拉一次最新数据,并在服务端渲染 HTML。

在 Next.js 的 App Router 模式下,组件分成两类:

  • 默认是服务端组件:可以直接访问后端资源(数据库/API),不能使用浏览器相关 API;
  • 标记为 'use client' 的客户端组件:可以使用 useStateuseEffect 等 Hook,可以访问浏览器 API。

典型结构是:

1// app/page.tsx
2import { fetchPosts } from '@/lib/api';
3import PostsList from './PostsList';
4
5export default async function Page() {
6  const posts = await fetchPosts(); // 服务端直接拉数据
7  return <PostsList posts={posts} />;
8}
1// app/PostsList.tsx
2'use client';
3
4export default function PostsList({ posts }: { posts: any[] }) {
5  const [selected, setSelected] = useState<string | null>(null);
6  // 客户端交互逻辑
7}

这种拆分方式的一个直接效果是:
把「拿数据」和「交互」硬性分开,减轻了客户端需要承担的工作量

在服务端渲染阶段,没有 windowdocumentlocalStorage 等对象。
如果组件在初次渲染时直接访问这些对象,会直接报错。

处理方式一般是:

  • 访问这些对象的逻辑放进 useEffect 里,只在客户端执行;
  • 或者在使用前做环境判断,例如:
1const isBrowser = typeof window !== 'undefined';

如果直接在 JSX 里使用 Math.random()Date.now(),服务端和客户端的结果不一致,Hydration 就会产生警告。

可以改成:

  • 在数据层(如 getServerSideProps)里算好,作为 props 传入;
  • 或者仅在客户端 effect 里更新。

在 SSR 场景下,全局状态需要考虑「每个请求一份」:

  • 不要把请求相关的状态挂在一个跨请求共享的单例上;
  • 对于 Redux、Zustand 等,需要在每个请求里创建一个新的 store 实例;
  • 把初始状态注入到 HTML 里,客户端再用这份状态 hydrate。

Next.js 已经帮忙处理了不少这一层的细节,但当你自己写 Node 服务来配合 SSR 时,这一点尤其需要注意。

SSR/SSG 带来的主要收益有:

  • 首屏 HTML 完整,可被搜索引擎抓取;
  • 用户不用等所有 JS 下载完就能看到内容。

但代价是:

  • 服务器需要承担更多渲染计算;
  • 部分交互要延后到 Hydration 之后才能工作;
  • 开发时需要时刻区分「服务端代码」和「客户端代码」。

一条比较实用的策略是:

  • 内容型/着陆页/文档页:优先考虑 SSG/ISR;
  • 纯后台管理或强交互应用:优先考虑 CSR + 局部 SSR,而不是全站强上。
  • SSR/SSG/ISR 是同一条轴线上的不同点,区别在于「在什么时候生成 HTML、多久刷新一次」。
  • Hydration 的目标是「接管现有 DOM」,服务端输出和客户端渲染保持一致是前提。
  • 在 Next.js 里,服务端组件负责拿数据,客户端组件负责交互,把这两部分拆开有利于性能和结构。
  • 使用浏览器限定 API、随机数、时间戳以及全局状态时,要特别留意它们在服务端和客户端的行为是否一致。