Next.js 与 SSR
你简历上四个项目都是 Next.js(Antigravity 官网、Insta360 官网、云存储、APP H5 重构),这页是被深挖时的后盾。
🔥 四种渲染模式
| 模式 | HTML 何时生成 | 适合 | 代价 |
|---|---|---|---|
| CSR | 浏览器里 JS 执行后 | 后台管理系统、强交互应用 | SEO 差、首屏白屏、依赖客户端性能 |
| SSR | 每次请求时在服务端 | 需要实时数据 + SEO(商品详情、个性化页) | 服务器压力大、TTFB 高 |
| SSG | 构建时预生成 | 内容不常变(营销页、博客、文档) | 内容更新要重新构建 |
| ISR | 构建时生成 + 按需/定时再生成 | 内容偶尔变但量大(电商列表) | 可能短暂看到旧内容 |
SEO 的核心差异:CSR 返回的 HTML 是个空壳,内容全靠 JS 渲染。虽然 Google 能执行 JS,但抓取预算有限、渲染队列延迟可能几天,且其他爬虫(Bing、社交媒体预览、AI 爬虫)基本不执行 JS。所以面向搜索的页面必须 SSR 或 SSG。
:::warning 验证「内容真的在 HTML 里」的方法 不要用浏览器 DevTools 的 Elements 面板 —— 它显示的是运行 JS 之后的 DOM,看起来有内容但爬虫看不到。
正确方法:curl 取原始 HTML,或 view-source:。更严格的是剥掉所有 <script> 标签之后再搜,因为 Next.js 会把数据以 JSON 的形式塞进 script 标签里(RSC payload / __NEXT_DATA__),grep 会命中那里而不是真实 DOM。
:::
🔥 水合(Hydration)
什么是水合 —— 服务端返回静态 HTML,浏览器下载 JS 后,React 在已有的 DOM 上「附加」事件监听器和状态,让它变成可交互的。
服务端渲染 HTML → 浏览器显示(可见但不可交互)→ 下载 JS → 水合 → 可交互
↑ FCP 很快 ↑ TTI 在这里
水合的三个代价:
- 可见但不可交互的窗口(uncanny valley) —— 用户看到页面了,点击没反应
- JS 必须下载并执行完才能交互 —— 所以 SSR 没有减少 JS 体积,只是提前了首屏可见
- 水合本身是一个长任务 —— React 要遍历整棵树,大页面会造成明显的 TBT
水合不匹配(Hydration Mismatch) —— 服务端和客户端渲染的内容不一致时 React 会报警告并丢弃服务端 HTML 重新渲染。常见原因:
// ❌ 服务端和客户端值不同
new Date().toLocaleString() // 时区不同
Math.random() // 每次都不同
window.innerWidth // 服务端没有 window
localStorage.getItem('theme') // 服务端读不到
// ✅ 解法:延迟到客户端
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return <Skeleton />
或者用 suppressHydrationWarning(只对该元素生效,适合时间戳这类必然不同的内容)。
这是 SSR 项目最常见的 bug 类型,被问到能说出具体案例很加分。
App Router 与 React Server Components
RSC 的核心价值 —— 组件在服务端执行,只把渲染结果(序列化的描述)传给客户端,组件代码本身不进客户端 bundle。
Server Component Client Component
───────────────── ────────────────
默认 需要 'use client'
可以 async/await 直接取数据 用 hooks 取数据
可以直接访问数据库 / 文件系统 不能
不进客户端 bundle(体积为 0) 进 bundle
不能用 hooks / 事件处理 / 浏览器 API 可以
不能有状态 可以
所以 RSC 解决的是「水合代价」的问题 —— 不需要交互的部分(文章内容、商品描述、布局)完全不下发 JS。
关键规则:
- Server Component 可以渲染 Client Component,反过来不行(Client 里 import 的组件自动成为 Client)
- 可以把 Server Component 作为 children 传给 Client Component —— 这是在客户端组件内部「插入」服务端内容的唯一方式
- 跨边界传的 props 必须可序列化 —— 不能传函数(除了 Server Actions)、Date 可以、class 实例不行
'use client'是边界标记而非文件标记 —— 标记之后,它导入的所有模块都进客户端
常见误解:以为 'use client' 的组件只在客户端渲染。实际上它仍然会在服务端预渲染一次 HTML(SSR),只是也会进客户端 bundle 并水合。真正「只在客户端」要用 dynamic(() => import(...), { ssr: false })。
App Router 的数据与缓存
// Server Component 里直接 await
async function Page({ params }) {
const data = await fetch(url, { next: { revalidate: 60 } }) // ISR
return <div>{data.title}</div>
}
fetch 的缓存选项:
| 配置 | 行为 |
|---|---|
cache: 'force-cache' | 永久缓存(相当于 SSG) |
cache: 'no-store' | 每次都取(相当于 SSR) |
next: { revalidate: 60 } | 60 秒后重新验证(ISR) |
next: { tags: ['posts'] } | 打标签,之后用 revalidateTag('posts') 按需失效 |
:::warning 缓存默认值在 Next.js 15 变了
Next.js 14 及之前:fetch 默认 force-cache(默认缓存),很多人踩「改了数据前台不更新」的坑。
Next.js 15:fetch 默认不缓存(no-store),Route Handler 和客户端路由缓存的默认值也改了。这个变更是因为「默认缓存」违反直觉、踩坑的人太多。
面试提到这个版本差异能显示实际用过而非只看过文档。 :::
四层缓存(App Router 的复杂度主要来自这里):
- Request Memoization —— 单次渲染内相同的 fetch 只发一次
- Data Cache —— 跨请求的 fetch 结果缓存(服务端,持久)
- Full Route Cache —— 整个路由的渲染结果缓存
- Router Cache —— 客户端的路由缓存(内存,短期)
失效手段:revalidatePath()、revalidateTag()、router.refresh()。
Server Actions —— 在服务端执行的函数,可以直接从表单或客户端组件调用:
async function createPost(formData) {
'use server'
await db.insert(...)
revalidateTag('posts') // 更新后失效缓存
}
好处是不用手写 API 路由。注意它本质是一个 POST 端点,所以必须做权限校验和入参校验 —— 「因为是服务端代码所以安全」是错的,它是公开可调用的。
Streaming 与 Suspense
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* 这部分慢,但不阻塞页面其他部分 */}
</Suspense>
价值:服务端可以先把已完成的部分流式发给浏览器,慢的部分后到。于是 TTFB 和 FCP 不再被最慢的那个数据请求拖累。
loading.tsx 是 App Router 里自动的 Suspense 边界。
代价:流式响应下无法设置某些响应头(已经开始发送 body 了),且错误处理更复杂(要用 error.tsx)。
Pages Router(你项目里可能还在用)
// SSR:每次请求执行
export async function getServerSideProps(ctx) {
return { props: { data } }
}
// SSG:构建时执行
export async function getStaticProps() {
return { props: { data }, revalidate: 60 } // 加 revalidate 就是 ISR
}
// 动态路由的 SSG 需要声明要生成哪些路径
export async function getStaticPaths() {
return { paths: [...], fallback: 'blocking' }
}
fallback 的三个值:false(未列出的路径 404)、true(先返回 fallback 页面,后台生成)、'blocking'(等生成完再返回,无 fallback 闪烁,SEO 友好)。
常见追问
SSR 一定比 CSR 快吗? 不一定。SSR 的 FCP/LCP 更快(HTML 里就有内容),但 TTFB 更慢(服务端要渲染)、TTI 不一定更快(仍要下载水合 JS)。如果服务端渲染耗时很长或服务器压力大,SSR 的首屏可能比 CSR 还慢。SSR 的确定优势是 SEO 和首屏内容可见,不是「更快」。
SSR 的服务端压力怎么办? CDN 缓存(能缓存就不要 SSR,用 SSG/ISR)、组件级缓存、限流和降级(服务端过载时降级为返回 CSR 的空壳)、水平扩容。最有效的是把能静态化的页面静态化 —— 大部分页面不需要真正的 per-request SSR。
SSR 项目的常见坑:
- 水合不匹配(上文)
- 在 Server Component 里用浏览器 API ——
window/document未定义 - 内存泄漏 —— Node 进程长期运行,模块级的全局变量会在请求间共享(典型 bug:把用户状态存模块变量,导致用户 A 看到用户 B 的数据)
- 第三方库不支持 SSR —— 需要
dynamic(..., { ssr: false }) - CSS-in-JS 的样式提取 —— 需要特殊配置,且和 RSC 不兼容
:::danger 第 3 条是真实的严重事故模式
// ❌ 模块级变量在 Node 进程里是所有请求共享的
let currentUser = null
export async function getData() {
currentUser = await fetchUser() // 并发请求会互相覆盖
return render(currentUser)
}
CSR 时代每个浏览器标签页是独立的 JS 环境,模块级变量天然隔离。SSR 下一个 Node 进程服务所有用户,这个习惯会直接导致跨用户数据泄漏。所有请求相关的状态必须通过参数传递或放在请求上下文里。 :::
怎么选渲染模式 —— 按页面而非按项目决定:
营销页 / 博客 / 文档 → SSG(最快、最便宜、最稳)
商品列表 / 分类页 → ISR(内容偶尔变,量大)
商品详情(库存、价格实时) → SSR 或 SSG + 客户端补差价
用户中心 / 购物车 / 后台 → CSR(无 SEO 需求,强交互)
「SSG + 客户端补差」是电商的常用模式 —— 页面主体静态生成(SEO 和首屏),价格库存这类实时数据在客户端单独请求。兼顾了 SEO、首屏速度和数据实时性。