跳到主要内容

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 在这里

水合的三个代价:

  1. 可见但不可交互的窗口(uncanny valley) —— 用户看到页面了,点击没反应
  2. JS 必须下载并执行完才能交互 —— 所以 SSR 没有减少 JS 体积,只是提前了首屏可见
  3. 水合本身是一个长任务 —— 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。

关键规则:

  1. Server Component 可以渲染 Client Component,反过来不行(Client 里 import 的组件自动成为 Client)
  2. 可以把 Server Component 作为 children 传给 Client Component —— 这是在客户端组件内部「插入」服务端内容的唯一方式
  3. 跨边界传的 props 必须可序列化 —— 不能传函数(除了 Server Actions)、Date 可以、class 实例不行
  4. '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 的复杂度主要来自这里):

  1. Request Memoization —— 单次渲染内相同的 fetch 只发一次
  2. Data Cache —— 跨请求的 fetch 结果缓存(服务端,持久)
  3. Full Route Cache —— 整个路由的渲染结果缓存
  4. 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 项目的常见坑:

  1. 水合不匹配(上文)
  2. 在 Server Component 里用浏览器 API —— window/document 未定义
  3. 内存泄漏 —— Node 进程长期运行,模块级的全局变量会在请求间共享(典型 bug:把用户状态存模块变量,导致用户 A 看到用户 B 的数据)
  4. 第三方库不支持 SSR —— 需要 dynamic(..., { ssr: false })
  5. 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、首屏速度和数据实时性。