SPA 的 SEO 困境与构建期预渲染

这篇笔记就是在解决 in4vue 自己的问题:"为什么我的站上线了,百度却搜不到?"

1. 先搞清楚:为什么搜不到

打开浏览器的 DevTools → Network → Disable cache → 访问首页 → 看 Doc 类型的那条响应。你会看到返回的 HTML 长这样:

<!doctype html>
<html>
  <head><title>in4vue</title></head>
  <body>
    <div id="app"></div>
    <script type="module" src="/assets/index-xxx.js"></script>
  </body>
</html>

正文一个字都没有

所有内容(笔记标题、摘要、列表)都是 JS 下载并执行后,Vue 挂载到 #app 才出现的。

Java 类比

你写 SpringBoot 接口返回 JSON,前端自己渲染——这是 API + SPA 架构。 你写 Thymeleaf / JSP,后端直接返回渲染好的 HTML——这是 SSR 架构。

架构 首次响应的 HTML 对 SEO 友好度
SSR (JSP) 完整正文 很好
SPA (Vue) 空壳 很差
预渲染 完整正文(构建时生成) 很好(静态站专用)

百度尤其不友好

Google 的爬虫已经能跑 JS(V8 内核,等同 Chrome),所以 SPA 对 Google 勉强能吃。但百度爬虫对 JS 执行的支持非常有限,绝大多数情况下它看到的就是那个空壳——于是抓不到任何正文,索引为零。

字节头条、搜狗、360 同理。

2. 三种应对方式

方案 做什么 成本
SSR 每次请求都在服务器跑 Vue 渲染出 HTML(Nuxt) 高,要 Node 服务器,@vue/repl / 浏览器 API 要改
预渲染 构建时把每条 URL 对应的 HTML 提前生成成静态文件 低,仍然是静态部署
动态渲染 通过 UA 判断是爬虫就返回预渲染版,否则返回 SPA(Prerender.io 中,要中间层

in4vue 是纯笔记站,内容几乎静态——预渲染是性价比最高的选择

类比 Java:SSR 像每次请求都过一遍 JSP 引擎;预渲染像 Maven 在构建期就把所有页面提前烘焙成静态 HTML,交给 Nginx 直接返回。

3. 基础设施:robots.txt / sitemap.xml / meta

不管走哪条路,这三件套是必备:

robots.txt

告诉爬虫"哪些能爬、sitemap 在哪"。放在站点根目录:

User-agent: *
Allow: /

Sitemap: https://your-domain.com/sitemap.xml

sitemap.xml

把所有可索引的 URL 列出来,爬虫会按这个清单去抓:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://your-domain.com/notes/01-%E5%9F%BA%E7%A1%80/01-es6</loc>
    <lastmod>2026-05-10</lastmod>
    <priority>0.8</priority>
  </url>
  ...
</urlset>

注意:URL 里的中文必须 URL 编码,用 encodeURI 而不是 encodeURIComponent(后者会把 / 也编掉)。

每页独立的 meta

<title><meta name="description"> 要每页都不一样。用 composable 包一下即可,本项目的 src/composables/usePageMeta.ts 就是这个用法:

usePageMeta({
  title: computed(() => note.value?.title),
  description: computed(() => note.value?.summary),
})

类比 Java:相当于每个 Controller 返回前往 Model 塞不同的 title/description,JSP 顶部 <c:out> 输出。

4. in4vue 的预渲染实现

思路非常直白:vite build 完成后,跑一个脚本,对每篇笔记生成一份 dist/notes/<slug>/index.html

关键技巧:把正文直接塞进 <div id="app">

<div id="app">
  <article class="prerender-content">
    <h1>ES6+ 核心语法</h1>
    <p>...笔记正文渲染后的 HTML...</p>
  </article>
</div>

"这不会和 Vue 的挂载冲突吗?" ——不会

看 Vue 3 createApp().mount() 的源码,内部有一行:

container.innerHTML = ''
const proxy = mount(container, false, ...)

挂载前会先清空容器,然后再渲染 Vue 组件树。所以预渲染的静态内容:

流程串起来

vite build
  ↓
dist/
  index.html   (空壳)
  assets/*.js
  ↓
node scripts/prerender.mjs
  ↓
dist/
  index.html   (注入了首页内容)
  notes/
    01-基础/01-es6/index.html   (注入了这篇笔记的正文)
    ...
  sitemap.xml
  robots.txt

核心脚本见 scripts/prerender.mjs,关键步骤:

  1. import.meta.glob 不能用(那是 Vite 运行时),改用 fs.readdirSync 递归遍历 src/notes/**/*.md
  2. 手写一个 20 行的 Frontmatter 解析器(复用 src/utils/notes.ts 里的逻辑)
  3. markdown-it 把 Markdown 渲染成 HTML(不需要 Shiki,爬虫不关心代码高亮)
  4. dist/index.html 作为模板,替换 <title>、注入 <meta>、把正文塞进 #app
  5. dist/notes/<slug>/index.html,最后产出 sitemap.xmlrobots.txt

一个容易踩的坑:Cloudflare Pages 的 _redirects

SPA 通常会在 public/_redirects 里写:

/*  /index.html  200

这是让深链(如 /notes/xxx 刷新时)不 404 的兜底。

好消息:Cloudflare Pages 的规则是"静态文件优先于 _redirects 兜底"。所以当 dist/notes/xxx/index.html 存在时,它直接被返回,兜底规则只对不存在的路径生效。预渲染和 SPA 可以和平共处。

类比 Java:像 Nginx 的 try_files $uri $uri/ /index.html——先找真实文件,找不到才走 SPA 入口。

5. 主动提交给搜索引擎

预渲染只是"让爬虫能看到",想要被收录还得主动推:

  1. 百度搜索资源平台 → 添加站点 → 验证所有权(HTML 文件或 DNS)
  2. 提交 sitemap(进"普通收录")
  3. 开启"自动推送"——在页面里加一段 JS,有人访问就自动把 URL 推给百度
  4. Google Search Console 同理

关键:国内域名需要 ICP 备案才能稳定被百度收录;部署在 Cloudflare Pages 这种海外节点的站,访问速度本身就会影响抓取成功率。

6. 什么时候不值得做

延伸阅读