ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Next.js SSR 实战:从数据流到水合避坑与性能优化

Next.js SSR 实战:从数据流到水合避坑与性能优化

SSR(服务器端渲染)这几年在 React 圈子里已经不算新词了,但说实话,很多人在面试时能背出“SSR 利于 SEO、加快首屏”这样的结论,真到了项目里,碰到服务端拿不到window、水合报错、接口响应慢导致整个页面超时这些问题,就完全抓瞎。我最近正好把一个内容型展示系统从纯 SPA 迁移到了 Next.js 的 SSR 方案,整个过程踩了不少坑,也把 React 服务器端渲染的完整链路重新梳理了一遍。这篇东西就围绕 Next.js 来拆:SSR 到底在服务端做了什么、客户端又做了什么、数据是怎么流动的,以及在实战里哪些环节最容易翻车。适合写过 SPA 但没系统接触过 SSR 的开发者,也适合已经在用 Next.js、却经常被“服务端/客户端边界”困扰的朋友。

先放下工具层面的细节,从最底层的问题聊起。

1. SSR 底层推演:SPA 的两个致命短板

1.1 空壳 HTML 带来的首屏和 SEO 困境

纯客户端渲染模式下,服务器返回给浏览器的 HTML 非常单薄,基本就是<div id="root"></div>加一串脚本链接。页面能不能显示出来,完全取决于浏览器能不能把 JS 下载完、解析完、执行完。在高速宽带上,这个时间差可能只有几百毫秒,体验不明显;但一旦换到弱网环境,或者像一些低端手机,白屏时间会被拉长到好几秒,用户根本等不到内容出现就划走了。

我习惯用一个类比来理解这件事:SPA 相当于一套毛坯房,钥匙(JS bundle)到了之后才开始装修;SSR 则是施工队在服务端就把墙刷好、家具摆好,浏览器一打开就能住。首屏能不能尽快“住进去”,直接影响跳出率和用户留存。

SEO 是另一个硬约束。虽然说现在的搜索引擎爬虫大多能执行 JavaScript,但执行成本高,抓取结果也不如纯 HTML 稳定。对内容型站点来说,标题、正文、关键结构能不能直接在 HTML 里被看到,直接关系到收录质量。这也是为什么电商详情页、新闻站、营销落地页几乎都要用 SSR 或静态生成的原因。

1.2 服务器端渲染的本质:在 Node 里把 React 跑一遍

SSR 的核心思路不复杂:React 本身只是一个 UI 库,组件函数既可以跑在浏览器里,也可以跑在服务器上。服务器端渲染,就是在请求到达时,由 Node 环境执行组件逻辑、拿到数据,然后把组件树渲染成 HTML 字符串,直接随着响应返回给浏览器。浏览器拿到的是完整可见的页面,后续再通过 JavaScript 接管交互能力。

这个过程比听起来要复杂,因为你要处理两个运行时环境:Node 和浏览器。同一个组件文件,在服务器上执行的时候没有window、document、localStorage,在客户端执行的时候又全都存在。框架要解决的核心问题,就是让同一套代码在两个环境下都能正确运行,并且最终产出的 HTML 和客户端首次渲染结果保持一致。这个“一致性”是 SSR 能不能稳定工作的命门,后面我会专门讲水合问题。

2. Next.js 服务端渲染的完整链路与核心机制

2.1 getServerSideProps:数据在服务端先跑一趟

Next.js 早期最有代表性的 SSR 入口是 Pages Router 里的getServerSideProps。这个函数很直观:每次请求到达页面时,Next.js 会先执行它,把返回结果作为props传给页面组件,再在服务端完成组件渲染。整个流程里,数据获取发生在服务端,而不是浏览器端,这是 SSR 和 CSR 最大的行为差异。

举个例子,做一个商品详情页/product/[id]的 SSR 版本,最核心的代码是这样的:

import type { GetServerSideProps, InferGetServerSidePropsType, } from "next"; type Product = { id: string; name: string; description: string; price: number; }; export const getServerSideProps: GetServerSideProps<{ product: Product; }> = async (context) => { const { id } = context.params ?? {}; const baseUrl = process.env.CONTENT_API_BASE_URL; const res = await fetch(`${baseUrl}/products/${id}`); if (!res.ok) { return { notFound: true }; } const product = (await res.json()) as Product; return { props: { product } }; }; export default function ProductPage({ product, }: InferGetServerSidePropsType<typeof getServerSideProps>) { return ( <main> <h1>{product.name}</h1> <p>{product.description}</p> <strong>{product.price} 元</strong> </main> ); }

这里有几个细节值得展开。第一,getServerSideProps只在服务端执行,所以可以放心放置访问数据库、调用内部接口的逻辑,不会暴露给浏览器。第二,每次页面请求都会触发这个函数,所以它的性能直接决定服务端响应时间(TTFB),如果数据接口慢,页面就跟着慢,没有缓冲可言。第三,context对象里有params、query、req、res等字段,可以拿到完整的请求上下文,比如根据请求头做设备判断、写 cookie,这些都是 CSR 模式很难直接做到的。

2.2 水合(Hydration):客户端如何接管服务端产出的 HTML

服务端返回完整 HTML 只是第一步,页面还不能交互。用户点击按钮需要响应,事件要能绑定到 DOM 上,这才是真正的“活页面”。Next.js 的做法是:在服务端渲染的同时,把组件的初始数据序列化到 HTML 里的一个脚本标签中(早期版本是__NEXT_DATA__),浏览器加载页面后,会下载对应的 JS bundle,在客户端再渲染一次同一个组件树,React 会把这次渲染的结果和服务器生成的 DOM 一一对比,然后在不重建 DOM 的前提下,把事件处理器绑定上去,这个过程就叫水合。

理解水合有两个关键点。第一,客户端会重新执行一次组件渲染,目的是获取事件绑定关系,而不是为了重新生成 DOM;第二,如果这次客户端渲染的结果和服务端生成的 HTML 不一样,React 会报水合不匹配的警告,并且可能直接采用客户端结果,导致页面闪烁甚至交互异常。所以,SSR 项目的黄金准则是:服务端和客户端首次渲染输出必须严格一致。

2.3 App Router 的演进:Server Components 改变了 SSR 的形态

如果你用的是 Next.js 13+ 的 App Router,SSR 的形态会有些变化。在新的路由模型里,Next.js 默认把组件视为 Server Components,这些组件只在服务端运行,不会把自身逻辑打包发送给浏览器,所以想用useState、useEffect这类客户端能力,必须显式标记"use client"。服务端组件可以直接读取数据库、访问文件系统,并且支持async函数,这减少了像getServerSideProps那样单独分层取数的需要。

但底层链路并没有本质改变:服务端组件负责产出 HTML,客户端运行时负责水合与交互。变化的是“哪些代码被服务端执行、哪些被客户端执行”的边界。Pages Router 是页面级 SSR,App Router 把边界下沉到了组件级,这让局部动态渲染成为可能,也带来了新的性能优化空间。

3. 实战手记:用 Next.js 实现 SSR 页面,从数据预取到水合

3.1 场景设计与目录结构

我先用一个模拟场景来演示完整过程:做一个内容站的“活动详情页”,内容会频繁更新,无法在构建时生成静态页面,所以必须走 SSR。页面需要展示活动名称、报名人数、活动状态,还会根据用户是否登录显示不同按钮,这是一个非常典型的“每个请求数据都可能变化”的页面。

项目采用 Pages Router 结构,虽然不是最新形态,但它把 SSR 的数据获取路线演示得最直观,理解之后迁移到 App Router 也很容易:

pages/ activity/ [id].tsx _app.tsx lib/ api.ts components/ ActivityCard.tsx

3.2 服务端数据获取与页面渲染实现

在lib/api.ts里封装活动数据接口:

export type Activity = { id: string; title: string; startTime: string; registeredCount: number; status: "ongoing" | "ended"; }; export async function getActivityById( id: string, ): Promise<Activity | null> { const baseUrl = process.env.CONTENT_API_BASE_URL; try { const res = await fetch(`${baseUrl}/activities/${id}`); if (!res.ok) { return null; } return (await res.json()) as Activity; } catch { return null; } }

然后是页面组件pages/activity/[id].tsx:

import type { GetServerSideProps } from "next"; import { getActivityById, type Activity } from "@/lib/api"; type Props = { activity: Activity; now: string; isLoggedIn: boolean; }; export const getServerSideProps: GetServerSideProps<Props> = async (context) => { const { id } = context.params ?? {}; const activity = await getActivityById(String(id)); if (!activity) { return { notFound: true }; } const cookie = context.req.headers.cookie ?? ""; const isLoggedIn = cookie.includes("session_token"); // 当前时间在服务端生成,序列化成字符串交给客户端 const now = new Date().toISOString(); return { props: { activity, now, isLoggedIn, }, }; }; export default function ActivityPage({ activity, now, isLoggedIn }: Props) { return ( <section> <h1>{activity.title}</h1> <p>状态:{activity.status === "ongoing" ? "进行中" : "已结束"}</p> <p>已报名:{activity.registeredCount} 人</p> <p>服务器当前时间:{now}</p> {isLoggedIn ? ( <button type="button">报名活动</button> ) : ( <button type="button">登录后报名</button> )} <style jsx>{` section { max-width: 640px; margin: 0 auto; padding: 24px; } `}</style> </section> ); }

这段代码包含了三个关键设计:

前端时间显示用服务端生成的now字符串,而不是在组件里直接调用new Date(),因为后者会导致服务端渲染的 HTML 和客户端首次渲染的时间不一致,触发水合报错。

登录状态通过读取请求头里的 cookie 判断,这在纯客户端渲染里必须等页面加载后才能知道,而 SSR 可以直接在首屏 HTML 里输出正确的按钮状态。

getActivityById内部做了异常捕获,接口挂了就返回null,然后走notFound,不至于让整站白屏或返回 500。

3.3 构建与部署的配置要点

SSR 项目在打包部署时,最容易忽略的是 Node 服务的最小环境要求。你本地开发时npm run dev没问题,生产环境如果只托管在静态文件服务器上,SSR 页面全部无法工作,因为getServerSideProps需要一个 Node.js 运行时来执行。部署时要把 Next.js 当作一个 Node 服务来跑,而不是把out目录扔给 Nginx。

我的配置文件里有一个比较实用的点,就是区分内部 API 地址和外部 API 地址。控制端、管理端走内网域名,对公网完全不可达,这样既避免绕一圈公网再回来,也减少了一层 DNS 解析开销。如果服务部署在同一台机器上,甚至可以直接用进程内存通信。这个优化对 SSR 接口响应时间影响非常明显,建议在环境变量里尽早做区分。

3.4 在 App Router 里如何实现同类需求

如果你在新项目里使用 App Router,实现同样需求的方式会有一点不同,但骨架是相似的:

// app/activity/[id]/page.tsx import { getActivityById } from "@/lib/api"; export default async function ActivityPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const activity = await getActivityById(id); if (!activity) { return <div>活动不存在</div>; } return ( <section> <h1>{activity.title}</h1> <p>已报名:{activity.registeredCount} 人</p> </section> ); }

服务端组件天然支持async,不再需要额外导出一个getServerSideProps函数,数据获取和组件渲染直接融合在一起。但要注意,App Router 模式下,页面文件默认是 Server Component,不可以用useState,如果要加交互逻辑,必须把交互部分抽成独立的客户端组件,再引入到服务端组件里。

4. SSR 排雷手册:我碰到的高频问题与解决套路

4.1 window is not defined,别再一棍子打死

这是 SSR 初学者最容易撞上的报错。原因很简单:Node 环境里根本没有浏览器对象。组件在服务端渲染时访问了window,就必然报错。但我发现实际项目里,这个报错背后的场景有很多种,处理方式也要区分:

场景推荐处理
在事件回调里访问document事件回调本来只在客户端执行,可以保留,但要确保不在渲染阶段直接调用
在组件顶层计算时读window.innerWidth把读取放到useEffect里,客户端挂载后再更新状态
第三方图表/地图组件依赖浏览器 API使用动态导入并关闭 SSR,ssr: false
需要根据设备类型做首屏渲染通过req.headers里的 User-Agent 做服务端判断,而不是读window

动态导入的做法很实用:

import dynamic from "next/dynamic"; const Chart = dynamic(() => import("@/components/Chart"), { ssr: false, });

这样图表组件只会在客户端加载,不会在服务端执行,从根上避免报错。

4.2 水合不匹配:服务端和客户端渲染结果为什么对不上

水合不匹配是 SSR 项目里最隐蔽的坑。表面上看报错可能只是一条 console warning,但实际后果可能是按钮事件丢失、页面内容闪烁,甚至整个交互组件重新挂载。我遇到过的典型原因有三种:

第一种是直接把Date.now()、Math.random()写进了渲染逻辑。服务端渲染拿到的值和客户端首屏渲染的值不一致,React 对不上就报警告。解决方法是把这类动态值放到useEffect里再设置,或者像前面示例一样由服务端在getServerSideProps里生成好,序列化传给客户端。

第二种是数据源在服务端和客户端表现不一致。比如从 localStorage 初始化状态,服务端没有 localStorage,自然拿不到值,客户端却读到了,结果就分叉了。这种情况要把初始化逻辑挪到客户端 effect 阶段。

第三种是 props 里传了不可序列化的对象,比如 Date 实例、函数、Set、Map。服务端序列化时这些值会丢失原型或直接报错。SSR 的 props 必须坚守 JSON 可序列化这个底线,日期转成字符串,复杂对象拍平成普通对象。

4.3 服务端接口慢:上游一个抖动,整页超时

这是 SSR 在生产环境最头疼的问题。CSR 模式里,页面框架先出来,数据接口再慢慢加载,用户至少有骨架屏可看;但 SSR 模式下,getServerSideProps必须在响应 HTML 之前把所有数据拿回来,上游某一个接口耗时超过 2 秒,整页 TTFB 就超过 2 秒,用户看到的一直是白屏。

排查时我先做一段日志埋点,记录每个上游接口的耗时,再逐个分析。常见问题有三个:

  • 串行请求:多个接口一个个await,总耗时是累加的。应该改成Promise.all并发请求。
  • 缺少超时控制:Node 内置fetch默认可以等很久,上游挂起时服务端线程一直占着。给每个接口调用加上超时,比如用AbortSignal.timeout(3000)。
  • 无法容忍的慢接口:如果某个接口就是需要 3 秒才能出结果,那就得考虑降级方案。比如先渲染页面主框架,慢数据区域在客户端异步加载。

Promise.all改造很简单:

export const getServerSideProps: GetServerSideProps = async (context) => { const { id } = context.params ?? {}; const [activity, user] = await Promise.all([ getActivityById(String(id)), getUserByCookie(context.req.headers.cookie), ]); return { props: { activity, user }, }; };

4.4 代理服务器和 CDN 缓存配置不当

SSR 页面的响应头里如果没有明确的缓存策略,CDN 或反向代理可能会默认缓存 HTML,也可能完全不缓存,两种结果都很糟糕。前者会导致后面登录的用户看到上一个用户的私有内容,后者会让每个请求都打到源服务器,压力全压在 Node 上。

我的做法是:公开活动页设置短缓存,像s-maxage=10, stale-while-revalidate=60,意思是在 CDN 层缓存 10 秒,过期后先返回旧内容再异步刷新。涉及用户画像的页面直接设置Cache-Control: private, no-store,避免内容串号。静态资源类的页面则尽量走 SSG 或 ISR,根本不进 SSR 这条路。

5. SSR 性能优化与工程化经验

5.1 缓存策略:不是所有页面都需要实时渲染

很多人一提到 SSR,就默认每个请求都要在服务端现算一次,这是最大的性能误区。实际上,一个内容型站点里真正需要每秒钟都为不同用户生成不同内容的页面极少。大多数页面数据更新频率是分钟级、小时级,完全可以用缓存挡掉大部分上游压力。

我实践下来比较好用的分层方案是:

层级适合场景实现方式
页面级静态生成(SSG)内容几乎不变,比如帮助文档、静态活动规则构建时生成 HTML
增量静态生成(ISR)数据偶尔变化,更新频率分钟级以上设置revalidate,按需重新生成
动态 SSR + 内存缓存数据变化较快,但间隔期内容相同在getServerSideProps里查缓存,命中则直接返回
纯动态 SSR每个用户内容都不同,比如登录后的个性化页面不缓存页面,只缓存单次请求结果

其中 ISR 是一个性价比非常高的方案。它在构建时生成静态页面,然后以revalidate为周期,在过期后的首次请求时异步触发重新生成。用户拿到的始终是一次完整渲染过的页面,但源服务器的渲染次数被压缩到很低。

5.2 组件级 SSR 与渲染区间的拆分

做 SSR 优化时,要避免“全页面同步渲染”的思路。如果一个页面里只有某个区域需要实时数据,其他区域是长期不变的,那不值得把整个页面变成动态 SSR。Next.js 可以并行使用多种渲染策略:营销区走静态生成,评论区走客户端异步加载,报名入口这种需要最新状态的部分单独抽成组件做服务端渲染。

在 Pages Router 里,你可以把独立请求数据的部分抽成服务端渲染接口,页面主体走静态生成,数据区在客户端请求一个带渲染能力的接口。在 App Router 里就更直接,因为 Server Component 是组件级的,你可以在单个页面里混合静态区域和动态区域,Next.js 会根据await的情况自动决定哪些部分阻塞渲染、哪些部分按流式输出。

5.3 流式渲染与 Suspense

App Router 还带来了一个我很看重的特性:流式渲染。在传统的整页 SSR 里,服务器必须把整个页面渲染完才发送响应,耗时取决于最慢的那块数据。有了 Suspense 和流式渲染,页面可以先发送一个包含骨架结构的 HTML,等某块异步组件数据就绪后再补充发送实际内容。用户感知上的首屏时间大大缩短,不再被最慢的短板卡住。

不过流式渲染对部署有要求,需要 Node 服务支持 streaming response,不能用普通静态托管,反向代理层也要注意是否缓冲了响应体。如果代理强制缓冲,流式的体验就完全丢失了。部署前我会先用 curl 看响应是不是分块返回,确认没有被网关吞掉。

5.4 关于 SSR 成本的一点个人判断

做了几年 SSR 项目,我越来越觉得 SSR 不应该被当作默认选项,它是在特定约束下的最优解。如果产品对首屏时间不敏感、内容不需要被爬虫收录、用户设备性能普遍较好,纯 CSR 完全够用,技术栈更简单,运维成本更低。但如果你正在做一个内容密集型应用,或者已经有硬性的 SEO 指标、性能预算要求,那 SSR 几乎是必选路线。

选型时我习惯把问题拆成三问:首屏时间是不是瓶颈?搜索引擎收录重不重要?页面里有多少内容是和用户强相关的实时数据?三个问题的答案加起来,基本就能决定该走哪条渲染路线。

就拿我手头这个内容型展示系统来说,最后采用的是混合策略:活动列表、专题介绍这类内容走 ISR,实时更新和用户状态相关的页面走 SSR,第三方可视化组件动态导入关闭 SSR。这样既有 SEO 和首屏保障,又没有为每个请求都付出全量动态渲染的代价。

我自己实际执行下来最深的感受是:SSR 不是玄学,它就是一次完整的数据获取流程被移到了更靠前的位置。理清楚服务端做什么、客户端做什么、两者之间交换什么,大部分问题都能解决。遇到水合警告不要糊弄,服务端响应慢就一层层拆,缓存策略要做在部署之前而不是上线之后。这套思路在 Next.js 里如此,换到其他 SSR 框架也一样成立。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进