[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"site-settings":3,"site-stats":4,"post-34":5},true,null,[6,28],{"locked":7,"post":8},false,{"id":9,"authorId":10,"categoryId":11,"tagId":12,"title":13,"tip":14,"coverUrl":15,"content":16,"videoUrl":14,"views":17,"likes":10,"isPublished":3,"isRecommended":3,"isPinned":7,"allowComment":3,"createdAt":18,"updatedAt":19,"deletedAt":4,"category":20,"tag":23,"author":25,"liked":7},34,1,2,26,"Vue3 博客 CSR 改 SSR 全记录：为了让百度收录，我把前台重写了一遍","","https:\u002F\u002Fimg.sanshijiu.cn\u002Fcommon\u002F1\u002F17904215813034af6c84e.png","![Vue3 CSR 改 SSR](https:\u002F\u002Fimg.sanshijiu.cn\u002Fcommon\u002F1\u002F17904215813034af6c84e.png)\n\n先交代下背景，不然后面的坑看着没头没尾。\n\n这个博客上线快两年了，前阵子刚把整个博客重新重构了一遍。前台 Vue3 + Vite + Element Plus，状态管理用 Pinia；后端 Node + Express，Prisma 管数据库；前后端分离，接口对接口，各管各的。后台管理也是 Vue 写的单页应用，跟前台共用一套后端。当时选这套没什么深思熟虑，就是熟，写起来快，页面交互也舒服。\n\n博客这东西，写着写着就有感情了。文章攒到现在几十篇，JWT 认证、评论、相册、许愿墙、暗黑模式，功能越加越多，自己用着挺爽。但个人博客有个绕不开的宿命——没人看。朋友圈不好意思天天发，搜索流量又一直是零，我就琢磨问题出在哪。\n\n用到现在，功能上没什么不满。直到前阵子心血来潮，搜了下 `site:www.sanshijiu.cn`。\n\n好家伙，百度就收录了一个首页，还是刚建站那会儿抓的。\n\n## 先搞懂几个词，不然容易懵\n\n讲方案之前，先把几个行话说清楚，这也是我当时临时补课的内容。已经懂的朋友可以直接跳过。\n\n**蜘蛛（爬虫\u002FSpider）**：搜索引擎派来的\"小程序\"，定时访问你的网页、把内容抓回去建索引。你的站能不能被搜到，第一道关就是蜘蛛来了能不能看懂页面。\n\n**CSR（客户端渲染）**：Vue、React 这类前后端分离的默认玩法。服务器返回的 HTML 是个空壳，正文全靠浏览器下载 JS、执行 JS 之后现场生成。\n\n**SSR（服务端渲染）**：请求到了服务器，服务器先把数据取好、把 HTML 拼完整，再吐给浏览器。蜘蛛拿到手的就是一篇能直接读的文章。\n\n**水合（Hydration）**：SSR 输出的 HTML 一开始只是\"看得见但点不动\"的静态页面，等浏览器把 JS 加载完，Vue 再把事件、响应式状态\"接\"到现有 DOM 上，页面才真正活过来。这个接管过程就叫水合。\n\n为什么 Google 没事、百度有事？因为 Google 的蜘蛛会执行 JS，相当于它自带一个浏览器；而百度蜘蛛长期不执行 JS（或者说执行得很弱），它每天来，看到的都是那个空壳。空口无凭，curl 拉了下自己首页的源码：\n\n```html\n\u003Cbody>\u003Cdiv id=\"app\">\u003C\u002Fdiv>\u003C\u002Fbody>\n```\n\n一个 div，没了。这就是百度蜘蛛眼里的这个站。写了两年的几十篇文章，在它那儿等于不存在，挺扎心的。\n\n## 路怎么选\n\n查了下方案，无非几种。\n\n**预渲染（SSG）**：构建时把页面生成静态 HTML。听着美好，但我的文章是动态的，归档、列表这些页面数据天天变，没法在构建时写死，pass。\n\n**SSR**：请求过来，服务器先把完整 HTML 渲染好再返回，蜘蛛一来就能看到全部内容。正路，但前端要从 Vue 单页应用换成 Nuxt，基本等于重写一遍。\n\n**给百度单独做一套静态页**：有些老站真这么干，维护两套页面，想想都恶心，pass。\n\n![CSR 与 SSR 的区别](https:\u002F\u002Fimg.sanshijiu.cn\u002Fcommon\u002F1\u002F179042158165685fe11b3.png)\n\n纠结了两天还是走 SSR。不过没按官方那套部署来（那套要服务器跑构建，2G 内存的小机器遭不住，`npm run build` 能把内存吃满直接 OOM），改成：本地构建好产物，打包传上去，服务器只负责跑 Node。后台管理不迁，后台又不需要 SEO，老代码继续用。\n\n下面是全过程，技术栈跟我差不多的（Vue3 + Vite + Node 后端），理论上可以照着走。每一步我都会说清楚\"为什么\"，因为当初我自己抄教程时，最烦的就是只给代码不讲原因。\n\n## 第一步：搬家，不是重写\n\n新建一个 Nuxt3 项目，然后把老项目的东西往里搬。听起来吓人，实际工作量比想象小，因为 Nuxt3 底层就是 Vue3，组件基本原样复制，`\u003Cscript setup>` 写法、Pinia、Element Plus 全都还能用。\n\n搬的东西：`components\u002F` 整个目录、`pages\u002F` 下的 vue 文件（原来 vue-router 的路由文件删掉，Nuxt 按文件名自动生成路由，`post\u002F[id].vue` 就是 `\u002Fpost\u002F:id`）、全局样式挪到 `assets\u002F` 再在 nuxt.config 里引。\n\n**新手提示**：Nuxt 的约定路由省掉了路由配置文件，但代价是文件名即路由，动态参数用方括号，404 页面就叫 `[...slug].vue`。搬的时候建议先搬一个最简单的页面跑通，再批量复制，别一口气全塞进去然后对着满屏报错发呆。\n\n真正要动脑子的是接口层。老项目用的 axios，全站到处 `import request from '@\u002Fapi\u002Frequest'`。要是把几十个接口调用全改成 Nuxt 的写法，改到天荒地老。我的做法是在 composables 里封一个假的\"request\"，把 axios 那套方法名模拟出来，`api\u002F` 目录一行没动就跑起来了。\n\n```js\n\u002F\u002F composables\u002FuseApi.js，核心思路\nexport const $api = $fetch.create({\n  \u002F\u002F 服务端直连后端，浏览器端走相对路径\n  baseURL: import.meta.server ? 'http:\u002F\u002F127.0.0.1:3000' : '',\n  onRequest({ options }) {\n    const account = useAccountStore()\n    if (account.token) {\n      options.headers = new Headers(options.headers)\n      options.headers.set('Authorization', `Bearer ${account.token}`)\n    }\n  },\n  onResponse({ response }) {\n    \u002F\u002F 统一拆后端的 { code, data, message } 包装\n    const body = response._data\n    if (body && body.code !== 200) throw createError({ message: body.message })\n    response._data = body.data\n  },\n})\n\nexport function useApiData(key, url, options = {}) {\n  return useFetch(url, { key, ...options, $fetch: $api })\n}\n```\n\n注意那个 `baseURL` 的写法，服务端直连 `127.0.0.1:3000`。一开始我偷懒让服务端也走相对路径让内部代理转发，结果在服务器上 502，折腾半天不如直连来得干脆。原因也简单：浏览器里的相对路径是相对当前域名的，有 Nginx 帮你转；但 Node 服务端发请求时没有\"当前域名\"这个概念，相对路径直接就废了。\n\n## 第二步：状态挪进 cookie\n\nPinia 的 store 在 CSR 里靠 localStorage 持久化，但服务端渲染时读不到 localStorage——服务端跑在 Node 里，哪来的 localStorage。直接读只会拿到 `undefined`，然后首屏渲染出一个\"未登录\"状态，水合时浏览器又说\"已登录\"，两边打架，控制台一片水合警告。\n\n所以凡是服务端渲染时就要用的状态，都得换成 cookie。我搬了两个：登录 token、暗黑模式主题。Nuxt 有现成的 `useCookie`，跟 ref 一个用法：\n\n```js\nconst token = useCookie('token', { maxAge: 60 * 60 * 24 * 30 })\n```\n\n服务端和浏览器端都读得到，水合的时候也不会对不上。\n\n纯客户端的状态（比如音乐播放器进度这种）不用管，留在 localStorage 里没事。判断标准就一句话：**这个状态会不会影响首屏 HTML？会影响，就进 cookie；只在用户点了之后才用，留着别动。**\n\n## 第三步：首页和文章页数据直出\n\n这是整个改造的灵魂。以前页面的数据是 mounted 之后请求接口再填进去的，现在要让服务端把数据取好、连着 HTML 一起发出去。\n\n用上面封的 `useApiData`，写起来跟以前的 `useFetch` 没啥区别：\n\n```js\n\u002F\u002F pages\u002Fpost\u002F[id].vue\nconst route = useRoute()\nconst { data: post } = await useApiData(`post-${route.params.id}`, `\u002Fapi\u002Fpost\u002F${route.params.id}`)\n```\n\n就多了个 await。Nuxt 会在服务端执行到这里，等接口返回，把数据填进 HTML 再发给浏览器。数据还会被序列化进页面，浏览器端接管的时候不用重新请求一遍——用户感觉打开速度反而变快了，因为省掉了\"白屏等 JS → 请求接口 → 渲染\"这一长串。\n\n我把首页、文章页、归档页这三个最需要 SEO 的页面做成了直出，别的页面（相册、留言板这种）保持客户端请求，省事，也没那个必要。**别追求全站 SSR，挑蜘蛛真正关心的页面做就行**，多做一个就多一份服务端开销和调试成本。\n\n## 第四步：暗黑模式防闪\n\n老站切暗黑模式是 JS 在浏览器里给 html 加 class，SSR 之后有个尴尬：服务端渲染时不知道用户选的什么主题，首屏按亮色输出，浏览器一接管发现用户是暗色，白屏闪一下再变黑，很掉价。深夜刷博客的人被白光糊一脸，体验灾难。\n\n解法就是第二步埋的伏笔——主题存 cookie，服务端直接读：\n\n```js\nconst theme = useCookie('theme')\nuseHead({\n  htmlAttrs: { class: () => theme.value === 'dark' ? 'dark' : '' },\n})\n```\n\n服务端输出的 HTML 带着 dark class，首屏就是暗的，一点不闪。\n\n![水合与暗黑模式防闪](https:\u002F\u002Fimg.sanshijiu.cn\u002Fcommon\u002F1\u002F17904215819885a0a7f55.png)\n\n顺便理解一下水合：上面图里那个\"服务端画骨架、浏览器装神经\"的过程，首屏内容是服务端给的，交互是水合之后才有的。凡是依赖\"随机数\"\"当前时间\"\"本地存储\"的逻辑，服务端和浏览器算出来不一样，水合必报警，这类代码统一挪到 `onMounted` 之后再跑。\n\n## 第五步：构建、打包、部署\n\n本地构建：\n\n```\nnpm run build\n```\n\n产物在 `.output\u002F` 目录，server 端 + 静态资源都在里面。这里有两个 Windows 特有的坑，都是我拿一晚上换来的。\n\n一是 Nuxt 在 Windows 上构建，`.output\u002Fserver\u002Fnode_modules` 里用的是 junction 链接（类似软链接），打包压缩再传到 Linux，链接全断，起不来就报 `Cannot find package 'hookable'`。所以打包时把这个目录排除掉：\n\n```\ntar --exclude='.output\u002Fserver\u002Fnode_modules' -czf nuxt-output.tar.gz .output\n```\n\n传到服务器解压，再进 `.output\u002Fserver` 里 `npm install --omit=dev` 装真依赖。\n\n二是 Node 版本。我服务器是 Node 17.9.1，而全局 fetch 是 Node 18 才内置的。症状很迷惑：页面能打开，数据全空，日志里接口请求全失败，本地却完全正常。我盯着这个\"本地好好的、线上全空\"的问题怀疑人生了一个多小时，最后才想起来两边 Node 版本不一样。解决方法就一行：\n\n```\nnode --experimental-fetch index.mjs\n```\n\n启动命令带上这个参数。加日志重定向放后台：\n\n```\nnohup node --experimental-fetch index.mjs > app.log 2>&1 &\n```\n\n**给后来者的建议：服务器该升 Node 就升，17 早就 EOL 了，我是嫌动环境麻烦才打补丁，能升别学我。** 另外 Nuxt 产物默认监听 3000，跟我的后端 API 撞了，写了个构建后的小脚本把默认端口改成 3001，不然起不来。\n\n## 第六步：Nginx 分流\n\n最后一步，把流量分对地方。一个域名进来，三类请求要去三个地方：接口和上传文件给老后端，前台页面给 Nuxt，后台和静态资源走老 dist。\n\n![Nginx 分流架构](https:\u002F\u002Fimg.sanshijiu.cn\u002Fcommon\u002F1\u002F179042158228329ba3547.png)\n\n我的配置核心就这几段：\n\n```nginx\n# 接口和上传的静态文件，走老后端 3000\nlocation \u002Fapi    { proxy_pass http:\u002F\u002F127.0.0.1:3000; }\nlocation \u002Fstatic { proxy_pass http:\u002F\u002F127.0.0.1:3000; }\n\n# 前台 SSR，其余所有路径\nlocation \u002F { proxy_pass http:\u002F\u002F127.0.0.1:3001; }\n\n# 前端构建产物带 hash，放心缓存\nlocation ^~ \u002F_nuxt\u002F {\n    proxy_pass http:\u002F\u002F127.0.0.1:3001;\n    expires 30d;\n}\n\n# 后台管理还是老静态文件，history 路由刷新要能兜回 index.html\nlocation ^~ \u002Fadmin {\n    root \u002Fwww\u002Fwwwroot\u002Fwww.sanshijiu.cn\u002Fdist;\n    try_files $uri $uri\u002F \u002Findex.html;\n}\nlocation = \u002Findex.html {\n    root \u002Fwww\u002Fwwwroot\u002Fwww.sanshijiu.cn\u002Fdist;\n}\n```\n\n单独说下 `location = \u002Findex.html` 这段，这个坑卡了我挺久。后台是老的 SPA，刷新 `\u002Fadmin\u002Fxxx` 时 Nginx 会 try_files 兜回 `\u002Findex.html`，但这个内部重定向会重新走一遍 location 匹配，被上面 `location \u002F` 的反代规则截胡，转给 Nuxt 去渲染，Nuxt 里没这个路由，404。给 `\u002Findex.html` 单独配一段精确匹配（`=` 是精确匹配，优先级最高），指回静态目录，才消停。\n\n改完 Nginx 记得 `nginx -t` 测一遍配置再 `nginx -s reload`，别直接 reload，语法错了线上瞬间 502，别问我怎么知道的。\n\n## 其他的坑\n\n捡印象深的补几个。\n\n**popper 报 `'placements' not found`**。Element Plus 依赖的 @popperjs\u002Fcore 是 CJS 包，具名导出在 Node 17 下解析不好，SSR 阶段直接炸。nuxt.config 里加一行让它内联：\n\n```ts\nvite: { ssr: { noExternal: ['@popperjs\u002Fcore'] } }\n```\n\n**水合警告刷屏**。我有个随机切换背景图的逻辑，服务端随机了一个，浏览器端又随机一个，两边对不上。凡是\"随机\"\"当前时间\"这类每次执行结果不同的代码，都得挪到客户端挂载之后跑，服务端别掺和。排查水合警告有个笨办法：看警告里的 DOM 差异节点，顺着那个组件往上找，十有八九能定位到。\n\n**sitemap.xml 和 robots.txt**。让后端直接吐这两个文件，文章列表查一遍库拼 XML，加密的文章不往里放。robots 把后台、个人中心、接口路径都 Disallow 掉。然后去站长平台提交 sitemap，剩下的交给时间。\n\n**页面 meta 别忘配**。SSR 只是让蜘蛛能读到内容，但标题、描述这些\"简历\"得自己写。用 Nuxt 的 `useSeoMeta` 给每个页面配 title、description、og 标签，文章页的标题直接用文章标题：\n\n```js\nuseSeoMeta({\n  title: () => post.value?.title,\n  description: () => post.value?.summary || post.value?.content?.slice(0, 100),\n  ogTitle: () => post.value?.title,\n  ogType: 'article',\n})\n```\n\n## 怎么确认成了\n\n别看页面显示正常——CSR 时代页面也显示正常。唯一的检验标准是源码：\n\n```\ncurl -s https:\u002F\u002F你的域名 | grep 某篇文章的标题\n```\n\n能 grep 出来，说明内容真的在 HTML 里，蜘蛛看得到。我改完源码从 1KB 变成 170 多 K，标题正文全躺在里面，成了。那一刻挺爽的，折腾几天就等这一行输出。\n\n再补两个验证手段：浏览器右键\"查看网页源代码\"（不是 F12 的 Elements，那个是 JS 跑完后的结果，CSR 也能看到内容，不算数）；以及百度搜索资源平台的\"抓取诊断\"，直接看百度蜘蛛抓到的原文，最权威。\n\n## 复盘：花了多久，值不值\n\n实际搬了大概三个晚上加一个周末。第一晚搭架子搬组件，第二晚死磕接口层和水合警告，部署那晚基本全耗在 Windows 打包链接和 Node 版本上——真正写业务代码的时间，反而不到三分之一。这也是我想分享的一点：**SSR 改造的难点从来不是新语法，而是\"同一套代码要在两个环境里跑\"带来的环境差异。**\n\n值不值？对个人博客来说，投入产出比其实见仁见智。但我觉得值，理由有三个：首屏确实快了，弱网和老设备感受明显；逼着自己把项目从\"能跑就行\"收拾到了\"结构清晰\"；以后写技术文章，至少有机会被需要的人搜到，而不是烂在自己的服务器里。\n\n如果你也打算动手，按这个清单过一遍，能避开我踩的大部分坑：\n\n1. 先搬一个最简单的页面跑通，再批量复制\n2. 服务端请求必须用完整地址，别写相对路径\n3. 影响首屏的状态进 cookie，其余留 localStorage\n4. 只给首页、详情页、列表页做直出，别贪多\n5. 随机\u002F时间相关逻辑全部挪到 onMounted 之后\n6. Windows 打包排除 `.output\u002Fserver\u002Fnode_modules`\n7. 服务器 Node 升到 18+，一劳永逸\n8. Nginx 改完先 `nginx -t`\n9. sitemap、robots、每页 meta 配齐，再去站长平台提交\n10. 最后用 curl grep 标题验收，别信眼睛\n\n百度什么时候收录不知道，反正这次不是它的问题了。收录了回来更新一句。\n\n如果你也是 Vue3 前后端分离的站，被收录问题折磨，上面这些步骤基本可以照抄。有拿不准的地方，评论区聊。",9,"2026-09-26T11:20:19.986Z","2026-09-30T09:28:56.802Z",{"id":11,"name":21,"color":22},"前端",0,{"id":12,"name":24},"Nuxt",{"id":10,"nickname":26,"avatarUrl":27},"叁拾玖","https:\u002F\u002Fimg.sanshijiu.cn\u002Favatar\u002F1\u002F1787397803188bebb136a.jpg",{"prev":29,"next":32},{"id":30,"title":31},33,"AI圈这半个月有点疯，随手记几条",{"id":33,"title":34},35,"都说ZCode送3亿Token是封口费？我查了时间线，事情不简单"]