Vue SPA 博客改为静态 HTML 输出解决爬虫无法索引文章内容
问题背景
基于 Vue 3 + Vite 的 SPA 博客,文章内容在构建时渲染为 HTML 字符串,存储为 JSON 文件(public/data/posts/{slug}.json)。运行时通过 fetch() 加载 JSON,再用 v-html 注入到弹窗组件中。
这套方案对搜索引擎爬虫和 AI 索引不友好:
- Googlebot 执行 JS 的能力有限
- AI 爬虫(如 ChatGPT)直接读 HTML 源码,拿不到 JSON 里的内容
- 用户分享链接时,接收方打开后只能看到空白首页(需要等 SPA 加载完再解析 URL 参数)
GitHub Pages 是纯静态托管,没有后端服务器,SSR 走不通。最终方案:构建时直接生成独立的 HTML 文件,每篇文章一个目录。
目录结构
改造前:
public/data/posts/esp32s3-esp-nn-sin-demo.json ← { "content": "<html>..." }
public/blog/嵌入式 & Linux/esp32s3-esp-nn-sin-demo/img.jpg改造后:
public/posts/esp32s3-esp-nn-sin-demo/
index.html ← 完整 HTML 页面(meta 标签 + 正文 + 样式引用)
img.jpg ← 图片和 HTML 同目录posts.json 索引文件保留,首页卡片列表仍依赖它加载文章元数据。slug 字段从 嵌入式 & Linux/esp32s3-esp-nn-sin-demo 改为 esp32s3-esp-nn-sin-demo,URL 不再包含分类路径。
构建流程
scripts/generate-data.js 负责整个流程:
- 扫描
src/content/blog/下的 Markdown 文件 - 解析 frontmatter,提取数学公式,用
marked+ Shiki 渲染为 HTML - 修复图片路径,复制图片到
public/posts/{postDir}/ - 调用
post-html-template.js生成完整 HTML 页面 - 生成
posts.json索引和sitemap.xml
公开文章和加密文章都输出为 HTML。加密文章的 HTML 内嵌 <script type="application/json" id="encrypted-data"> 存储密文,页面自带解密 UI 和内联的 Web Crypto 解密脚本。
package.json 构建脚本:
node scripts/generate-data.js && vite build && node scripts/generate-data.js --post-build第三步 --post-build 把 public/posts/ 重新复制到 dist/posts/,覆盖 Vite 构建时改写过的版本。
关键设计决策
扁平 URL 结构
所有文章放在 posts/ 根目录下,不按分类建子目录。分类仅用于首页筛选和侧边栏展示,不进入 URL。好处是改分类名不会导致已分享的链接失效。
图片用绝对路径
HTML 中图片使用 /posts/{postDir}/img.jpg 而非相对路径 img.jpg。原因是 SPA 弹窗通过 fetch() 获取 HTML 后用 innerHTML 注入首页 DOM,浏览器会以当前页面 URL 为基准解析相对路径,导致图片地址错误。
CSS 样式和 KaTeX CSS 用相对路径 ../post-styles.css,因为它们在 <head> 中引用,直接由浏览器加载,不存在 DOM 注入的上下文问题。
Vite dev 服务器中间件
Vite dev 服务器把 /posts/xxx/ 当作 SPA 路由,返回 index.html 而不是实际的文章 HTML 文件。通过 configureServer 钩子添加中间件拦截请求:
function postsMiddleware() {
return {
name: 'posts-middleware',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/posts/') && !req.url.includes('.')) {
const urlPath = decodeURIComponent(req.url).replace(/\/$/, '')
const filePath = resolve(__dirname, 'public' + urlPath + '/index.html')
if (fs.existsSync(filePath)) {
res.setHeader('Content-Type', 'text/html')
res.end(fs.readFileSync(filePath, 'utf-8'))
return
}
}
next()
})
}
}
}直接访问文章 URL 的处理
文章 HTML 内嵌一段 JS:
if (window.top === window) {
window.location.replace('/?article={slug}');
}浏览器直接访问文章 URL 时,先加载 HTML 页面,脚本检测到是顶层窗口(非 SPA 弹窗的 fetch),自动跳转到首页并携带 ?article= 参数。首页的 checkUrlForArticle() 读取该参数,自动打开对应文章弹窗,URL 替换为 /posts/{slug}。
踩过的坑
Vite 构建改写 public 下 HTML 的路径
vite build 会处理 public/ 目录下的 HTML 文件,把 src="img.jpg" 解析为 src="/posts/嵌入式 & Linux/xxx/img.jpg" 绝对路径。解决办法是构建脚本在 Vite 完成后执行 --post-build,用未被改写的版本覆盖 dist/posts/。
dev 服务器缓存旧数据
npm run dev 启动的 generate-data.js --watch 进程使用 Node.js 模块缓存,修改构建脚本代码后不会自动生效。需要杀掉旧进程重新启动 dev 服务器。Vite 本身也有进程级缓存,即使文件已更新,正在运行的 Vite 进程仍返回旧内容。
SPA 弹窗中相对图片路径解析错误
最初图片用相对路径 img.jpg,在独立访问时正常,但在 SPA �弹窗中加载失败。原因是 innerHTML 注入后浏览器以当前页面 URL(/ 或 /posts/xxx)为基准解析相对路径,而不是以文章 HTML 所在目录为基准。改为绝对路径 /posts/{postDir}/img.jpg 后解决。
最终效果
- 每篇文章是独立 HTML 页面,包含完整的 meta 标签、Open Graph、Schema.org 结构化数据
sitemap.xml列出所有文章 URL,搜索引擎可直接发现和索引- 首页 SPA 体验不变,点击卡片弹窗显示文章内容
- 直接访问文章 URL 自动跳转首页并打开弹窗
- 加密文章同样输出 HTML,密文和解密 UI 内嵌在页面中
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。