SPA 静态化实战:无需替换框架,低成本地将 React、Vue... 应用转为 SSG 的方案
SPA 的甜蜜烦恼与性能优化的诉求
单页面应用(SPA)以其流畅的客户端路由和丰富的交互体验赢得了开发者的青睐。然而,这种架构也带来了一些挑战,尤其是在性能和爬虫可见性方面:
- 搜索引擎优化(SEO): 尽管搜索引擎爬虫能力在提升,但它们抓取和理解依赖 JavaScript 动态渲染内容的 SPA 页面,仍不如直接处理静态 HTML 那样高效和稳定。这可能影响网站在搜索结果中的排名,对内容驱动型网站尤其不利。
- 首屏加载性能(FCP/LCP): SPA 首次加载需下载并执行大量 JavaScript,然后才能渲染内容和请求数据。这个过程可能导致较长的白屏时间,影响用户体验,特别是在网络或设备性能受限时。
主流 SSR 方案的挑战与迁移成本
为了解决上述问题,社区涌现了像 Next.js (基于 React) 和 Nuxt.js (基于 Vue) 这样的优秀服务端渲染(SSR)或静态站点生成(SSG)框架。它们提供了开箱即用的解决方案,能有效改善 SEO 和首屏性能。
然而,将现有的、基于标准 React/Vue CLI 构建的 SPA 项目迁移到这些框架,并非总是轻松之举:
- 学习成本: 需要学习新框架的约定、路由机制、数据获取方式(如
getServerSideProps
,getStaticProps
,asyncData
等)、配置和生命周期。 - 项目重构: 往往需要对现有代码结构、路由逻辑、状态管理、甚至是 CSS 处理方式进行较大规模的调整,以适应框架的规范。这对于已具规模或历史悠久的项目来说,可能意味着巨大的工作量和潜在风险。
- 构建与部署复杂性: SSR 方案通常需要运行一个 Node.js 服务器环境,对部署架构有特定要求。虽然它们也支持 SSG 模式,但整个开发和构建流程与传统 SPA 不同。
那么,有没有一种方式,可以在不颠覆现有 SPA 项目结构的前提下,低成本地享受 SSG 带来的好处呢?
这就是我开发这个小工具的初衷:提供一种非侵入式的、轻量级的解决方案,让你能够为现有 SPA 项目中的特定页面(如首页、关于页、产品页等)生成静态 HTML,从而快速优化这些关键页面的 SEO 和加载性能,而无需进行大规模的项目迁移或重构。
使用示例
安装
bash
npm i @jl-org/ssg puppeteer-core -D
使用
- 生成静态 HTML
ts
const path = require('node:path')
const { ssg } = require('@jl-org/ssg')
const puppeteer = require('puppeteer-core')
const PORT = '4173'
main()
async function main() {
await ssg({
port: PORT,
/**
* 创建浏览器实例的函数,默认需要你自行下载 puppeteer-core 或者 puppeteer
* ### 因为 puppeteer 会自动下载浏览器,所以提供此配置。你可以使用自己浏览器的路径,节省内存
* @example
* puppeteer.launch({ headless: true, ... })
*/
createBrowser: () => puppeteer.launch({
executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
headless: true
}),
ssgPages: [
{
url: `http://localhost:${PORT}`,
target: path.resolve(__dirname, '../dist/index.html')
},
{
url: `http://localhost:${PORT}/pricing`,
target: path.resolve(__dirname, '../dist/pricing.html')
},
{
url: `http://localhost:${PORT}/about`,
target: path.resolve(__dirname, '../dist/about.html')
},
]
})
process.exit(0)
}
- 修改 Nginx 配置,并且重启服务
nginx
server {
listen 80;
server_name yourdomain.com;
# pages
location /pricing {
root /Your_Root_Dir;
index pricing.html;
try_files $uri $uri/ /pricing.html;
}
location /about {
root /Your_Root_Dir;
index about.html;
try_files $uri $uri/ /about.html;
}
}
- 测试生效
bash
# 查看返回内容,是否为整个 HTML
curl http://yourdomain.com/pricing
核心原理与工作流程
这个工具的核心思路是在构建流程中,模拟真实用户访问应用的过程,捕获最终渲染好的 HTML,并将其保存为静态文件。
以下是详细的工作流程及关键代码原理:
-
启动本地预览服务器:
-
动作: 执行命令(如
npx vite preview --port <端口>
)启动 SPA 的预览服务。默认使用 vite,你可自定义命令 -
原理: 需要一个 HTTP 服务器来提供 SPA 资源。我们使用 Node.js 的
child_process.spawn
异步启动这个服务。typescript/** 伪代码示意 */ const proc = spawn('npx', ['vite', 'preview', '--port', '4173'], { shell: true }) /** 通过监听 stdout 确认服务器启动成功 */ proc.stdout.on('data', (data) => { if (data.toString().includes('server running at')) { resolve(killServerFunction) /** 服务器就绪,继续下一步 */ } })
-
关键: 确保服务器完全启动后再进行下一步。
-
-
启动无头浏览器实例:
-
动作: 利用 Puppeteer 库启动一个无头(headless)浏览器实例。
-
原理: 无头浏览器提供 API 控制浏览器行为。通过
createBrowser
配置,允许用户指定本地浏览器路径,避免额外下载。 -
注意: 为了节省用户内存,启动浏览器需要用户从外部传入,你可以选择下载 puppeteer-core ,传入自己浏览器路径来节省内存;或者下载 puppeteer ,直接用自动下载的浏览器
typescriptconst puppeteer = require('puppeteer-core') /** 伪代码示意 - createBrowser 函数内 */ const browser = await puppeteer.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', /** 用户指定的路径 */ headless: true /** 在后台运行 */ }) return browser
-
关键: 使用
headless: true
在后台执行,executablePath
提供灵活性。
-
-
访问目标页面:
-
动作: 无头浏览器导航到配置中指定的每个本地 URL(如
http://localhost:4173/about
)。 -
原理: 调用 Puppeteer Page 对象的
goto
方法。typescript/** 伪代码示意 - genHtml 函数内 */ const page = await browser.newPage() await page.goto('http://localhost:4173/about', { /* ... */ })
-
-
等待页面完全加载:
-
动作: 等待页面资源加载完毕且网络活动静默。
-
原理: 使用
goto
方法的waitUntil: 'networkidle0'
选项。这指示 Puppeteer 等待,直到在 500ms 内网络连接数少于或等于 0,通常意味着异步加载和渲染已完成。typescript/** 伪代码示意 - page.goto 调用时 */ await page.goto(url, { waitUntil: 'networkidle0', /** 等待网络空闲 */ timeout: 100000 /** 设置较长超时以防页面加载慢 */ })
-
关键:
networkidle0
是捕获完整动态内容的核心。
-
-
捕获 HTML 内容:
-
动作: 获取当前页面的完整 DOM 结构,并序列化为 HTML 字符串。
-
原理: 调用 Page 对象的
content()
方法。typescript/** 伪代码示意 - genHtml 函数内 */ const html = await page.content()
-
-
(可选)HTML 优化与压缩:
-
动作: 若启用
needMinify
,使用html-minifier-terser
库压缩 HTML。 -
原理: 调用
minify
函数,传入 HTML 内容和压缩选项。typescript/** 伪代码示意 - genHtml 函数内 */ if (needMinify) { html = await minify(html, { collapseWhitespace: true, removeComments: true, // ... 其他压缩选项 }) }
-
关键: 减小文件体积,提升加载速度。
-
-
路径处理:
-
原因: 通过开发服务器启动的页面,里面的资源是 https://localhost:PORT/xxx.png 之类的路径,这在服务器里不可用,应该替换掉 http 路径,移除 HTML 中指向本地预览服务器的绝对 URL 前缀。
-
原理: 使用字符串的
replace
方法配合正则表达式,将http://localhost:PORT
替换为空字符串,使资源引用变为相对路径或根相对路径。typescript/** 伪代码示意 - genHtml 函数内 */ const curUrl = `http://localhost:${PORT}` /** 获取当前基础 URL */ html = html.replace(new RegExp(curUrl, 'g'), '') /** 全局替换 */
-
关键: 保证部署后的资源路径正确。
-
-
写入静态文件:
-
动作: 将处理后的 HTML 内容写入指定的文件路径。
-
原理: 使用 Node.js 的
fs.writeFileSync
。typescript/** 伪代码示意 - genHtml 函数内 */ writeFileSync(targetPath, html) // targetPath 是配置中指定的输出路径
-
-
循环与清理:
-
动作: 对所有配置的页面完成 SSG 后,关闭浏览器页面和实例,并终止预览服务器进程。
-
原理: 使用
page.close()
关闭单个页面,browser.close()
关闭浏览器。最后还需要关闭命令行(npx vite preview
),使用tree-kill
库确保预览服务器及其所有子进程被彻底关闭,释放资源。typescriptimport treeKill from 'tree-kill' treeKill(pid, 'SIGTERM', (err) => { if (err) { console.error(`关闭服务器失败 ${pid}:`, err) } else { console.log(`Process ${pid} 已经关闭`) } })
-
关键: 资源管理,避免进程残留。
-