一个容易被低估的问题:流水线质量也是技术债
前端项目的 CI/CD 看起来是件很标准的事:拉代码、装依赖、跑测试、构建、部署。大多数团队早期照着模板写一个 workflow,能跑就行。但项目一旦到了几十个页面、上百个 PR 同时在推进的阶段,流水线本身就会变成一个需要被治理的工程问题。

我见过不少团队是在某个周五下午打开 GitHub Actions 页面,发现自己一个普通 PR 的完整流水线要跑 15 分钟以上,才开始认真看每一步的耗时。这时候的痛点往往不是某个工具不会配置,而是整条链路里的重复劳动、串行等待和低效缓存交织在一起。这篇文章想围绕三个方向展开:依赖缓存到底该怎么设计,并行任务怎么拆才有意义,部署预览怎么接才不会变成负担。
先搞清楚 Pipeline 慢在哪,再动手优化
很多团队优化 CI/CD 的第一步是加并行。这个方向本身没问题,但前端流水线的时间分布往往极度不均匀,不加分析地并行,经常是白忙活。
一个常见的流程长这样:push 一个 commit 到 PR,触发 checkout、setup-node、依赖安装、lint、单元测试、build、上传产物。在没有缓存的情况下,依赖装 40 到 60 秒,lint 和 typecheck 各 20 秒,单测 30 秒到 2 分钟,build 也能占到 30 秒以上。这里真正值得并行的,是 lint、typecheck、单测这些互相没有依赖关系的任务;而依赖安装和 build 天然是前后依赖,强行拆开只会增加额外开销。
还有一个每个团队都会经历的场景:并发度上去了,但 GitHub Actions 免费额度有并发数限制,企业版虽然放宽了,每个 job 重新拉镜像、装环境仍然要时间。如果一个任务本身只跑 2 分钟,你把它拆成 5 个并行 job,每个 job 光准备环境就要 1 分钟,总账通常是亏的。
我的习惯是先量化:打开 workflow 的执行记录,按平均耗时排序,找到最大的几个瓶颈,再决定是加缓存、加并行,还是合并任务。优化要有数据依据,而不是凭感觉。
缓存策略:缓存安装结果,而不是安装过程
依赖缓存是前端 CI/CD 里收益最明显的一步,也是最容易埋坑的一步。第一个误区,就是直接缓存 node_modules。
node_modules 不适合作为 GitHub Actions 的缓存对象,原因有几个:它跨平台不兼容,Linux runner 上生成的软链接和二进制产物换到 macOS runner 上就失效;它容易被 postinstall、patch 工具链修改,缓存命中后反而拿到一个脏状态;它体积大,上传下载的时间成本几乎抵消了节省的安装时间。
正确做法是缓存包管理器自己的存储目录。npm 是 ~/.npm,yarn 是 ~/.cache/yarn,pnpm 是 ~/.local/share/pnpm/store。以 pnpm 为例,一个稳妥的配置长这样:
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
这里的关键在 key 的设计。lockfile 指纹参与 key 计算,lockfile 每次变化都会生成新缓存,命中不了就重新缓存;restore-keys 则提供可用但可能旧的兜底。实际效果是:lockfile 没变的提交,安装命令依然要跑,但因为 store 里已经有大部分 package,pnpm install 能在几秒内完成;lockfile 变了,就重新建立缓存,代价也完全可控。
第二个常见坑,是命中缓存后直接跳过依赖安装。很多团队以为缓存命中就等于不需要 install,结果 build 阶段发现原生依赖缺失、linker 状态不对,反而更难排查。缓存只是让 install 变快,它不是 install 的替代品。
第三个坑是往缓存里塞太多无关的东西。构建产物应该走 artifacts,不应该进 cache;coverage 报告、日志文件更是没必要。actions/cache 有存储上限,超出后缓存会被压缩甚至淘汰,让所有 key 全部失效。缓存内容越精炼,命中越稳定。
下面这张表总结了前端项目里常见缓存对象的取舍:
| 缓存对象 | 建议 | 原因 |
|---|---|---|
| 包管理器 store / cache 目录 | 建议缓存 | 跨 job 复用,命中率高,安全 |
| node_modules | 不建议 | 跨平台兼容差,容易被修改产生脏状态 |
| 构建临时缓存(.vite 等) | 谨慎 | 仅大型项目收益明显,小项目徒增复杂度 |
| dist 构建产物 | 不建议 | 应作为 artifacts 在 job 间传递 |
| coverage 报告 | 不建议 | 产物化处理,不应进入缓存 |
并行任务:Job 才是 GitHub Actions 的并行单位
另一个高频误区是:把 lint、typecheck、单测全部写进同一个 job,然后期待它们并行执行。在 GitHub Actions 里,一个 job 内的 steps 永远串行运行。真正并行的单位是 job,或者更准确地说,是 workflow 中互相没有依赖关系的多个 job。
正确拆法是这样的:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run
注意,这里的代价是依赖安装被重复了多次。如果缓存命中后 install 能控制在 10 秒内,这个重复成本完全不是问题;但如果你的安装步骤常年要 30 秒以上,说明缓存策略还没做好,这时候加并行的收益会大打折扣。
还有一种更细粒度的并行:测试分片。当前端仓库的单测数量上来之后,单线程跑测试会成为一个明显的瓶颈。以 Vitest 为例,可以用 shard 机制配合 matrix 把用例分成多份同时跑:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --shard=${{ matrix.shard }}/4
分片数不是越多越好。每片执行的测试数量不均,整体耗时取决于最慢的那一片;分片太多还会带来额外的 runner 调度开销。我的经验是从 4 片起步,观察各片耗时分布,再把最慢的文件单独调整。
concurrency:别让 PR 队列无限膨胀
如果你在一个迭代节奏很快的团队里,一定会遇到这个场景:上午一共 push 了 6 次 commit,每次 push 都触发一遍完整 workflow,Actions 的队列页面排满了正在等待的任务。旧的还没跑完,新的又进来了,最要命的是人对最新结果的判断会被旧任务的绿色标志干扰。
GitHub Actions 的 concurrency 字段就是为了处理这个问题:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group 指定并发组,github.ref 把每个分支或 PR 区分开;cancel-in-progress 为 true 时,同一组内已有任务在跑,新的触发会直接把旧任务取消。这样 PR 每次 push 只保留最新一轮的 CI 结果,队列时间大幅下降。
但这里有一个值得提醒的边界:部署类的 job 不要把 cancel-in-progress 设为 true。如果你正在往生产环境推送构建产物,一个新提交触发的取消动作可能让部署停在半路,这比排队更危险。更稳的做法是,部署 job 使用单独的 workflow 或单独的 concurrency group,保证同一时间内最多只有一个部署任务在跑。
部署预览:让 PR 变成一个可讨论的环境
部署预览可能是前端 CI/CD 里回报最高的环节,它的价值在于:把代码 review 升级成真实环境体验。一个改动到底有没有破坏视觉细节、交互状态对不对、接口字段是否匹配,光看 diff 很难判断,但一个可点开的 URL 能立刻让设计师、产品经理和测试参与进来。
实现方式上,无外乎三条路线:
- Vercel、Netlify 这类托管平台:原生支持 PR preview,创建后自动生成预览地址,几乎零成本,前提是生产环境也托管在这些平台上。
- GitHub Pages 搭配 actions/deploy-pages:适合纯静态站点,但对多分支、多 PR 的预览支持有限。
- 自建服务器或对象存储:灵活度最高,可以按 PR 号生成子路径,适合有私有化部署要求的团队。
如果你的项目用 Vite 自建 preview,最容易踩的坑是静态资源路径。Vite 默认 base 是根路径,preview 地址如果是 /preview/pr-123/ 这种子路径,不设置 base 的话,HTML 里引用的 JS、CSS 会全部 404。workflow 里可以这样处理:
- name: Build preview
run: vite build --base=/preview/pr-${{ github.event.pull_request.number }}/ --out-dir=dist-preview
- name: Upload preview
run: |
scp -r dist-preview deploy@server:/data/nginx/preview/
另一个常被忽略的问题是接口地址。preview 环境如果继续请求生产 API,数据会被污染,功能验证也失去意义。建议把 API 地址作为构建时的环境变量注入,在 pull_request 事件里使用 preview 专用的值。
还需要为 preview 环境加上生命周期。PR 合并或关闭后,对应的预览目录不会自动消失,日积月累会占满服务器磁盘。比较顺手的做法是在 workflow 里监听 closed 事件,清理对应的目录:
on:
pull_request:
types: [opened, synchronize, closed]
把部署和清理挂在同一个 workflow 里,用事件类型区分动作,就不会出现 PR 关了、环境还留着的问题。
三个容易踩的坑
回头看,前端团队在 GitHub Actions 上翻车,基本都集中在三种情况:
- 把并行寄托在 step 上。GitHub Actions 的 step 就是串行的,想并行必须拆 job 或用 matrix,认清这个模型能省掉很多无效调整。
- 为了缓存命中率牺牲正确性。缓存 key 里不包含 lockfile、命中后跳过 install、缓存整个 node_modules,这些做法都可能让流水线变快的同时引入难以定位的不一致问题。
- preview 环境只建不拆。没有 closed 事件的清理逻辑,环境越堆越多,最终变成服务器上的垃圾资产。
落地顺序:缓存、并行、预览,一个都不能反
如果团队第一次系统性优化前端 CI/CD,我建议按这个顺序演进。
先把耗时量化。翻出最近 20 次 workflow 的执行记录,记录每个 job 的平均耗时,找到最大的瓶颈。接着做依赖缓存:把包管理器 store 的 key 设计做对,这是风险最低、收益最明显的一步。然后拆并行 job:确认 install 在缓存命中后足够快,再把 lint、typecheck、单测拆开并行。最后才做部署预览,先挑一个内部项目试点,把 base 路径、接口地址和清理逻辑都验证清楚。
这套顺序的核心逻辑是:缓存解决基础设施开销,并行解决任务执行时间,预览解决协作验证效率。顺序反了,很容易出现一种尴尬局面——并行 job 拆了一堆,结果每个 job 的第一分钟都在做同一个依赖安装。工程问题的解法通常不是单个技巧,而是这些技巧在正确顺序下的组合。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/762/