本地明明正常,为什么 CI 又挂了:一次前端构建失败的排查过程

本文是「前端工程现场」系列的第 3 篇。

下面使用一个假设场景说明问题,代码和日志均为简化示例。

一次用户列表导出功能开发完成后,本地页面可以正常打开,点击按钮也能生成文件,pnpm build 同样通过。代码推到远程仓库后,CI 却停在构建阶段。

流水线日志里出现了这样的错误:

text 复制代码
[vite]: Rollup failed to resolve import "xlsx"
from "src/modules/user/utils/exportUsers.ts"

开发目录里的 node_modules 明明存在 xlsx,构建命令也和 CI 一样。最初很容易把问题归到流水线缓存、镜像源或网络波动上,但日志已经说明,失败发生在模块解析阶段:构建工具没有找到项目代码导入的包。

继续检查 package.json 后才发现,当前分支没有声明 xlsx。它来自另一个实验分支留下的安装结果:之前在那个分支执行过 pnpm add xlsx,切回当前分支时,Git 恢复了 package.json 和锁文件,却不会主动清理 node_modules 中多出来的包。

因此,本地代码仍然能够执行:

ts 复制代码
import * as XLSX from 'xlsx'

模块解析器在磁盘上找到了对应文件,也就没有提示当前分支缺少依赖声明。CI 从空目录检出代码,再按照仓库中的锁文件安装,安装结果里没有 xlsx,构建才暴露问题。

xlsx 加入 dependencies 并更新锁文件后,流水线再次执行。这一次,构建停在另一个位置:

text 复制代码
Could not load
src/modules/user/components/exportDialog.vue

仓库里的实际文件名是 ExportDialog.vue,导入语句却写成了:

ts 复制代码
import ExportDialog from './exportDialog.vue'

Windows 常见文件系统对大小写不敏感,本地构建会把这两个名称当作同一个文件。CI 运行在 Linux 上,ExportDialog.vueexportDialog.vue 指向不同路径,构建工具按导入字符串查找文件时直接失败。

这次提交没有复杂的业务错误。一个问题来自残留依赖,另一个来自文件系统差异,它们都被开发目录里的既有状态暂时遮住了。

本地通过,可能依赖了仓库之外的文件

前端开发目录会长期保留很多不受 Git 管理的内容。node_modules.env.local、Vite 缓存、编辑器配置和全局命令不会因为切换分支而自动恢复成该分支的理想状态。

这种状态积累并不罕见。开发者会在多个功能分支之间切换,某个分支增加依赖后,另一个分支即使没有相同声明,也可能继续访问磁盘上已经存在的包。

可以先用 pnpm why xlsxpnpm list xlsx 查看当前目录为什么存在这个包。如果命令显示它不属于当前项目声明的依赖,或者当前锁文件中根本找不到它,本地构建结果就已经带上了仓库之外的条件。

排查时,可以先删除安装结果,再按照现有锁文件重新安装:

bash 复制代码
rm -rf node_modules
pnpm install --frozen-lockfile
pnpm build

这里保留 pnpm-lock.yaml,因为目标是复现仓库中已经记录的依赖树。直接把锁文件一起删除,会让 pnpm 重新解析版本,新的安装结果可能避开原问题,也可能引入另一组变化,排查现场随之消失。

--frozen-lockfile 会阻止 pnpm 在安装过程中重写锁文件。如果开发者只修改了 package.json,却没有提交对应的锁文件变化,安装会直接失败。

CI 中使用相同命令:

yaml 复制代码
- name: Install dependencies
  run: pnpm install --frozen-lockfile

这一步检查两个对象是否对得上:项目声明需要哪些依赖,以及仓库记录了怎样的解析结果。流水线不会替提交者补写锁文件,也不会带着临时生成的依赖树继续执行。

例如,开发者手动把 xlsx 写进 package.json,却没有执行 pnpm install 更新 pnpm-lock.yaml。普通安装可能在 CI 机器上补写锁文件,后续构建使用的是一份只存在于流水线工作目录里的解析结果。任务结束后这个目录被销毁,仓库中依旧保留着不一致的两个文件。

冻结安装会在这里停止。提交者需要回到本地执行正确的安装命令,把锁文件变化和依赖声明放进同一次提交,代码审查时也能看到依赖树发生了什么变化。

如果干净安装后,本地也出现了和 CI 相同的 xlsx 报错,问题已经完成复现。此时需要修正的是依赖声明和锁文件,不需要先调整远程流水线。

文件名大小写为什么只在 CI 中暴露

依赖补齐后,第二次失败来自导入路径:

text 复制代码
实际文件:ExportDialog.vue
导入路径:exportDialog.vue

在 Windows 常见文件系统中,大小写不同的路径可能仍然定位到同一个文件。Linux 通常严格区分大小写,因此同一段导入代码会得到不同结果。

这种错误与 node_modules 无关。重新安装依赖、清理 Vite 缓存或切换包管理器都不会修改仓库里的错误路径。

项目可以通过 TypeScript 配置减少这类问题:

json 复制代码
{
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true
  }
}

这个选项会检查同一文件的引用大小写是否保持一致,但能否在当前开发机上发现所有问题,仍然受解析方式和文件系统行为影响。Linux 环境中的生产构建依旧是可靠的补充检查。

在 Git 中只修改文件名大小写时,Windows 还可能没有把变化记录完整。例如将 exportDialog.vue 改成 ExportDialog.vuegit status 可能看不到预期结果。

可以先改成一个临时名称,再改成目标名称:

bash 复制代码
git mv exportDialog.vue ExportDialog.tmp.vue
git mv ExportDialog.tmp.vue ExportDialog.vue
git status

这样 Git 会明确记录两次重命名,远程仓库中的文件名也会同步更新。

对于当前场景,流水线不需要加入复杂的路径检查脚本。只要它在 Linux 环境中按照仓库内容执行一次生产构建,错误的导入路径就会停在明确的构建步骤中。

yaml 复制代码
steps:
  - uses: actions/checkout@v4

  - uses: pnpm/action-setup@v4
    with:
      version: 10

  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: pnpm

  - run: pnpm install --frozen-lockfile
  - run: pnpm build

示例中的 Node.js 和 pnpm 版本只用于展示结构。项目应当在 package.json、版本管理文件和 CI 配置中使用同一套已验证版本,避免本地使用 Node.js 20,流水线却仍然停在另一个主要版本。

可以在 package.json 中补充:

json 复制代码
{
  "engines": {
    "node": ">=20 <21"
  },
  "packageManager": "pnpm@10.0.0"
}

这份声明不会自动保证每台机器都严格切换版本,但它给本地开发、Corepack 和 CI 提供了同一个版本依据。排查构建差异时,也不用先在群里确认每个人口中的"最新版"究竟是哪一版。

在新目录中复现 CI 的安装过程

直接在原项目目录里反复执行安装和构建,可能继续受到未提交文件、缓存和历史依赖影响。即使构建重新通过,也难以确认是哪一步清理操作改变了结果。

可以为当前提交创建一个新的 worktree:

bash 复制代码
git worktree add ../order-admin-ci-check HEAD
cd ../order-admin-ci-check

这个目录来自当前提交,不会自动继承原目录中的 node_modules.env.local 和未提交修改。进入目录后可以先执行 git status,确认工作区确实干净,再运行与 CI 相同的命令:

bash 复制代码
corepack enable
pnpm install --frozen-lockfile
pnpm build

如果 xlsx 没有被声明,安装完成后的构建会稳定报错。修正依赖并更新锁文件后,再重新创建或清理这个 worktree,就能验证仓库是否已经具备独立构建条件。

这种方式还有一个好处:原开发目录可以继续保留正在编写的内容,复现目录只负责验证当前提交。两边的用途分开后,不需要为了排查 CI 临时 stash 一堆改动,也减少了误删本地配置的风险。

检查结束后可以移除临时目录:

bash 复制代码
cd ../order-admin
git worktree remove ../order-admin-ci-check

worktree 只能减少当前开发目录带来的干扰,无法消除操作系统差异。Windows 上的新 worktree 仍然可能放过文件名大小写错误,所以 Linux CI 日志仍然需要保留。

排查时可以先把条件对齐到相同提交、相同 Node.js 版本、相同 pnpm 版本、相同锁文件和相同构建命令。完成这些步骤后,剩余问题通常集中在操作系统、环境变量或外部服务上,不必同时怀疑整个流水线。

CI 通过验证了哪些范围

修复依赖声明和导入路径后,流水线能够完成安装与构建。它说明锁文件可以在指定包管理器下完成安装,构建工具也能解析当前提交中的模块、路径和生产配置。

这个结果没有覆盖浏览器里的完整业务交互。CI 没有登录用户管理页面,没有点击导出按钮,也没有打开生成的 Excel 检查字段顺序和数据内容。

如果项目只配置了安装与构建步骤,绿色结果应当被理解为"当前提交可以在干净环境中生成生产产物"。按钮交互、接口权限和文件内容还需要页面回归、组件测试或端到端测试处理。

这篇把范围停在可复现构建,不继续展开测试分层、环境变量注入和部署监控。把这些主题全部塞进一次构建失败复盘,会让事故本身失去焦点。

在这个假设场景中,最终修改只有两处:正式声明 xlsx 并更新锁文件,修正 ExportDialog.vue 的导入路径。下次遇到本地通过、CI 构建失败时,可以先在新目录中执行冻结安装和生产构建;如果错误只出现在 Linux,再检查路径大小写和脚本权限。

下一篇会继续处理 .env 中的变量什么时候进入构建产物,以及测试环境接口地址为什么可能出现在生产包中。

相关推荐
太平洋月光1 小时前
stagewise如何结合cursor开发
前端·ai编程
前端Hardy1 小时前
JavaScript 终于又进化了!ES2026 正式发布,这些新特性你必须知道!
前端
光影少年1 小时前
react navite 页面跳转、传参、路由监听、导航栏自定义
前端·react native·react.js
黑土豆1 小时前
CSS 新特性:Container Queries 到底能解决什么痛点?
前端·css
问商十三载2 小时前
2026法律行业AI引擎生成式优化怎么优化?三层专业加固提排名,零成本提33%引用率附适配表
java·前端·人工智能·算法
恋猫de小郭2 小时前
Flutter 3D 渲染的全新选择和应用场景
android·前端·flutter
IT_陈寒2 小时前
Java线程池踩了个坑,任务居然默默消失了
前端·人工智能·后端
程序员爱钓鱼2 小时前
配置 GoLand 与 VS Code 开发环境
前端·后端·go
程序员爱钓鱼2 小时前
Rust Vec 动态数组详解:创建、增删、遍历与排序
前端·后端·rust