你在本地跑 npm run build 一切正常,push 完发现 Vercel、Netlify 或 GitHub Actions 红了,报 Cannot find module 'some-lib'、SyntaxError: Unexpected token 或 error TS2307。这是 AI 辅助编码里最高频的一类”环境漂移”问题:Claude Code 或 Cursor 在本地装了依赖却没提 lockfile、用了你 CI 镜像太旧不支持的 Node 特性、或引用了一个只有你本机才有、runner 上没有的文件。
最快的修法(约 70% 的情况能解决): 用 CI 一模一样的 Node 版本配 npm ci 在本地复现,再确认 lockfile 进了你最近的 commit。两步加起来大概两分钟:
node -v # 和 CI log 最前面几行对比
git log --name-only -1 # package.json 变了但 lockfile 没变?
如果这两条都解释不了,就照下面的对照表逐项排。整套流程是一个 5 分钟的”复现并钉死”循环,不是靠猜。
先确认你属于哪一类
把你看到的 CI 红字对到最可能的原因,再跳到对应小节。下面按命中率从高到低排序。
| 你看到的 CI 报错 | 最可能的原因 | 跳到 |
|---|---|---|
npm ci 报 “out of sync”、ERR_PNPM_OUTDATED_LOCKFILE、“lockfile not up to date” | lockfile 没提(或 pnpm 版本漂移) | 原因 1 |
SyntaxError: Unexpected token、内置方法 “is not a function” | CI 的 Node 比本地旧 | 原因 2 |
command not found: tsx / vite / esbuild | 工具只在本地全局装了,没进 devDependencies | 原因 3 |
文件明明在,却报 Cannot find module './button' | 文件名大小写不一致(Linux 区分大小写) | 原因 4 |
build 时值是 undefined,或某个 PUBLIC_*/NEXT_PUBLIC_* 变量为空 | 环境变量本地有、CI 没有 | 原因 5 |
读某个 config 或 fixture 时报 ENOENT: no such file or directory | config 引用了只在本地的 mock / 文件 | 原因 6 |
常见原因
1. Lockfile 没 commit(最常见)
Agent 跑了 npm install some-lib,package.json 进了 commit,但 package-lock.json / pnpm-lock.yaml / yarn.lock 没进。CI 上跑 npm ci(或 pnpm install --frozen-lockfile),它拒绝去动一个不同步的 lockfile:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync.
npm error Missing: some-lib@2.3.0 from lock file
pnpm 是同样的失败,只是报错字符串不同:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with package.json
如果你 CI 跑的是普通 npm install 而不是 npm ci,它可能悄悄装了一个和你本机不同的版本,结果改在 build 期或运行时才炸——这种更难发现,所以 CI 才应该用 npm ci。
pnpm 还有个更隐蔽的变种:lockfile 提了,但它是由和 CI 不同的 pnpm 大版本生成的,格式对不上。这时连 pnpm 版本一起钉(见 Step 3)。
如何判断: git log --name-only -1 看最近一次 commit。如果 package.json 变了但 lockfile 没变,就是这个。
2. 本地 Node 版本比 CI 新
你本地是 Node 24,runner 是 Node 20(或更旧)。Agent 用了较新的 API,比如 Array.prototype.toSorted()(Node 20+)、Object.groupBy()(Node 21+),或内置的 fetch/structuredClone 全局,CI 上就抛错。还残留着 Node 18 固定值的老模板是常见元凶:Node 18 已于 2025 年 4 月停止维护(EOL),多数平台都已下线,一份过时的 .nvmrc 或 workflow 会把你留在一个行为和本机不一样的、不再受支持的运行时上。
SyntaxError: Unexpected token 'await'
at wrapSafe (node:internal/modules/cjs/loader:1378:18)
截至 2026 年 6 月各平台的默认 Node 版本,在什么都没固定时很有用:
| 平台 | 默认 Node | 备注 |
|---|---|---|
| Vercel | 24.x | 2026 年 2 月起为默认;只能选大版本 |
| Netlify | 22 | 2025 年 2 月起为默认 |
GitHub Actions setup-node | 看你怎么写 | 没有安全默认值,不指定就是坑 |
如何判断: 本地跑 node -v,和 CI log 最前面几行(runner 打印它的 Node 版本那几行)对比。差一个大版本、或 CI 打印出 Node 18,都高度可疑。
3. AI 用了本地全局装的工具
Agent 在 build 脚本里直接写了 tsx、esbuild、vite 或 tsc,默认它在 PATH 上。你某次全局装过就忘了;CI 从干净镜像起步,只有 lockfile 里的东西,于是失败:
sh: 1: tsx: command not found
修法是把工具加进 devDependencies,再通过 npx 或 npm script 调用,让它从 node_modules/.bin 里解析。
如何判断: 在干净容器里复现(Step 5)。最快的信号就是干净 npm ci 后再 npm run build 在本地也挂。
4. 文件名大小写不一致
macOS 和 Windows 文件系统默认不区分大小写,Linux(所有云端 CI 镜像)严格区分。Agent 在 macOS 上写了 import Button from './button',但文件实际叫 Button.tsx。本地能解析,CI 报:
Cannot find module './button' or its corresponding type declarations.
只改了大小写的重命名(button.tsx 改成 Button.tsx)也会踩这个坑:如果 core.ignorecase 没处理好,git 可能不记录这次改动,CI 看到的还是旧文件名。
如何判断: git ls-files | grep -i button 看 git 实际追踪的磁盘大小写,逐字符和 import 对比。
5. 环境变量本地有、CI 没有
Agent 加了 process.env.SOME_API_KEY,你本地 .env 里有,部署平台没有。build 时它解析成 undefined,如果被内联进了客户端代码,运行时就报 undefined is not a function。Astro、Next.js、Vite 都在 build 期读 env,所以 CI 通常当场就挂、而不是运行时才挂。客户端可见的变量还得带对前缀,否则打包器会把它丢掉。
如何判断: grep 最近 diff 里所有 process.env.X,和部署平台的环境变量面板对账。
6. AI 残留了开发期 mock 或只在本地的文件
调试时 Agent 把 next.config.js 或 astro.config.mjs 指向了一个 fixture、本地 JSON 文件、或只有你磁盘上才有的路径。引用进了 commit,但文件没进,build 就找不到:
Error: ENOENT: no such file or directory, open '/vercel/path0/mocks/data.json'
如何判断: git diff main -- '*.config.*' 看所有 config 改动,并确认它引用的路径是否真的提交了(git ls-files | grep data.json)。
最短修复路径
按收益排序。Step 1-2 通常能修掉绝大多数情况。
Step 1:本地用和 CI 一样的 Node 版本 + npm ci 复现
这是收益最高的动作,因为它把”push 完干等”的慢循环变成了本地循环:
# 从 CI log 最前面几行读出 runner 的 Node 版本,比如 22.14.0
nvm install 22.14.0
nvm use 22.14.0
rm -rf node_modules
npm ci # 是 ci 不是 install——它会像 CI 一样严格按 lockfile 装
npm run build
如果本地能复现,就在本地一路改到绿。如果本地依然过,说明差异在平台 env vars 或 CI 镜像本身,跳到 Step 4。
Step 2:检查 lockfile 是否在最近 commit 里
git log --name-only -5 | grep -E '(package-lock|pnpm-lock|yarn.lock)'
如果最近几个 commit 动了 package.json 但从没动 lockfile,立刻补提:
npm install # 按 package.json 重新生成 lockfile
git add package.json package-lock.json
git commit -m "chore: sync lockfile after AI dependency add"
用 pnpm 就 pnpm install 再 git add pnpm-lock.yaml;用 yarn 就 yarn install 再 git add yarn.lock。
Step 3:把 Node(和 pnpm)版本到处钉死
要避免四份版本号各自漂移,干净的做法是单一事实来源:一个 CI 会去读的 .nvmrc 文件。
echo "22.14.0" > .nvmrc
GitHub Actions 里读这同一个文件,而不是硬编码版本:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
同时在 package.json 里声明支持范围,这样本地 Node 版本不对会立刻报错:
{
"engines": {
"node": ">=22.0.0 <23.0.0",
"npm": ">=10.0.0"
}
}
Vercel: Settings -> Build and Deployment -> Node.js Version,选大版本(比如 22.x);Vercel 只暴露大版本,minor/patch 安全更新它自己来。注意 package.json 里 engines.node 的大版本会覆盖这个下拉框。Netlify: 在 netlify.toml 里设 NODE_VERSION,它优先于 UI:
[build.environment]
NODE_VERSION = "22"
如果用 pnpm,再通过 packageManager 字段把 pnpm 也钉住,让 CI 用和写 lockfile 时一样的大版本:
{ "packageManager": "pnpm@9.15.0" }
Step 4:环境变量对账
把代码里所有 process.env.X 列出来,和部署平台对账:
grep -rEo 'process\.env\.[A-Z0-9_]+' src/ | sort -u
平台缺哪个就补哪个,然后 redeploy。客户端可见的变量得带对前缀,否则打包器在编译期就把它剥掉:Next.js 是 NEXT_PUBLIC_*,Astro 是 PUBLIC_*,Vite 是 VITE_*。前缀写错就读成 undefined。
Step 5:在干净容器里复现
终极复现:一个没有任何全局工具、Node 版本严格固定的全新镜像,和 runner 一样。
docker run --rm -it -v "$(pwd):/app" -w /app node:22.14.0 sh -c "
npm ci &&
npm run build
"
如果这里过了但 CI 还挂,剩下的差异基本就是平台 env vars(Step 4)或一份过时的 build 缓存——清缓存再 redeploy。
如何确认修好了
三条都成立才算修完:
- 本地干净跑能精确复现 CI:在固定的 Node 版本上
rm -rf node_modules && npm ci && npm run build全绿。 git status显示 lockfile 已和任何package.json改动一起提交(build 后没有未暂存或脏文件)。- 下一次 CI 在
.nvmrc声明的同一 Node 版本上是绿的(去 log 里核对版本那一行)。
预防建议
- Node 版本单一事实来源:一个
.nvmrc,CI 通过node-version-file去读,再用package.json的engines.node和平台下拉框镜像它。份数越少,漂移越小。 - CI 里始终用
npm ci(而不是npm install),让 lockfile 漂移当场报错,而不是悄悄装了不同的版本。 - 在
CLAUDE.md/.cursorrules里写:“装依赖必须和package.json在同一个 commit 里提交 lockfile。” - pre-commit hook 加一条:
package.json改了就要求 lockfile 也改了。 - import 路径强制大小写一致:ESLint 开
import/no-unresolved配大小写敏感的 resolver,或用eslint-plugin-import的大小写检查。 - 把
.env.example提交进 repo,CI 在 build 前校验所有必需 env vars 都已设置。 - 连包管理器本身一起钉(
packageManager字段),让 pnpm/yarn 的 lockfile 格式跨机器保持一致。
常见问题
为什么 npm install 能过、npm ci 却挂?
npm install 会悄悄更新 lockfile 去匹配 package.json;npm ci 不会,两者对不上时它直接报错。这种严格正是关键所在:CI 应该在 lockfile 过期时失败,而不是默默装一棵不同的依赖树。把重新生成的 lockfile 提了,错误就没了。
我的 CI log 没打印 Node 版本,怎么知道它用了哪个?
GitHub Actions 上,setup-node 步骤会打印解析出的版本;没这一步的话,runner 用镜像默认值,而这个值会随时间变。加一行 - run: node -v 让它显式打印出来,然后钉死。Vercel 和 Netlify 上,版本在 build log 顶部附近。
我一年前钉了 Node 18,本地现在还”能跑”,要升吗? 要。Node 18 已于 2025 年 4 月 EOL,不再有安全补丁,平台也在下线它(截至 2026 年 6 月,Vercel 默认 24.x、Netlify 默认 22)。换到一个活跃的 LTS,比如 22 或 24,先在本地用它把整套 build 跑通,再在所有同样的地方钉上新版本。
只在 CI 挂、本地从来不挂,连 npm ci 都过,还剩啥?
那说明问题不在依赖,而在环境:少了个 env var(Step 4)、只有 Linux 才较真的文件名大小写(原因 4)、或 config 引用了没提交的文件(原因 6)。Step 5 的 Docker 复现是把大小写和缺文件问题逼出来的最快办法;Docker 过了之后还剩的,就是 env vars。
直接删掉 lockfile 让报错消失行不行? 不行。删了之后每次 install 都不可复现,CI 和你本机随时可能漂到不同版本。正确做法是提交一份最新的 lockfile,而不是把这道检查去掉。