Vite 项目的 SSG 与预渲染:vike、路由预渲染与静态分发

在 Vite 项目里落地 SSG 与预渲染:SPA 空 HTML 的 SEO 短板、CSR/SSR/SSG/ISR 的渲染谱系、vike 与 vite-plugin-ssr 的静态模式、路由预渲染脚本、构建期数据获取与注入、动态 meta 与 JSON-LD、sitemap 与 robots 生成、静态产出的 CDN 分发、增量重建与高频陷阱清单。

引言

单页应用最大的隐性代价是首屏只有一具空壳: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 与抓取困境

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 的 assetsmax-age 一年加 immutable内容变则文件名变
HTMLno-cache保证拿到最新版本
sitemap.xmlmax-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,是最快的验证手段。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 中的 3D 与 WebGL 工程化:Three.js、模型纹理压缩与渲染性能治理
  2. Vite 项目的 GraphQL 数据层:Apollo、urql、codegen 与缓存失效实战
  3. Vite 项目部署平台适配实战:Vercel、Netlify、Cloudflare Pages 与自建方案