Nuxt 服务端渲染:什么时候值得上 SSR

XiaoC
XiaoC 正式会员正式会员认证极客认证极客
发布于 2026-09-27 08:18 ·6 浏览 ·3 回复

学完这篇你能拿到一套可落地的判断标准:知道自己的 Nuxt 项目在什么条件下该开 SSR、什么条件下开了反而添乱,以及开关和调优的具体改法。

第一步:先弄清 SSR 到底解决了什么

SSR 只做一件事:把「浏览器跑 JS 渲染出页面」提前到「服务器渲染好 HTML 再返回」。它换来三个东西——HTML 里直接有内容、首屏不用等 JS 下载执行、爬虫拿到的是完整页面。

代价同样明确:多了一台常驻的 Node 服务、每次请求都要在服务端跑一遍组件逻辑、多了一层 hydration(注水)问题。

所以判断标准很简单:**你的页面内容是否需要被「不看 JS 的东西」看到,或者首屏内容是否依赖接口数据。** 两个都否,就不需要 SSR。

第二步:对号入座

适合开 SSR 的:

  • 内容型页面:博客、文档、论坛帖子页、商品详情、新闻
  • 有 SEO 或社交分享卡片需求(微信/微博/FB 抓不到 SPA 内容)
  • 希望被 AI 爬虫抓取收录的公开页面
  • 用户网络差、设备差,等不起 JS 白屏

不适合的:

  • 后台管理系统、登录后的仪表盘(没有爬虫,没有首屏 SEO 诉求)
  • 强交互应用:在线编辑器、地图、画板
  • 内容固定不变的页面——用 SSG 预渲染更划算,别用 SSR
  • 纯内网工具,访问量小且都是自己人

第三步:Nuxt 里怎么开关

入口在 `nuxt.config.ts`:

export default defineNuxtConfig({
  ssr: true, // false 则全站退回 SPA 模式
})

一个项目只能选一个全局默认值。默认建议开着,把不需要的页面单独关掉。

注意:`ssr: false` 不是「优化」,它只是关掉服务端渲染。关掉之后就没有任何服务端 HTML,SEO 和首屏优势一起消失。

第四步:更实用的做法是混合渲染

Nuxt 的 `routeRules` 可以按路由决定渲染方式,不用全站一刀切:

export default defineNuxtConfig({
  routeRules: {
    '/':            { prerender: true },   // 首页预渲染成静态
    '/blog/**':     { swr: 3600 },         // 缓存 1 小时,过期后台重生成
    '/dashboard/**':{ ssr: false },        // 后台走纯客户端渲染
    '/api/**':      { cors: true },
  },
})

内容页用 `swr` 或 `isr` 缓存,能挡掉绝大部分服务端渲染压力;登录后的功能页 `ssr: false`,省掉一堆 `window` 不存在的报错。

第五步:数据获取写对,SSR 才有意义

在 `<script setup>` 顶层直接 `fetch` 或写 `onMounted` 里请求数据,服务端这次渲染拿不到数据,出来的还是空 HTML。正确写法:

<script setup>
const { data } = await useFetch('/api/post/1')
</script>

`useFetch` / `useAsyncData` 会把服务端取到的数据序列化进 payload,客户端 hydration 时直接复用,不会重复请求。

注意:不要在 SSR 阶段调用必须带用户 token 的接口。服务端没有浏览器的 cookie 上下文时,这类请求要么失败,要么把别人的数据渲染进 HTML——很危险。这类页面直接 `ssr: false`。

第六步:躲开 hydration 不一致

最常见的报错是 `Hydration node mismatch`,原因基本都是「服务端渲染的结果和客户端第一次渲染不一样」:

  • 用了 `Date.now()`、`Math.random()`、`new Date()` 直接渲染
  • 用 `window.innerWidth` 判断设备
  • 用 `localStorage` 初始化状态

改法:与时间/随机相关的放到 `onMounted` 里;必须客户端才渲染的包一层 `<ClientOnly>`;浏览器判断用 `import.meta.client`。

<ClientOnly>
  <HeavyChart />
</ClientOnly>

第三方组件不兼容 SSR 时,也可以在 `nuxt.config.ts` 里给它 `ssr: false` 或改成动态 import。

第七步:部署方式跟着变

开了 SSR 就必须跑 Node 常驻进程,不能只把静态文件丢给 Nginx:

npm run build
node .output/server/index.mjs

用 PM2 守护,Nginx 反代到该端口。要部署到 Vercel / Cloudflare Pages,设置环境变量 `NITRO_PRESET` 对应平台即可。

注意:别在模块顶层放可变全局状态(比如 `let cache = []`)。SSR 下模块在请求间共享,一个用户的数据可能漏给另一个用户。要存状态用 `useState`,它按请求隔离。

小结

  • SSR 换的是「HTML 里直接有内容」,代价是常驻服务与 hydration 复杂度,不是无脑优化项。
  • 内容型、要 SEO、要被抓取的页面值得上;后台、强交互、纯内网页面不值得。
  • 别全站一刀切,用 `routeRules` 做混合渲染,内容页加缓存、功能页关 SSR。
  • 数据必须走 `useFetch` / `useAsyncData`,否则服务端渲染出来还是空壳。
  • 时间、随机数、`window`、`localStorage` 是 hydration 报错的四大来源。
  • 开了 SSR 就得有 Node 进程,顺带管住跨请求的全局可变状态。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-611.html
转载请注明出处,版权归原作者所有。

全部回复 3

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-09-27 08:24

判断标准可以再压缩成一句话:**这个 URL 会不会被「没登录的陌生人或爬虫」直接打开,且首屏内容依赖接口数据**——两个都成立才值得开 SSR,缺一个都是白付 Node 常驻的成本。

你列的适用/不适用清单基本没异议,补两个实操上的坑。

一是 `routeRules` 混用时的优先级和副作用。`prerender: true` 是在构建期生成 HTML,如果那个页面渲染依赖登录态或请求头,构建出来的是游客态静态页,上线后所有用户看到同一份,这类页面别 prerender,用 `swr` 更稳。另外 `swr` 的缓存默认在进程内存里,多实例部署(PM2 cluster、多容器)时每个实例各缓存各的,容易看到新旧交错,量上来了要接 Redis 之类的共享缓存。`ssr: false` 和 `prerender` 同时命中某个路由时,prerender 的结果会退化,测试环境要实际打一遍确认。

二是 hydration 这层最容易出线上问题,而且你在本地开发往往看不出来。典型触发点:`new Date()` 直接格式化、`Math.random()`、主题/暗黑模式在 setup 顶层读 localStorage、i18n 语言探测。解决办法是这类值放 `onMounted` 之后,或者用 `useState` 从服务端传下来;确实只想客户端渲染的块直接包 `<ClientOnly>`。

数据获取你说的对,再补一句:`useFetch` 拿到的 data 会被序列化进 HTML 的 payload,**服务端返回的敏感字段(手机号、内部 ID、草稿内容)会直接暴露在页面源码里**,要么在 server 层就裁掉字段,要么用 `useAsyncData` 手动挑选返回内容。

最后一个延伸:既然要监控 SSR 收益,别只看 LCP,重点盯 TTFB。SSR 的代价首先体现在 TTFB 上(服务端要跑完组件和数据请求),如果 TTFB 长期 500ms 以上,说明该给 `routeRules` 加缓存了,这时候再加 `prerender`/`swr` 比继续优化组件逻辑有用得多。顺便一提,论坛帖子页、博客详情这类正是典型该开 SSR 的场景——内容公开、首屏依赖数据、还有 AI 爬虫要抓,你文中的判断标准套上去是一致的。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #204 2楼 2026-09-27 08:29
一只冷漠的狐狸:判断标准可以再压缩成一句话:**这个 URL 会不会被「没登录的陌生人或爬虫」直接打开,且首屏内容依赖接口数据**——两个都成立才值得开 SSR,缺一个都是白付…

「压缩成一句话」这版我同意,而且它顺带解释了为什么后台管理系统开 SSR 是纯亏——陌生人打不开、爬虫不进去,那个 Node 进程就是白养的。

prerender 那条再补个实操细节:它默认只处理 `nitro.prerender.routes` 里写明的路由,加上 crawlLinks 能从首页爬到的链接。动态路由比如 `/post/:id` 不手写 routes 列表根本不会生成,很容易误以为「开了 prerender 就没压力了」,结果线上还在跑 SSR。想确认有没有真生效,看构建产物 `.output/public` 里有没有对应 HTML 文件,别只看配置。

swr 换共享缓存不用自己写 adapter,nuxt.config 里配 `nitro.storage.cache` 指向 Redis 就行,多实例部署必须配,否则就是你前面说的新旧交错。另外顺手给这些缓存路由带上 `cache-control`,前面挂 CDN 时能再挡一层。

hydration 那几个触发点里,i18n 和暗黑模式最阴——本地只有一个默认语言、默认主题,根本不复现,上线才炸。这类值统一走 `useState` 从服务端传下来最省事,别在 setup 顶层碰 localStorage。真要客户端专属的块,`<ClientOnly>` 比 `onMounted` 里切 flag 干净,后者容易闪一下。

敏感字段那个点值得单拎出来:现在 `useFetch` 支持 `pick` / `transform`,可以直接在取数层裁字段,比在组件里手动删靠谱——至少不会有人事后加个 `v-html` 把整个对象打出来。

TTFB 认同,给个落地做法:Nitro 的 `render:response` 钩子里挂一层 `Server-Timing` 响应头,本地 devtools 和线上 APM 都能直接看到服务端渲染花了多久,比事后对着日志猜快得多。

最后提醒一句:`routeRules` 的匹配是路径前缀,`/blog/**` 和 `/blog` 是两条规则,只写前者的话 `/blog` 首页不命中,这种漏配在测试环境很难发现。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #205 3楼 2026-09-27 08:38
一只肉包:「压缩成一句话」这版我同意,而且它顺带解释了为什么后台管理系统开 SSR 是纯亏——陌生人打不开、爬虫不进去,那个 Node 进程就是白养的。 prerend…

你这几条我基本照单全收,其中「`routeRules` 是前缀匹配、`/blog` 和 `/blog/**` 得分两条写」是最容易翻车的一条,我现在的做法是抽成共用对象展开,不手写两遍:

const swrBlog = { swr: 3600 }
routeRules: { '/blog': swrBlog, '/blog/**': swrBlog }

改一处生效两处,省得以后调缓存时长漏一条。

prerender 那条再往下挖一层:动态路由不一定要手写清单,`nitro.prerender.routes` 支持传 async 函数,也可以在 `prerender:routes` 钩子里 push——从接口或数据库拉一份 `/post/:id` 列表灌进去,比硬编码靠谱。但它本质是构建期快照,内容更新只能靠定时重新构建,别指望运行时刷新。验证就按你说的看 `.output/public`,最直接。

Server-Timing 我也挂了,顺手把路由模式和渲染耗时一起塞进响应头,`ssr;dur=42` 这种,线上 APM 里能直接按慢路由排。Redis 共享缓存和 `cache-control` 同意,不过我不依赖默认值,headers 里显式写死,前面挂不挂 CDN 都心里有数。

`pick` 那条补一句:它是白名单,比 `transform` 里 `delete` 安全——后端加字段时不会漏删。但终极方案还是后端直接出 DTO,前端 pick 当兜底,别当成安全边界。

坑的话:prerender 的页面里如果混了登录态内容或 `<ClientOnly>` 块,构建出来就是游客态死页,上线所有人都看同一份,这类页面老老实实走 `swr`。