经典痛点:本地 npm run dev 一切正常,部署到 Vercel / Netlify / Cloudflare Pages / Render 后,要么 build fail、要么 runtime crash、要么页面能加载但功能全坏。“在我电脑上能跑”(works on my machine)是软件圈最老的梗,而根因几乎永远是同一类:本地的隐式假设在生产环境不成立。
最快的修法(先做这个):在本地跑一次真正的生产 build——npm run build && npm start(Vite 项目用 npm run preview)。大约 90% 的”本地能跑、生产挂”都能在自己机器上一分钟内复现,这就说明它是 build 或代码 bug,而不是什么部署玄学。如果本地 build 和运行都干净、部署后仍挂,那差异就在环境上:按可能性排序是环境变量、Node 版本、Linux 大小写敏感。
这是 diff 的活,不是猜的活。按下面五个维度逐一过:env vars → runtime version → file system → network/CORS → build process。
先定位你属于哪一类
动手改之前,先用生产里的报错字符串对照最可能的原因。
| 生产日志 / 控制台里的报错 | 最可能的原因 | 跳转 |
|---|---|---|
Cannot read properties of undefined、Invalid URL、数据库连接被拒 | 生产缺环境变量 | Step 2 |
X is not a function、SyntaxError: Unexpected token、Unsupported engine | Node/Python 版本不一致 | Step 3 |
Module not found / Cannot find module './Button'(本地 build 正常) | Linux 大小写敏感 | Step 4 |
EROFS: read-only file system、对运行时写入的路径报 ENOENT | 依赖本地文件系统/内存状态 | Step 5 |
dev 正常,只在 build 后挂(白屏、hydration 报错) | dev 服务器 vs 生产 build | Step 6 |
CORS 报错、401/403、cookie 没设上、OAuth 跳转对不上 | origin/cookie/key 差异 | Step 7 |
| 时间戳整体差 N 小时 | timezone/locale | Step 8 |
常见原因(按命中率)
1. 环境变量在生产没设
最高频的单一原因。你本地 .env 里有 DATABASE_URL、STRIPE_SECRET_KEY 等,但部署平台从没收到这些值,于是运行时 process.env.X 是 undefined。还有个更隐蔽的变体:变量确实设了,但只设在了 Preview 环境而不是 Production(Vercel 的变量是按环境分作用域的);或者一个要暴露给浏览器的变量缺了必需的前缀(NEXT_PUBLIC_、VITE_、PUBLIC_),导致 client bundle 打包时根本没带上它。
如何判断:生产日志报 Cannot read properties of undefined、Invalid URL,或启动时数据库/Redis 连接被拒。
2. Node / Python 版本不一致
本地 Node 24、生产跑在更老的大版本上——或者反过来。截至 2026 年 6 月,各平台的默认版本都往上挪了,所以”生产卡在 Node 18”这个老假设现在通常已经不成立;但如果 .nvmrc 或项目设置里钉死了一个老版本,照样会咬人:
- Vercel:提供
24.x(默认)、22.x、20.x。Node 18.x 已弃用。 - Cloudflare Pages:V3 build image 默认 Node 22(2025 年取代了原来的 Node 18 默认值)。
- Render:新建 service 默认 Node 22.10.0;已有 service 保留创建时的版本。
- Netlify:读取
.nvmrc/NODE_VERSION;当前默认跟随某个活跃 LTS。
ES2023+ 的语法和 API(Array.prototype.toSorted、findLast、某些配置下的 top-level await)在 Node 20+ 上存在、在 18 上不存在,所以仍钉死在 18 的 service 跑现代代码会直接崩。
如何判断:生产日志报 X is not a function、SyntaxError: Unexpected token,或平台直接以 Unsupported engine / EBADENGINE 拒绝 build。
3. 文件系统差异(大小写、路径分隔符)
你的笔记本在骗你。macOS(默认 APFS)和 Windows(NTFS)是大小写不敏感的;而 Linux——所有主流生产环境跑的系统——是大小写敏感的。
// macOS / Windows:大小写不敏感,这能解析
import './components/Button'; // 磁盘上文件名是 button.tsx——本地照样能跑
// Linux 生产:大小写敏感,这会抛错
import './components/Button'; // Error: Cannot find module './components/Button'
同样的坑也会出现在资源路径(/Logo.png vs /logo.png),以及任何你在运行时读取的字符串路径上。
如何判断:生产 build 报 Module not found / Cannot find module,但在你的 Mac/Windows 上 npm run build 是干净的。
4. 依赖本地服务(文件系统、内存状态)
本地你用 fs.writeFile('./uploads/x.jpg') 存上传,或把缓存放在一个模块级变量里。部署到 serverless 平台(Vercel Functions、Cloudflare Workers/Pages Functions)后,文件系统除了 /tmp 之外都是只读的,而且每次调用可能命中不同的冷实例——于是内存状态会悄悄重置、写入直接抛错。
如何判断:报 EROFS: read-only file system、ENOENT,或”上传成功了一次然后就 404”,或者缓存/会话莫名其妙清空。
5. dev 服务器和生产 build 行为不同
Vite / Next / Webpack 的 dev 模式不打包、带 HMR、解析宽松。生产 build 会 tree-shake、minify,并(Next/Astro 等)做服务端渲染。依赖 dev-only 行为的代码——读 import 的副作用、SSR 期间用 window、组件输出不确定——只会在 build 之后挂。
如何判断:npm run dev 正常,但 npm run build && npm start 复现出白屏、React hydration mismatch,或 window is not defined 的 SSR 报错。
6. CORS / cookie domain / 第三方 key
localhost:3000 和 yourdomain.com 是不同的 origin,因此受不同的 CORS allowlist、不同的 cookie SameSite/Secure 行为(Secure cookie 在纯 http://localhost 上会被丢弃,除非你显式放开)、以及不同的 OAuth redirect-URI allowlist 约束。一个常见疏漏:dev 用了 provider 的测试 key,而 OAuth app 里注册的 redirect URI 只列了 localhost。
如何判断:生产 Network 面板报 CORS error、401/403、“cookie 没设上”,或 dev 里从没出现过的 OAuth redirect_uri_mismatch。
7. timezone / locale
new Date().toString() 按你笔记本的本地时区渲染;生产 server 跑在 UTC。于是所有服务端格式化出来的时间戳都会差你的 UTC 偏移量。
如何判断:生产里日期/时间一致地偏移了固定小时数。
最短修复路径
Step 1:本地复现生产 build
在动部署平台之前,先跑一次真正的生产 build:
# Next.js
npm run build && npm start
# Vite(React/Vue/Svelte SPA)
npm run build && npm run preview
# Astro / 通用 Node server
npm run build && NODE_ENV=production node ./dist/server/entry.mjs
如果这一步就挂了,那就是 build/代码问题、不是部署——在反馈最快的本地修掉。如果这一步干净,差异就在环境上,继续往下。
Step 2:diff env vars(可靠的做法)
别手动誊抄 dashboard。在 Vercel 上,把线上环境变量拉到本地文件,再跟你的 .env 对比:
# 把生产环境变量拉到 .env.production.local
vercel env pull .env.production.local --environment=production
# 都归一化成 KEY 名,比较各自存在哪些
comm -3 \
<(grep -oE '^[A-Z0-9_]+' .env | sort -u) \
<(grep -oE '^[A-Z0-9_]+' .env.production.local | sort -u)
打印在左侧的,就是本地有、生产没有的。把缺的补上:
vercel env add DATABASE_URL production
vercel env add STRIPE_SECRET_KEY production
# CLI 会提示你粘贴值(输入不回显)
然后重新部署——环境变量的改动不会作用到已有的部署上。几个高频坑的检查清单:
- 设在了正确的环境(Production,而不只是 Preview/Development)。
- 要暴露给浏览器的变量带上框架前缀:
NEXT_PUBLIC_、VITE_、PUBLIC_(Astro/SvelteKit)。 - 多行密钥(私钥)保留换行——直接粘贴,别手敲。
其他平台从 dashboard 核对:Render → service → Environment;Cloudflare Pages → Settings → Variables and Secrets;Netlify → Site configuration → Environment variables。
Step 3:锁 runtime 版本
让本地、CI、生产统一到同一个 Node 大版本。在 package.json 和一个版本文件里都钉死:
// package.json——Vercel/Netlify/Cloudflare 都读 engines.node
{
"engines": {
"node": "22.x"
}
}
// .nvmrc(也接受 .node-version)
22
截至 2026 年 6 月的各平台细节:
- Vercel 部署你指定大版本的最新补丁版(如
22.x→ 最新的 22)。engines.node会覆盖 dashboard 里的设置。在 Build Command 里跑node -v、或运行时打印process.version来验证。 - Cloudflare Pages 按这个顺序解析:
.nvmrc/.node-version→NODE_VERSIONbuild 环境变量 → 默认(V3 image 上是 Node 22)。 - Render 读
NODE_VERSION(环境变量)或.node-version;接受 semver 或lts这类别名。
Step 4:修 Linux 大小写敏感
找出大小写跟磁盘文件对不上的 import:
# 列出相对 import,再跟实际文件名比对
grep -rEn "from '\.\./|from '\./" src/ | sort -u | head -50
ls -la src/components/
要可靠地自动抓到它,就在一个跟生产匹配的 Linux 容器里跑 build(一行 dry run,不必去折腾 APFS 大小写敏感分区):
docker run --rm -v "$PWD":/app -w /app node:22-slim sh -c "npm ci && npm run build"
如果这个 build 报 Cannot find module 而本地 build 通过,你就抓到了一个大小写 bug。用 eslint-plugin-import(import/no-unresolved)把它常驻 lint 住,并把文件名/import 改成完全一致。
Step 5:替换本地专属依赖
把任何”假设有可写、持久化本地机器”的东西,换成云原生的等价物:
| 本地用(在你机器上能跑) | 生产改用 |
|---|---|
fs.writeFile 写到 ./uploads | S3 / Cloudflare R2 / Cloudinary |
| 内存缓存 / 全局变量 | Redis (Upstash) / Cloudflare KV |
| 本地 SQLite 文件 | Postgres (Neon/Supabase) / Turso |
本地 cron / setInterval | Vercel Cron / Cloudflare Cron Triggers / Inngest |
| 从本地文件读密钥 | 平台环境变量 / 密钥管理服务 |
在 serverless 上,如果非要碰磁盘,只有 /tmp 可写,而且每次调用都是临时的。
Step 6:让 dev 和 prod 行为一致
不要按 NODE_ENV 分叉逻辑——那等于保证 dev 永远测不到 prod 的 bug:
// 反模式:dev 永远走不到 prod 路径
if (process.env.NODE_ENV === 'development') {
// ...
}
// 更好:一个显式、可测试、能在 staging 翻转的 flag
const useMock = process.env.USE_MOCK === 'true';
然后用一个设了生产 flag 的 staging/preview 部署去跑、在那里验证,再 merge。对 SSR 框架,碰浏览器全局对象前要先守卫:if (typeof window !== 'undefined') 之后再用 window/document。
Step 7:为生产 origin 对齐 CORS、cookie 和 OAuth
生产 origin(https://yourdomain.com)不是开发 origin(http://localhost:3000),所以一切按 origin 绑定的东西都得重新登记:
- CORS allowlist:把生产 origin 加到服务端的
Access-Control-Allow-Origin(以及对应的预检配置),不能只有localhost。如果要跨域带 cookie,得设Access-Control-Allow-Credentials: true,并且用明确的 origin,而不是*。 - Cookie:生产是 HTTPS,所以要设
Secure,并选好合适的SameSite——跨站用SameSite=None; Secure,同站用Lax。一个在http://localhost上能用的 cookie,如果要求Secure但请求不是 HTTPS、或者Domain配错了,在生产就会被悄悄丢弃。 - OAuth:把生产回调(
https://yourdomain.com/api/auth/callback/...)加进 provider 的 redirect-URI allowlist,并把 provider 的测试 key 换成正式 key。
验证:打开生产站点,复现登录/请求流程,确认 Network 面板里没有 CORS 拦截、没有 redirect_uri_mismatch,且会话 cookie 带着预期的 Secure/SameSite 属性被设上了。
Step 8:统一用 UTC
// server 端:用 UTC ISO 字符串存储和传输
const iso = new Date().toISOString(); // 如 2026-06-21T08:00:00.000Z
// client 端:只在显示时转成用户时区
new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai' }).format(new Date(iso));
server 上也设 TZ=UTC,这样即便有零散的本地时间格式化,至少是一致的。
如何确认已修好
- 本地生产 build(
npm run build && npm start)干净通过。 vercel env pull的 diff 显示生产没有缺任何 key。- build 日志里的
node -v(或运行时的process.version)在每个环境都匹配你钉死的大版本。 - Linux 容器 build(
docker run … node:22-slim … npm run build)成功。 - 重新部署,再重新触发那个出问题的操作。生产日志里原来的报错字符串消失、浏览器控制台干净。
FAQ
为什么本地能 build、只在部署平台挂?
你的开发机大小写不敏感(macOS/Windows),而部署跑在大小写敏感的 Linux 上,而且它的环境变量跟你生产项目里的不一致。用一个 node:22-slim 的 Docker build 加一次 vercel env pull diff 同时复现这两点,差异通常立刻就冒出来了。
npm run dev 能跑、npm run build 挂——差在哪?
dev 服务器不打包、解析宽松;生产 build 会 tree-shake、minify,并(SSR 框架)做服务端渲染。常见元凶:SSR 期间碰了 window/document、React hydration mismatch、以及只有打包器会丢掉的 import 副作用。在本地用 npm run build && npm start 复现。
我在 Vercel 上加了环境变量,但它还是 undefined。
环境变量的改动只对新部署生效,所以要触发一次重新部署。再确认它设在了 Production(不只是 Preview),且任何 client 端变量都用了框架前缀(NEXT_PUBLIC_ / VITE_ / PUBLIC_)——少了它,值永远到不了浏览器 bundle。
怎么让所有地方的 Node 版本一致?
在 package.json 里写 "engines": { "node": "22.x" },在 .nvmrc 里写 22。Vercel、Netlify、Cloudflare Pages 都读这两个。GitHub Actions 用 actions/setup-node@v4 配 node-version-file: .nvmrc。每个 build 日志里用 node -v 验证。
我的时间戳在生产里正好差 N 小时。
生产 server 跑 UTC,你的笔记本不是。存储和传输都用 new Date().toISOString(),server 上设 TZ=UTC,只在显示时用 Intl.DateTimeFormat({ timeZone }) 转成用户时区。
预防建议
- 每次推 PR 前都跑一次
npm run build && npm start——别只信 dev server。 - 用
zod(或类似工具)在 boot 时校验环境变量,缺 key 时立刻 fail-fast 给出清晰报错,而不是在请求深处崩掉。 - 在
package.json里钉死engines.node,并配一个匹配的.nvmrc;CI 用actions/setup-node@v4配node-version-file: .nvmrc。 - Mac 用户:在 Linux 容器(
node:22-slim)里跑 build,在部署前就暴露大小写敏感 bug。 - 任何”只在本地能用”的代码(
fs.write、内存状态)从 day 1 就用云服务替代。 - 维护一个配置跟 prod 一致的 preview/staging 环境,每个 PR merge 前先在那里验证。
- 时间一律存 UTC ISO 字符串,只在显示层转 timezone。
- 全团队统一一个
.nvmrc/.python-version,彻底告别”我电脑能跑”。