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 组件树。所以预渲染的静态内容:
- 爬虫抓到:看到完整正文 ✓
- 用户打开:JS 加载前短暂看到正文(比空白屏体验更好),JS 就位后被 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,关键步骤:
import.meta.glob不能用(那是 Vite 运行时),改用fs.readdirSync递归遍历src/notes/**/*.md- 手写一个 20 行的 Frontmatter 解析器(复用
src/utils/notes.ts里的逻辑) - 用
markdown-it把 Markdown 渲染成 HTML(不需要 Shiki,爬虫不关心代码高亮) - 读
dist/index.html作为模板,替换<title>、注入<meta>、把正文塞进#app - 写
dist/notes/<slug>/index.html,最后产出sitemap.xml和robots.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. 主动提交给搜索引擎
预渲染只是"让爬虫能看到",想要被收录还得主动推:
- 百度搜索资源平台 → 添加站点 → 验证所有权(HTML 文件或 DNS)
- 提交 sitemap(进"普通收录")
- 开启"自动推送"——在页面里加一段 JS,有人访问就自动把 URL 推给百度
- Google Search Console 同理
关键:国内域名需要 ICP 备案才能稳定被百度收录;部署在 Cloudflare Pages 这种海外节点的站,访问速度本身就会影响抓取成功率。
6. 什么时候不值得做
- 后台管理系统:不需要被搜索,加这套反而拖慢构建
- 强登录态的应用:SEO 页很少,手写 meta 就够了
- 页面数量极大(几万+):构建时间会爆炸,应该切到 SSR 或动态渲染