写在前面
最近在做一个带大量静态页面的个人站点,遇到一个经典问题:
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()
}
}
两个等待条件是保证预渲染质量的关键:
四、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 的地盘);
- 页面数量极其庞大(几万个页面预渲染会很慢,需要做并发和增量);
- 强依赖个性化登录态渲染的页面(这类页本来就不该被搜索引擎收录)。
七、还能继续优化的点
如果后续要继续打磨,有几个方向:
最后
很多人一遇到 SEO 需求就条件反射上 Nuxt/Next,但 SEO 的本质需求其实只是**“让爬虫在第一次请求时拿到有内容的 HTML”**。对于内容固定的站点,SSG 用一次性的构建时成本,换掉了 SSR 需要长期维护的实时渲染服务,这笔账怎么算都划算。
技术选型没有银弹,匹配自己的业务形态才是最重要的。
完整的在线效果和那 32 个免费工具可以在这里体验:https://www.sourcekit.cn ,像 JSON 格式化、房贷计算器、Cron 生成、UUID、二维码这些日常开发小工具都免注册直接用。有兴趣的同学可以 Ctrl + U 看看源代码,对比一下预渲染前后的 HTML 差异。
如果这篇文章对你有帮助,欢迎点赞收藏,有问题评论区交流。
网硕互联帮助中心






评论前必须登录!
注册