用GitHub Actions实现前端项目全自动部署实战
优化前:卡得不行
上周上线一个内部管理后台的自动部署流程,CI/CD跑完后,前端资源要推到 CDN,再触发一次全量缓存刷新。结果每次部署完,运营同学点开页面第一反应是:「又崩了?」——其实没崩,就是白屏 5 秒起步,FMP(首次有意义绘制)平均 4.8s,LCP 稳定在 5.2s。我本地清缓存测了三次,手都按累了,还是等。
更魔幻的是,这个项目本身才 300KB 的 JS 总体积(gzip 后),用的还是 Vite + React + 懒加载,按理说不该这么慢。但部署一跑,首页就卡成 PPT。不是首屏代码问题,是部署后的「冷启动」链路太糙了。
找到瘼颈了!
我先没急着改代码,开了 Chrome DevTools 的 Network + Performance 面板,手动触发一次部署后的首次访问(强制禁用缓存、勾选 Disable cache)。发现两个关键信号:
- HTML 响应快(120ms),但
<script type="module">加载完后,有整整 2.3s 的「空档期」,主线程几乎不动; - Network 面板里,
manifest.json请求失败了两次(404),然后才 fallback 到硬编码路径——这玩意儿是我们部署脚本自动生成的资源映射表,但部署脚本没等它写完就发了 reload 信号。
接着去翻 CI 日志,果然:部署脚本用的是 rsync -av 推静态文件,但没加 --delete-after,导致旧 manifest 还在,新 manifest 写了一半就被读了。浏览器拿到半截 JSON,解析失败,然后重试,再失败,最后放弃,退化成硬编码路径+全量重新请求 chunk —— 所以那 2.3s 就是反复 fetch + parse 失败 + 降级的总耗时。
顺手用 curl -I https://jztheme.com/manifest.json 测了下,响应头里 ETag 是空的,说明 Nginx 没配静态文件强缓存策略,每次都要走磁盘读取,而我们的 CDN 源站又是单台小机器……啧。
核心优化:把 manifest 生成和发布原子化
这是最痛的一环。原来脚本是分三步走的:
npm run build→ 输出 dist/node scripts/gen-manifest.js→ 把 dist/ 下的文件哈希扫出来,写进dist/manifest.jsonrsync -av dist/ user@server:/var/www/
问题就出在第 2 步和第 3 步之间:gen-manifest.js 是同步 fs.writeFileSync,但 Node 的 I/O 并不保证落盘瞬间完成(尤其在高负载小硬盘上),而 rsync 又是快速扫描+批量传输,极大概率传一半 manifest 就被读了。
解决方法很简单粗暴:把 manifest 生成塞进构建过程里,并确保它和 HTML 在同一轮写入。我改了 vite.config.ts 的 build.rollupOptions.plugins,加了个 inline 插件:
{
name: 'write-manifest',
apply: 'build',
generateBundle(options, bundle) {
const manifest = {};
for (const [fileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.isEntry) {
manifest[fileName.replace(/.js$/, '')] = chunk.fileName;
}
if (chunk.type === 'asset' && fileName.endsWith('.css')) {
manifest[fileName.replace(/.css$/, '')] = chunk.fileName;
}
}
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: JSON.stringify(manifest, null, 2)
});
}
}
然后在 index.html 里用内联 script 读它(不用 fetch):
<script type="module">
const manifest = await import('./manifest.json', { assert: { type: 'json' } });
// 后续逻辑用 manifest.default.xxx 做资源路径替换
</script>
注意这里用了 import(..., { assert: { type: 'json' } }),Vite 会把它当静态依赖提前 resolve,打包时自动注入 hash,不会出现「找不到 manifest」的问题。而且整个流程都在 Rollup 构建图里,manifest.json 和其他产物一定是原子写入 dist/ 的。
另外,Nginx 配置也补了一刀:
location /manifest.json {
add_header Cache-Control "public, max-age=31536000, immutable";
etag on;
}
加上 immutable,浏览器就知道这个文件一辈子不会变,下次直接从 disk cache 拿,不用发请求。
捎带手修的几个小毛病
虽然主 bottleneck 解决了,但顺手把其他拖后腿的也理了理:
- 部署脚本里删掉了所有
curl https://jztheme.com/healthz健康检查 —— 这玩意儿以前用来等服务 ready,但实际它只是返回 200,不校验静态资源是否就位。改成ssh user@server 'ls -l /var/www/manifest.json 2>/dev/null | wc -l',只确认文件存在且非空; - HTML 里去掉了
<link rel="preload">对 main.js 的预加载 —— 因为现在 manifest 是内联的,main.js 路径动态生成,preload 写死会失效,不如交给浏览器自己的优先级调度; - CI 的 Node 版本从 16 升到 20,Vite 构建速度快了 18%,虽然不影响运行时,但部署窗口缩短了,间接降低「半成品状态」暴露的概率。
优化后:流畅多了
改完上线当天我就蹲着测数据。本地清缓存 + 禁用缓存,首屏时间直接掉到 820ms 左右,FMP 稳定在 790ms,LCP 降到 850ms。最关键的是——没再出现白屏卡顿,资源加载曲线非常顺滑,没有重试、没有 fallback、没有空档期。
线上 RUM(真实用户监控)数据也印证了这点:部署后 24 小时内,首屏 >3s 的 PV 比例从 37% 降到 1.2%,其中 98% 的降级请求消失了。运营同学发来微信:“今天打开好快,我以为我网好了。” 我回:“不,是你终于用上了没 bug 的部署流程。”
当然,不是 100% 完美。比如极端情况下(服务器磁盘 IO 突然打满),manifest.json 写入还是可能延迟几毫秒,不过目前还没观测到失败案例。这个方案不是理论最优,但它是当前工程约束下最简单、最可控、见效最快的解法。
性能数据对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| FMP(首次有意义绘制) | 4820ms | 790ms | ↓ 84% |
| LCP(最大内容绘制) | 5210ms | 850ms | ↓ 84% |
| 首屏 JS 请求次数 | 平均 5.3 次(含 2 次 404) | 稳定 2 次(manifest + main.js) | ↓ 62% |
| 部署失败率(manifest 相关) | 12.7%(日均) | 0%(连续 7 天) | ↓ 100% |
以上是我踩坑后的总结,希望对你有帮助
自动部署的性能问题,往往不在构建工具或 CDN,而在「部署动作」本身是否原子、可靠、可预测。别迷信 CI 脚本里的 sleep 3s 或 curl 等待,真正靠谱的是让构建产物天然具备一致性,让部署行为收敛到最小不可分单元。
这个方案我们跑了两周,没再出过类似问题。如果你也在用 manifest 做资源映射,或者遇到部署后首屏卡顿,不妨试试把 manifest 生成收编进构建流程。比写一堆容错逻辑省心多了。
以上是我个人对这个自动部署性能问题的完整实战记录,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如配合 SW 实现离线 manifest 更新,后续会继续分享这类博客。

暂无评论