叁拾玖2026-09-26 19:20

Vue3 博客 CSR 改 SSR 全记录:为了让百度收录,我把前台重写了一遍

9 1

Vue3 CSR 改 SSR

先交代下背景,不然后面的坑看着没头没尾。

这个博客上线快两年了,前阵子刚把整个博客重新重构了一遍。前台 Vue3 + Vite + Element Plus,状态管理用 Pinia;后端 Node + Express,Prisma 管数据库;前后端分离,接口对接口,各管各的。后台管理也是 Vue 写的单页应用,跟前台共用一套后端。当时选这套没什么深思熟虑,就是熟,写起来快,页面交互也舒服。

博客这东西,写着写着就有感情了。文章攒到现在几十篇,JWT 认证、评论、相册、许愿墙、暗黑模式,功能越加越多,自己用着挺爽。但个人博客有个绕不开的宿命——没人看。朋友圈不好意思天天发,搜索流量又一直是零,我就琢磨问题出在哪。

用到现在,功能上没什么不满。直到前阵子心血来潮,搜了下 site:www.sanshijiu.cn。

好家伙,百度就收录了一个首页,还是刚建站那会儿抓的。

先搞懂几个词,不然容易懵

讲方案之前,先把几个行话说清楚,这也是我当时临时补课的内容。已经懂的朋友可以直接跳过。

蜘蛛(爬虫/Spider):搜索引擎派来的"小程序",定时访问你的网页、把内容抓回去建索引。你的站能不能被搜到,第一道关就是蜘蛛来了能不能看懂页面。

CSR(客户端渲染):Vue、React 这类前后端分离的默认玩法。服务器返回的 HTML 是个空壳,正文全靠浏览器下载 JS、执行 JS 之后现场生成。

SSR(服务端渲染):请求到了服务器,服务器先把数据取好、把 HTML 拼完整,再吐给浏览器。蜘蛛拿到手的就是一篇能直接读的文章。

水合(Hydration):SSR 输出的 HTML 一开始只是"看得见但点不动"的静态页面,等浏览器把 JS 加载完,Vue 再把事件、响应式状态"接"到现有 DOM 上,页面才真正活过来。这个接管过程就叫水合。

为什么 Google 没事、百度有事?因为 Google 的蜘蛛会执行 JS,相当于它自带一个浏览器;而百度蜘蛛长期不执行 JS(或者说执行得很弱),它每天来,看到的都是那个空壳。空口无凭,curl 拉了下自己首页的源码:

<body><div id="app"></div></body>

一个 div,没了。这就是百度蜘蛛眼里的这个站。写了两年的几十篇文章,在它那儿等于不存在,挺扎心的。

路怎么选

查了下方案,无非几种。

预渲染(SSG):构建时把页面生成静态 HTML。听着美好,但我的文章是动态的,归档、列表这些页面数据天天变,没法在构建时写死,pass。

SSR:请求过来,服务器先把完整 HTML 渲染好再返回,蜘蛛一来就能看到全部内容。正路,但前端要从 Vue 单页应用换成 Nuxt,基本等于重写一遍。

给百度单独做一套静态页:有些老站真这么干,维护两套页面,想想都恶心,pass。

CSR 与 SSR 的区别

纠结了两天还是走 SSR。不过没按官方那套部署来(那套要服务器跑构建,2G 内存的小机器遭不住,npm run build 能把内存吃满直接 OOM),改成:本地构建好产物,打包传上去,服务器只负责跑 Node。后台管理不迁,后台又不需要 SEO,老代码继续用。

下面是全过程,技术栈跟我差不多的(Vue3 + Vite + Node 后端),理论上可以照着走。每一步我都会说清楚"为什么",因为当初我自己抄教程时,最烦的就是只给代码不讲原因。

第一步:搬家,不是重写

新建一个 Nuxt3 项目,然后把老项目的东西往里搬。听起来吓人,实际工作量比想象小,因为 Nuxt3 底层就是 Vue3,组件基本原样复制,<script setup> 写法、Pinia、Element Plus 全都还能用。

搬的东西:components/ 整个目录、pages/ 下的 vue 文件(原来 vue-router 的路由文件删掉,Nuxt 按文件名自动生成路由,post/[id].vue 就是 /post/:id)、全局样式挪到 assets/ 再在 nuxt.config 里引。

新手提示:Nuxt 的约定路由省掉了路由配置文件,但代价是文件名即路由,动态参数用方括号,404 页面就叫 [...slug].vue。搬的时候建议先搬一个最简单的页面跑通,再批量复制,别一口气全塞进去然后对着满屏报错发呆。

真正要动脑子的是接口层。老项目用的 axios,全站到处 import request from '@/api/request'。要是把几十个接口调用全改成 Nuxt 的写法,改到天荒地老。我的做法是在 composables 里封一个假的"request",把 axios 那套方法名模拟出来,api/ 目录一行没动就跑起来了。

// composables/useApi.js,核心思路
export const $api = $fetch.create({
  // 服务端直连后端,浏览器端走相对路径
  baseURL: import.meta.server ? 'http://127.0.0.1:3000' : '',
  onRequest({ options }) {
    const account = useAccountStore()
    if (account.token) {
      options.headers = new Headers(options.headers)
      options.headers.set('Authorization', `Bearer ${account.token}`)
    }
  },
  onResponse({ response }) {
    // 统一拆后端的 { code, data, message } 包装
    const body = response._data
    if (body && body.code !== 200) throw createError({ message: body.message })
    response._data = body.data
  },
})

export function useApiData(key, url, options = {}) {
  return useFetch(url, { key, ...options, $fetch: $api })
}

注意那个 baseURL 的写法,服务端直连 127.0.0.1:3000。一开始我偷懒让服务端也走相对路径让内部代理转发,结果在服务器上 502,折腾半天不如直连来得干脆。原因也简单:浏览器里的相对路径是相对当前域名的,有 Nginx 帮你转;但 Node 服务端发请求时没有"当前域名"这个概念,相对路径直接就废了。

第二步:状态挪进 cookie

Pinia 的 store 在 CSR 里靠 localStorage 持久化,但服务端渲染时读不到 localStorage——服务端跑在 Node 里,哪来的 localStorage。直接读只会拿到 undefined,然后首屏渲染出一个"未登录"状态,水合时浏览器又说"已登录",两边打架,控制台一片水合警告。

所以凡是服务端渲染时就要用的状态,都得换成 cookie。我搬了两个:登录 token、暗黑模式主题。Nuxt 有现成的 useCookie,跟 ref 一个用法:

const token = useCookie('token', { maxAge: 60 * 60 * 24 * 30 })

服务端和浏览器端都读得到,水合的时候也不会对不上。

纯客户端的状态(比如音乐播放器进度这种)不用管,留在 localStorage 里没事。判断标准就一句话:这个状态会不会影响首屏 HTML?会影响,就进 cookie;只在用户点了之后才用,留着别动。

第三步:首页和文章页数据直出

这是整个改造的灵魂。以前页面的数据是 mounted 之后请求接口再填进去的,现在要让服务端把数据取好、连着 HTML 一起发出去。

用上面封的 useApiData,写起来跟以前的 useFetch 没啥区别:

// pages/post/[id].vue
const route = useRoute()
const { data: post } = await useApiData(`post-${route.params.id}`, `/api/post/${route.params.id}`)

就多了个 await。Nuxt 会在服务端执行到这里,等接口返回,把数据填进 HTML 再发给浏览器。数据还会被序列化进页面,浏览器端接管的时候不用重新请求一遍——用户感觉打开速度反而变快了,因为省掉了"白屏等 JS → 请求接口 → 渲染"这一长串。

我把首页、文章页、归档页这三个最需要 SEO 的页面做成了直出,别的页面(相册、留言板这种)保持客户端请求,省事,也没那个必要。别追求全站 SSR,挑蜘蛛真正关心的页面做就行,多做一个就多一份服务端开销和调试成本。

第四步:暗黑模式防闪

老站切暗黑模式是 JS 在浏览器里给 html 加 class,SSR 之后有个尴尬:服务端渲染时不知道用户选的什么主题,首屏按亮色输出,浏览器一接管发现用户是暗色,白屏闪一下再变黑,很掉价。深夜刷博客的人被白光糊一脸,体验灾难。

解法就是第二步埋的伏笔——主题存 cookie,服务端直接读:

const theme = useCookie('theme')
useHead({
  htmlAttrs: { class: () => theme.value === 'dark' ? 'dark' : '' },
})

服务端输出的 HTML 带着 dark class,首屏就是暗的,一点不闪。

水合与暗黑模式防闪

顺便理解一下水合:上面图里那个"服务端画骨架、浏览器装神经"的过程,首屏内容是服务端给的,交互是水合之后才有的。凡是依赖"随机数""当前时间""本地存储"的逻辑,服务端和浏览器算出来不一样,水合必报警,这类代码统一挪到 onMounted 之后再跑。

第五步:构建、打包、部署

本地构建:

npm run build

产物在 .output/ 目录,server 端 + 静态资源都在里面。这里有两个 Windows 特有的坑,都是我拿一晚上换来的。

一是 Nuxt 在 Windows 上构建,.output/server/node_modules 里用的是 junction 链接(类似软链接),打包压缩再传到 Linux,链接全断,起不来就报 Cannot find package 'hookable'。所以打包时把这个目录排除掉:

tar --exclude='.output/server/node_modules' -czf nuxt-output.tar.gz .output

传到服务器解压,再进 .output/server 里 npm install --omit=dev 装真依赖。

二是 Node 版本。我服务器是 Node 17.9.1,而全局 fetch 是 Node 18 才内置的。症状很迷惑:页面能打开,数据全空,日志里接口请求全失败,本地却完全正常。我盯着这个"本地好好的、线上全空"的问题怀疑人生了一个多小时,最后才想起来两边 Node 版本不一样。解决方法就一行:

node --experimental-fetch index.mjs

启动命令带上这个参数。加日志重定向放后台:

nohup node --experimental-fetch index.mjs > app.log 2>&1 &

给后来者的建议:服务器该升 Node 就升,17 早就 EOL 了,我是嫌动环境麻烦才打补丁,能升别学我。 另外 Nuxt 产物默认监听 3000,跟我的后端 API 撞了,写了个构建后的小脚本把默认端口改成 3001,不然起不来。

第六步:Nginx 分流

最后一步,把流量分对地方。一个域名进来,三类请求要去三个地方:接口和上传文件给老后端,前台页面给 Nuxt,后台和静态资源走老 dist。

Nginx 分流架构

我的配置核心就这几段:

# 接口和上传的静态文件,走老后端 3000
location /api    { proxy_pass http://127.0.0.1:3000; }
location /static { proxy_pass http://127.0.0.1:3000; }

# 前台 SSR,其余所有路径
location / { proxy_pass http://127.0.0.1:3001; }

# 前端构建产物带 hash,放心缓存
location ^~ /_nuxt/ {
    proxy_pass http://127.0.0.1:3001;
    expires 30d;
}

# 后台管理还是老静态文件,history 路由刷新要能兜回 index.html
location ^~ /admin {
    root /www/wwwroot/www.sanshijiu.cn/dist;
    try_files $uri $uri/ /index.html;
}
location = /index.html {
    root /www/wwwroot/www.sanshijiu.cn/dist;
}

单独说下 location = /index.html 这段,这个坑卡了我挺久。后台是老的 SPA,刷新 /admin/xxx 时 Nginx 会 try_files 兜回 /index.html,但这个内部重定向会重新走一遍 location 匹配,被上面 location / 的反代规则截胡,转给 Nuxt 去渲染,Nuxt 里没这个路由,404。给 /index.html 单独配一段精确匹配(= 是精确匹配,优先级最高),指回静态目录,才消停。

改完 Nginx 记得 nginx -t 测一遍配置再 nginx -s reload,别直接 reload,语法错了线上瞬间 502,别问我怎么知道的。

其他的坑

捡印象深的补几个。

popper 报 'placements' not found。Element Plus 依赖的 @popperjs/core 是 CJS 包,具名导出在 Node 17 下解析不好,SSR 阶段直接炸。nuxt.config 里加一行让它内联:

vite: { ssr: { noExternal: ['@popperjs/core'] } }

水合警告刷屏。我有个随机切换背景图的逻辑,服务端随机了一个,浏览器端又随机一个,两边对不上。凡是"随机""当前时间"这类每次执行结果不同的代码,都得挪到客户端挂载之后跑,服务端别掺和。排查水合警告有个笨办法:看警告里的 DOM 差异节点,顺着那个组件往上找,十有八九能定位到。

sitemap.xml 和 robots.txt。让后端直接吐这两个文件,文章列表查一遍库拼 XML,加密的文章不往里放。robots 把后台、个人中心、接口路径都 Disallow 掉。然后去站长平台提交 sitemap,剩下的交给时间。

页面 meta 别忘配。SSR 只是让蜘蛛能读到内容,但标题、描述这些"简历"得自己写。用 Nuxt 的 useSeoMeta 给每个页面配 title、description、og 标签,文章页的标题直接用文章标题:

useSeoMeta({
  title: () => post.value?.title,
  description: () => post.value?.summary || post.value?.content?.slice(0, 100),
  ogTitle: () => post.value?.title,
  ogType: 'article',
})

怎么确认成了

别看页面显示正常——CSR 时代页面也显示正常。唯一的检验标准是源码:

curl -s https://你的域名 | grep 某篇文章的标题

能 grep 出来,说明内容真的在 HTML 里,蜘蛛看得到。我改完源码从 1KB 变成 170 多 K,标题正文全躺在里面,成了。那一刻挺爽的,折腾几天就等这一行输出。

再补两个验证手段:浏览器右键"查看网页源代码"(不是 F12 的 Elements,那个是 JS 跑完后的结果,CSR 也能看到内容,不算数);以及百度搜索资源平台的"抓取诊断",直接看百度蜘蛛抓到的原文,最权威。

复盘:花了多久,值不值

实际搬了大概三个晚上加一个周末。第一晚搭架子搬组件,第二晚死磕接口层和水合警告,部署那晚基本全耗在 Windows 打包链接和 Node 版本上——真正写业务代码的时间,反而不到三分之一。这也是我想分享的一点:SSR 改造的难点从来不是新语法,而是"同一套代码要在两个环境里跑"带来的环境差异。

值不值?对个人博客来说,投入产出比其实见仁见智。但我觉得值,理由有三个:首屏确实快了,弱网和老设备感受明显;逼着自己把项目从"能跑就行"收拾到了"结构清晰";以后写技术文章,至少有机会被需要的人搜到,而不是烂在自己的服务器里。

如果你也打算动手,按这个清单过一遍,能避开我踩的大部分坑:

  1. 先搬一个最简单的页面跑通,再批量复制
  2. 服务端请求必须用完整地址,别写相对路径
  3. 影响首屏的状态进 cookie,其余留 localStorage
  4. 只给首页、详情页、列表页做直出,别贪多
  5. 随机/时间相关逻辑全部挪到 onMounted 之后
  6. Windows 打包排除 .output/server/node_modules
  7. 服务器 Node 升到 18+,一劳永逸
  8. Nginx 改完先 nginx -t
  9. sitemap、robots、每页 meta 配齐,再去站长平台提交
  10. 最后用 curl grep 标题验收,别信眼睛

百度什么时候收录不知道,反正这次不是它的问题了。收录了回来更新一句。

如果你也是 Vue3 前后端分离的站,被收录问题折磨,上面这些步骤基本可以照抄。有拿不准的地方,评论区聊。

全文完