引言
单页应用最大的隐性代价是首屏只有一具空壳:index.html 里除了挂载点和几个 <script> 标签,没有任何正文内容。搜索引擎爬虫与社交平台的抓取器拿到的就是这个空壳——于是页面收录慢、分享卡片没有标题与缩略图。SSG(静态站点生成)与预渲染正是针对这一短板的标准解法:在构建期把每个路由渲染成完整的 HTML,让首屏内容与元信息一次到位。
本文从 SPA 的 SEO 短板讲起,梳理 CSR、SSR、SSG 与 ISR 的渲染谱系,再落到 Vite 生态的两条主路径——vike(原 vite-plugin-ssr)的 SSG 模式与路由预渲染脚本,接着覆盖构建期数据获取、动态 meta 与 JSON-LD 注入、sitemap 与 robots 生成、静态产出的 CDN 分发,最后给出增量重建方案与高频陷阱清单。
前置:SSR 框架与渲染模式、构建产物与多入口。产物分析见 包体分析与性能监控:Bundle Analyzer、性能预算与门禁。
目录
- 1. SPA 的 SEO 短板:空 HTML 与抓取困境
- 2. 渲染模式谱系:CSR、SSR、SSG 与 ISR
- 3. vike 与 vite-plugin-ssr 的 SSG 模式
- 4. 路由预渲染:vite-plugin-prerender 与自写脚本
- 5. 构建期数据获取与静态数据注入
- 6. 动态 meta、Open Graph 与 JSON-LD
- 7. sitemap、robots 与 canonical 生成
- 8. 静态产出与 CDN 分发策略
- 9. 增量重建与内容更新
- 10. 常见陷阱与落地清单
1. SPA 的 SEO 短板:空 HTML 与抓取困境
1.1 空壳 HTML 的三重代价
Vite 的 SPA 产物 dist/index.html 里只有一个挂载点 <div id="app"></div> 和一行 <script type="module">,正文、标题、描述全部由运行时 JS 生成。这带来三个问题:
收录慢 → 爬虫需要执行 JS 才能看到内容,抓取预算被浪费
分享差 → 社交抓取器不执行 JS,卡片只有裸链接
首屏慢 → 白屏时间取决于 JS 下载与执行,LCP 被拖后
1.2 爬虫到底执行不执行 JS
主流搜索引擎的渲染能力参差不齐,且渲染队列有延迟:
| 抓取方 | 执行 JS | 说明 |
|---|---|---|
| Googlebot | 是 | 分两阶段,渲染结果延迟数天 |
| 社交平台抓取器 | 否 | 只读初始 HTML 的 meta |
| 多数 SEO 工具 | 否 | 按初始 HTML 评估 |
结论很明确:不能把内容渲染赌在爬虫执行 JS 上。
1.3 预渲染要解决什么
预渲染的目标不是取代 JS 应用,而是在构建期把「爬虫与首屏需要的 HTML」提前固化,同时保留客户端水合后的完整交互。
记忆:SPA 的短板是空 HTML——收录慢、分享差、首屏白屏;预渲染在构建期固化 HTML,让不执行 JS 的抓取器也能拿到内容。
2. 渲染模式谱系:CSR、SSR、SSG 与 ISR
2.1 四种模式对照
| 模式 | HTML 生成时机 | 首屏 | 数据新鲜度 |
|---|---|---|---|
| CSR | 浏览器运行时 | 空壳 | 实时 |
| SSR | 每次请求 | 完整 | 实时 |
| SSG | 构建期一次 | 完整 | 构建时快照 |
| ISR | 构建期加按需再生成 | 完整 | 准实时 |
2.2 选择判据
内容频繁变、需登录态、强个性化 → SSR
内容稳定、追求极致首屏与低成本 → SSG
内容量极大、无法全量构建 → ISR 或按需再生成
纯后台系统、无 SEO 诉求 → CSR 就够
2.3 SSG 的代价
SSG 并非免费午餐:构建时间随路由数线性增长,且任何内容更新都要重新构建,因此路由数量、构建耗时、更新频率三者共同决定它是否划算。
记忆:CSR 空壳、SSR 每次请求渲染、SSG 构建期一次、ISR 按需再生成——内容稳定性与规模决定选哪种,SSG 的代价是构建时间随路由数增长。
3. vike 与 vite-plugin-ssr 的 SSG 模式
3.1 从 vite-plugin-ssr 到 vike
vite-plugin-ssr 已更名为 vike,它把「路由 + 数据 + 渲染」抽象成一套与框架无关的约定,同时支持 SSR 与 SSG。
npm i vike vike-react
3.2 开启预渲染
在 vite.config.ts 中启用插件:
// vite.config.ts
import { defineConfig } from 'vite'
import vike from 'vike/plugin'
export default defineConfig({
plugins: [vike({ prerender: true })],
})
单个页面通过 +config.ts 里的 prerender: true 决定是否静态化;开启后插件会遍历所有声明静态化的路由,逐个渲染并写出 dist/client/<路由>/index.html 与客户端水合脚本。
记忆:vike 是 vite-plugin-ssr 的新名字——用
prerender: true让路由在构建期渲染成完整 HTML,客户端脚本仍负责水合。
4. 路由预渲染:vite-plugin-prerender 与自写脚本
4.1 用插件做预渲染
// vite.config.ts
import prerender from 'vite-plugin-prerender'
export default defineConfig({
plugins: [
prerender({
routes: ['/', '/about', '/posts/hello'],
renderer: '@prerenderer/renderer-puppeteer',
}),
],
})
4.2 自写 postbuild 脚本
插件方案依赖无头浏览器,重且慢。路由可枚举时,用脚本直接遍历更轻:
// scripts/prerender.mjs
import { readFile, writeFile, mkdir } from 'node:fs/promises'
const routes = ['/', '/about', '/posts/hello']
const template = await readFile('dist/index.html', 'utf8')
for (const route of routes) {
const html = await render(route, template)
const dir = 'dist' + (route === '/' ? '' : route)
await mkdir(dir, { recursive: true })
await writeFile(dir + '/index.html', html)
}
4.3 两种方案对比
| 方案 | 依赖 | 速度 | 适用 |
|---|---|---|---|
| vite-plugin-prerender | 无头浏览器 | 慢 | 路由少、需真实渲染 |
| 自写脚本 | 纯 Node | 快 | 路由可枚举、模板可控 |
记忆:预渲染两条路——插件靠无头浏览器渲染真实页面但慢,自写 postbuild 脚本遍历路由写出 HTML 又快又轻,路由可枚举时优先后者。
5. 构建期数据获取与静态数据注入
5.1 构建期取数
静态站点的数据在构建期取好,随 HTML 一起产出:
// scripts/build-data.mjs
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
await writeFile('src/data/posts.json', JSON.stringify(posts))
页面直接 import posts from './data/posts.json',数据就被打进产物。
5.2 注入到 HTML
若不想把数据塞进 JS 包,可用 transformIndexHtml 注入:
export default defineConfig({
plugins: [
{
name: 'inject-data',
transformIndexHtml(html) {
return html.replace(
'</head>',
`<script>window.__DATA__=${JSON.stringify(data)}</script></head>`,
)
},
},
],
})
5.3 取数的边界
构建期取数内容随构建冻结、更新需重建;运行时取数虽实时,但首屏拿不到、SEO 不可见。稳妥做法是关键内容构建期注入,个性化数据运行时补。
记忆:构建期把数据取好并随 HTML 注入——关键内容走构建期保证 SEO 可见,个性化数据留给运行时补,别把首屏内容押在客户端请求上。
6. 动态 meta、Open Graph 与 JSON-LD
6.1 每个路由独立 meta
预渲染的核心收益之一是每个 HTML 都有自己的一套 meta:
<title>Vite 的 SSG 与预渲染 | 站点名</title>
<meta name="description" content="页面描述" />
<meta property="og:title" content="分享标题" />
<meta property="og:image" content="https://cdn.example.com/cover.png" />
在渲染阶段按路由替换占位符(如 html.replace('<!--title-->', page.title))即可。
6.2 JSON-LD 结构化数据
结构化数据帮助搜索引擎理解页面语义:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Vite 的 SSG 与预渲染",
"datePublished": "2026-10-03"
}
</script>
6.3 易错点
| 问题 | 后果 | 处理 |
|---|---|---|
| 所有路由同一 title | 重复内容降权 | 按路由渲染 |
| og:image 用相对路径 | 分享卡片无图 | 用绝对 URL |
| JSON-LD 与正文不符 | 可能被判作弊 | 与页面内容一致 |
记忆:预渲染的价值一半在 meta——每个路由独立 title、description 与 OG,og:image 必须绝对 URL,JSON-LD 要与正文一致。
7. sitemap、robots 与 canonical 生成
7.1 生成 sitemap
路由可枚举时,sitemap 完全可以构建期生成:
// scripts/sitemap.mjs
const routes = ['/', '/about', '/posts/hello']
const urls = routes.map((r) => ` <url><loc>https://example.com${r}</loc></url>`).join('\n')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`
await writeFile('dist/sitemap.xml', xml)
7.2 robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Sitemap: https://example.com/sitemap.xml
7.3 canonical 的坑
预渲染后容易出现「同一内容多个 URL」(带尾斜杠与不带、带查询参数与不带)。每个页面必须声明唯一的 canonical,否则权重被稀释:
<link rel="canonical" href="https://example.com/about" />
记忆:sitemap 与 robots 构建期生成即可——canonical 必须每页唯一,尾斜杠与查询参数造成的多 URL 是权重稀释的头号原因。
8. 静态产出与 CDN 分发策略
8.1 静态产物的目录结构
预渲染把路由变成真实目录:
dist/ index.html
about/index.html
posts/hello/index.html
assets/index-a1b2c3.js
这种结构对静态托管天然友好——服务器按路径直接命中文件,无需任何重写规则。
8.2 缓存策略
| 资源 | 缓存头 | 理由 |
|---|---|---|
| 带 hash 的 assets | max-age 一年加 immutable | 内容变则文件名变 |
| HTML | no-cache | 保证拿到最新版本 |
| sitemap.xml | max-age 一小时 | 更新频率低 |
8.3 回源与刷新
- 发布时先上传 assets,再上传 HTML(避免旧 HTML 引用新资源前资源缺失)
- 刷新 CDN 时只刷 HTML 与 sitemap,不动带 hash 的资源
- 用目录默认索引命中 about/ 到 about/index.html
记忆:预渲染产出是纯静态目录树——带 hash 资源永久缓存、HTML 一律 no-cache、发布顺序先资源后 HTML,CDN 刷新只刷 HTML。
9. 增量重建与内容更新
9.1 全量重建的瓶颈
路由上千后,每次内容更新都全量重建会拖垮发布节奏。解法是只重建受影响的路由。
9.2 按需重建
// scripts/rebuild.mjs
const changed = process.argv.slice(2) // 变更的 slug 列表
const routes = changed.map((s) => `/posts/${s}`)
await prerenderRoutes(routes) // 只渲染这些路由
9.3 与内容源的联动
CMS webhook → CI 触发 → 计算受影响路由 → 局部预渲染 → 增量上传
纯静态托管 → 无服务端,只能全量重建后整体替换
| 方案 | 更新粒度 | 复杂度 |
|---|---|---|
| 全量重建 | 整站 | 低 |
| 局部预渲染 | 单路由 | 中 |
| 按需再生成 | 单路由、无重建 | 高 |
记忆:内容规模上来后必须做增量——用 CMS webhook 驱动 CI,只对受影响路由做局部预渲染再增量上传,全量重建只留给小站。
10. 常见陷阱与落地清单
10.1 高频陷阱
| 现象 | 原因 | 处理 |
|---|---|---|
| 预渲染后仍空 HTML | 挂载点未产出内容 | 检查渲染是否真的执行 |
| 水合报错 | 两端 HTML 不一致 | 保证同构、避免随机值 |
| 路由 404 | 静态目录缺 index.html | 确认每路由都写出文件 |
| meta 不生效 | 只在客户端设置 | 改为构建期注入 |
| 首屏闪烁 | 预渲染与水合结果差异大 | 数据构建期与运行期一致 |
| sitemap 收录旧页 | 未随构建更新 | 构建期重新生成 |
10.2 水合不一致的根因
水合报错几乎都来自渲染期与运行期的差异:Date.now()、Math.random()、window 判断、时区。原则是首屏渲染只用构建期确定的数据。
10.3 落地清单
□ 关键路由已预渲染并产出完整 HTML
□ 每路由独立 title、description 与 OG,og:image 用绝对 URL
□ JSON-LD 与正文一致
□ sitemap.xml 与 robots.txt 构建期生成,每页 canonical 唯一
□ 带 hash 资源 immutable、HTML no-cache,发布顺序先 assets 后 HTML
□ 路由上千时已做局部预渲染
□ 已用 curl 抓取产出 HTML 核对内容与 meta
记忆:预渲染的坑集中在「没渲染出来、水合不一致、meta 没进 HTML、缓存错位」——用 curl 直接抓产物 HTML 核对内容与 meta,是最快的验证手段。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。