用GitHub Actions实现前端项目全自动部署实战

___玉杰 工具 阅读 1,814
赞 20 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个内部管理后台的自动部署流程,CI/CD跑完后,前端资源要推到 CDN,再触发一次全量缓存刷新。结果每次部署完,运营同学点开页面第一反应是:「又崩了?」——其实没崩,就是白屏 5 秒起步,FMP(首次有意义绘制)平均 4.8s,LCP 稳定在 5.2s。我本地清缓存测了三次,手都按累了,还是等。

用GitHub Actions实现前端项目全自动部署实战

更魔幻的是,这个项目本身才 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 生成和发布原子化

这是最痛的一环。原来脚本是分三步走的:

  1. npm run build → 输出 dist/
  2. node scripts/gen-manifest.js → 把 dist/ 下的文件哈希扫出来,写进 dist/manifest.json
  3. rsync -av dist/ user@server:/var/www/

问题就出在第 2 步和第 3 步之间:gen-manifest.js 是同步 fs.writeFileSync,但 Node 的 I/O 并不保证落盘瞬间完成(尤其在高负载小硬盘上),而 rsync 又是快速扫描+批量传输,极大概率传一半 manifest 就被读了。

解决方法很简单粗暴:把 manifest 生成塞进构建过程里,并确保它和 HTML 在同一轮写入。我改了 vite.config.tsbuild.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 更新,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论