云计算百科
云计算领域专业知识百科平台

别再迷信 SSR 了:我用 Vue3 + Puppeteer 给 32 个工具页做了 SSG 预渲染

写在前面

最近在做一个带大量静态页面的个人站点,遇到一个经典问题:

Vue 写起来很爽,但 SPA 对搜索引擎实在不友好。

爬虫抓到的永远是一个空的 <div id="app"></div>,页面内容全靠 JS 在浏览器里执行后才渲染出来。虽然现在 Google 对 JS 渲染的支持已经不错,但百度等国内搜索引擎的支持仍然一言难尽。

上 Nuxt / SSR 吗?想了想成本:

  • 需要 Node 服务做中间层(SSR 必须有服务器实时渲染);
  • 我的页面绝大多数是内容固定的工具页,实时渲染纯属浪费;
  • 增加一套框架心智负担,useAsyncData、服务端/客户端两端的坑都要踩。

最后选了一个更"笨"但极其省心的方案:构建完成后,用无头浏览器把每个路由跑一遍,把渲染好的 HTML 存成静态文件,也就是 SSG(Static Site Generation)。

这篇文章把完整实现记录下来,包含:动态 SEO meta 管理、预渲染脚本、路由收集策略、踩到的坑。

在线效果可以看这里:https://www.sourcekit.cn ,站点里的 32 个工具页(JSON 格式化、房贷计算器、Cron 表达式等)都是这套方案产出的,直接查看网页源代码就能看到完整的静态内容。 在这里插入图片描述


一、整体思路

先看技术栈:

类别选型
框架 Vue 3.5(Composition API)
构建 Vite 6
状态 Pinia
UI Element Plus(按需自动引入)
预渲染 puppeteer-core + 本机 Chrome
压缩 vite-plugin-compression(gzip)

预渲染的核心流程其实就四步:

1. vite build 正常构建,产出 dist/(此时是标准 SPA)
2. 在 dist/ 上起一个临时静态服务器(带 SPA history 回退)
3. 用 puppeteer 逐个打开路由,等 Vue 渲染完,抓取完整 DOM
4. 把渲染后的 HTML 写回 dist/<路由>/index.html

最终产物是一堆纯静态文件,丢到 Nginx / OSS / CDN 上即可,不需要 Node 服务。用户访问时拿到的是带完整内容的 HTML,客户端再由 Vue 完成 hydration(激活)接管交互。

# package.json 里的构建命令
"build:prod": "vite build && node scripts/prerender.mjs"


二、第一步:先把动态 SEO meta 管好

预渲染要抓的不只是页面 DOM,更重要的是每个页面的 <title>、description、keywords 这些 TDK 标签。

我封装了一个 useSeo composable,在每个页面的 setup 里调用即可:

// src/composables/useSeo.js
import { watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import siteConfig from '@/config/siteConfig'

export function useSeo(options = {}) {
const route = useRoute()

// 设置 <meta>,已存在则复用,不存在则创建
function setMeta(name, content, attr = 'name') {
if (!content) {
removeMeta(name, attr)
return
}
let el = document.head.querySelector(`meta[${attr}="${name}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, name)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}

watchEffect(() => {
const raw = typeof options === 'function' ? options() : options
const resolve = (v) => (typeof v === 'function' ? v() : v)

const {
title = siteConfig.siteName,
description = siteConfig.siteDescription,
keywords = siteConfig.siteKeywords,
noindex = false
} = raw

// 标题拼接规则:内页 → "页面标题 | 站点名"
document.title = title === siteConfig.siteName
? `${title} – ${siteConfig.siteSlogan}`
: `${title} | ${siteConfig.siteName}`

setMeta('description', resolve(description))
setMeta('keywords', resolve(keywords))

// canonical 规范化链接,避免重复收录
const canonicalUrl = `https://${siteConfig.domain}${route.fullPath.split('?')[0]}`
setLink('canonical', canonicalUrl)

// Open Graph:分享到社交平台时的标题/描述/封面
setMeta('og:title', document.title, 'property')
setMeta('og:description', resolve(description), 'property')
setMeta('og:url', canonicalUrl, 'property')
setMeta('og:type', 'website', 'property')

// robots 索引控制
setMeta('robots', noindex ? 'noindex,nofollow' : 'index,follow')
})
}

页面里这样用:

// src/views/tools/detail.vue
import { useSeo } from '@/composables/useSeo'

useSeo(() => ({
title: tool.value.seo?.title || tool.value.name,
description: tool.value.seo?.description || tool.value.description,
keywords: tool.value.name
}))

这里有个细节值得说:options 支持传函数。因为工具详情页的标题依赖异步请求回来的数据,传函数后配合 watchEffect,数据一到 meta 就会自动更新。预渲染时只要等数据加载完,抓到的就是正确的 TDK。

另外建议每个工具都配上独立的长尾关键词标题,比如:

  • 房贷计算器 → 房贷计算器2026最新版 – 等额本息/等额本金/提前还款测算
  • JSON 格式化 → JSON在线格式化校验工具 – 压缩/转义/中文unicode不转码

这种"一个工具一个长尾词"的页面矩阵,是工具站拿搜索流量的基本盘。


三、核心:预渲染脚本实现

3.1 为什么用 puppeteer-core 而不是 puppeteer

puppeteer 安装时会自动下载一份 Chromium(约 300MB),又慢又占地方。而 puppeteer-core 不带浏览器,直接调用本机已经装好的 Chrome 或 Edge:

import puppeteer from 'puppeteer-core'

// 跨平台浏览器候选路径
const BROWSER_PATHS = [
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium'
]

function findBrowser() {
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH
}
for (const p of BROWSER_PATHS) {
if (fs.existsSync(p)) return p
}
return null
}

CI 环境没有桌面浏览器时,可以用环境变量 CHROME_PATH 显式指定,或者装一个 chromium 包。

3.2 起一个带 SPA 回退的静态服务器

预渲染时浏览器访问的是构建产物,所以要在 dist/ 上起个 HTTP 服务。直接读文件用 file:// 协议会有一堆跨域和 history 路由的问题:

function startServer() {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(req.url.split('?')[0])
if (urlPath === '/') urlPath = '/index.html'

let filePath = path.join(DIST_DIR, urlPath)

// 防止路径穿越
if (!filePath.startsWith(DIST_DIR)) {
res.writeHead(403)
return res.end('Forbidden')
}

if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
// SPA 回退:已预渲染的目录用其 index.html,否则回退根 index.html
const fallback = path.join(filePath, 'index.html')
filePath = fs.existsSync(fallback)
? fallback
: path.join(DIST_DIR, 'index.html')
}

const data = fs.readFileSync(filePath)
const ext = path.extname(filePath).toLowerCase()
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' })
res.end(data)
})

return new Promise((resolve) => {
server.listen(PORT, HOST, () => resolve(server))
})
}

3.3 路由清单怎么来:静态 + 正则提取 + 接口动态收集

这是我觉得设计得比较顺手的一点。路由分三类来源:

第一类:固定的核心页面,写在配置里:

// src/config/seoRoutes.js
export const PRERENDER_PATHS = [
'/', '/sources', '/tools', '/services', '/blog',
'/privacy', '/terms', '/notice'
]

第二类:工具页,从工具配置文件里用正则批量提取,避免手动维护两份清单:

async function collectToolPaths() {
const toolListPath = path.resolve(__dirname, '../src/config/tools/toolList.js')
const content = fs.readFileSync(toolListPath, 'utf-8')
return […content.matchAll(/path:\\s*'(\\/tool\\/[^']+)'/g)]
.map(m => m[1])
}

第三类:动态详情页(源码详情、博客文章),ID 是后端数据驱动的,构建时调接口拿 ID 列表:

async function fetchDetailRoutes(apiPath, prefix, label) {
try {
const res = await fetch(`${API_BASE}${apiPath}?pageNum=1&pageSize=500`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const ids = (data.rows || []).map(r => r.id).filter(Boolean)
return ids.map(id => `${prefix}${id}`)
} catch (e) {
// 后端没启动就优雅跳过,不阻塞构建
console.warn(`[prerender] 无法获取${label}列表,跳过`)
return []
}
}

三类合并就是最终清单。注意第三类的容错:本地构建时后端没开也不该让整个构建失败,跳过详情页即可。

3.4 打开页面、等渲染、抓 DOM

browser = await puppeteer.launch({
executablePath: browserPath,
headless: 'new',
args: ['–no-sandbox', '–disable-setuid-sandbox', '–disable-gpu']
})

for (const route of ROUTES) {
const page = await browser.newPage()
try {
await page.setViewport({ width: 1440, height: 900 })
await page.goto(`http://${HOST}:${PORT}${route}`, {
waitUntil: 'networkidle0',
timeout: 30000
})

// 关键:等 #app 里真的有内容了,说明 Vue 挂载完成
await page.waitForFunction(
() => {
const app = document.querySelector('#app')
return app && app.innerHTML.trim().length > 500
},
{ timeout: 15000 }
)
// 再缓冲 300ms,等异步 meta 注入、字体加载稳定
await new Promise((r) => setTimeout(r, 300))

const html = await page.content()

// 输出:/tool/json → dist/tool/json/index.html
const targetDir = route === '/'
? DIST_DIR
: path.join(DIST_DIR, route.replace(/^\\//, ''))
fs.mkdirSync(targetDir, { recursive: true })
fs.writeFileSync(path.join(targetDir, 'index.html'), html, 'utf-8')
} catch (e) {
console.error(`[prerender] ✗ ${route} – ${e.message}`)
} finally {
await page.close()
}
}

两个等待条件是保证预渲染质量的关键:

  • waitUntil: 'networkidle0':等网络空闲,确保异步数据请求完成;
  • waitForFunction 判断 #app 内容长度:确保 Vue 真的渲染出了 DOM,而不是抓到空壳。

  • 四、Nginx 配置:别让预渲染白做

    静态文件部署到 Nginx 后,有两个容易忽略的点。

    第一,gzip 静态压缩优先用构建产物里的 .gz:

    gzip_static on; # 直接返回 vite 构建时生成的 .gz,省实时压缩 CPU

    第二,history 路由 + 已预渲染目录的回退顺序:

    location / {
    # 先找预渲染好的静态 index.html
    try_files $uri $uri/ /index.html;
    }

    Nginx 的 try_files 天然就按「文件 → 目录 → SPA 回退」的顺序找,和我们预渲染脚本里的逻辑一致,不用额外写规则。


    五、效果验证

    构建完之后,最直接的验证方式——不要 F12 看渲染后的页面(那是 JS 执行过的),而是:

    # 用 curl 看服务器最初返回的原始 HTML
    curl https://你的域名/tool/mortgage

    如果返回的 HTML 里能直接看到工具标题、表单、FAQ 文本以及完整的 <meta name="description">,说明预渲染成功了。这也正是搜索引擎爬虫拿到的内容。

    线上这套方案目前的表现:

    • 32 个工具页 + 核心页面全部产出独立静态 HTML;
    • 每页独立 TDK + canonical + Open Graph;
    • 纯静态部署,一台最基础的 2 核 2G 服务器就能扛,配合 CDN 基本无压力;
    • 交互完全不受影响,Vue hydration 后该怎么用怎么用。

    六、这套方案适合 / 不适合谁

    适合:

    • 页面内容相对固定(工具站、文档站、博客、营销页、产品官网);
    • 想白嫖搜索引擎流量,但不想引入 SSR 的 Node 服务层;
    • 希望部署简单,产物能丢任意静态托管。

    不适合:

    • 内容高度实时、每次请求都不同(那是 SSR 的地盘);
    • 页面数量极其庞大(几万个页面预渲染会很慢,需要做并发和增量);
    • 强依赖个性化登录态渲染的页面(这类页本来就不该被搜索引擎收录)。

    七、还能继续优化的点

    如果后续要继续打磨,有几个方向:

  • 预渲染并发:目前是串行逐页打开,可以用浏览器多 tab 或多 page 并发,构建速度能再快几倍;
  • 增量预渲染:只重新渲染内容有变更的页面,配合构建缓存;
  • sitemap 自动生成:预渲染时顺便根据路由清单输出 sitemap.xml;
  • 关键内容内联:把首屏关键 CSS/数据内联进 HTML,进一步压缩 LCP。

  • 最后

    很多人一遇到 SEO 需求就条件反射上 Nuxt/Next,但 SEO 的本质需求其实只是**“让爬虫在第一次请求时拿到有内容的 HTML”**。对于内容固定的站点,SSG 用一次性的构建时成本,换掉了 SSR 需要长期维护的实时渲染服务,这笔账怎么算都划算。

    技术选型没有银弹,匹配自己的业务形态才是最重要的。

    完整的在线效果和那 32 个免费工具可以在这里体验:https://www.sourcekit.cn ,像 JSON 格式化、房贷计算器、Cron 生成、UUID、二维码这些日常开发小工具都免注册直接用。有兴趣的同学可以 Ctrl + U 看看源代码,对比一下预渲染前后的 HTML 差异。

    如果这篇文章对你有帮助,欢迎点赞收藏,有问题评论区交流。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 别再迷信 SSR 了:我用 Vue3 + Puppeteer 给 32 个工具页做了 SSG 预渲染
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!