别被 500 骗了:一次 Vercel 部署中 ESM/CJS 混战的真实复盘

最近我在折腾一个 AI 驱动的博客 CMS,用了经典的 monorepo 架构:apps/web 是前台,打算直接扔进 Vercel 托管;apps/admin 是后台,因为重度依赖 Playwright,只能老老实实本地跑;packages/core 则是全家桶共享库。环境挺干净,前台就两个变量:DATABASE_URL(Supabase 的连接串,池化端口 6543)和 LLM_ENCRYPTION_KEY。本以为是个顺滑的部署流程,结果硬生生让我体验了一把"坐过山车"的感觉。今天就把这次翻车现场完整扒一遍,希望能帮你省下几个熬夜排查的夜晚。

开局不利:CLI 的"贴心"默认设置

一开始我信心满满,用 Vercel CLI 完成设备授权登录后,顺手创建了项目 smart-blog-web,把 Root Directory 指向 apps/web。想着万事俱备,直接开干。

第一次我从 apps/web 目录下执行 vercel link,心里还嘀咕这 CLI 挺智能啊,自动识别子目录。结果构建日志一出来,傻眼了------它居然只上传了 apps/web 这一个文件夹,总共才 43KB。远端根本找不到 packages/core,构建直接报错罢工。我这才反应过来,Vercel CLI 的 link 逻辑是按当前工作目录走的,你站在子目录里,它就以为你的项目只有这么大。赶紧切回仓库根目录重新 link,并在 Dashboard 里手动把 Root Directory 指回 apps/web,这才走上正轨。

上了正轨不代表一路顺风。根目录执行 vercel link 后,CLI 默认会把整个仓库打包上传。好家伙,357MB!node_modules、.browserslistrc、甚至本地的测试数据全塞进去了。上传速度龟速不说,Vercel 的构建配额也被瞬间吃满。我赶紧在项目根目录甩了一个 .vercelignore,把那些冗余目录统统拦在外面。再一看上传体积,瞬间清爽到 2.3MB。这个坑不算大,但第一次上云的人大概率会栽一下。

环境变量配置倒是中规中矩,Production 和 Preview 环境都配齐了,顺便把 Vercel 默认的访问保护关了(不然每次预览链接都得先登录 Vercel 账号才能看,体验太割裂)。一切准备就绪,点击 Deploy,进度条走完,状态显示 Ready。我以为可以撒花庆祝了。

构建绿灯,运行时红灯:500 背后的隐形杀手

现实总是喜欢在你最放松的时候给你一记闷棍。打开前端域名,白屏,控制台冷冷地抛出一句:"This page couldn't load"。刷新、换路由、试 Preview 地址,无一例外,全部 500。

这时候千万别慌,也别急着去改代码。构建日志是绿色的,说明编译阶段没问题。问题出在运行时。我果断点开 Vercel Dashboard 里的 Function Logs,果然,一行刺眼的红字跳了出来:

vbnet 复制代码
Error: Failed to load external module jsdom-...: Error [ERR_REQUIRE_ESM]: require() of ES Module .../@exodus/bytes/encoding-lite.js from .../html-encoding-sniffer/lib/html-encoding-sniffer.js not supported

看到 ERR_REQUIRE_ESM 这几个字母,技术人的 DNA 应该已经开始动了。这题我熟。

抽丝剥茧:一条 ESM 依赖链如何掀翻整个函数

咱们来顺一下这条倒霉的依赖链。Next.js 为了优化打包体积,默认会把一些大型包(比如 jsdom)标记为 external,也就是不打进 Server Bundle,而是让运行时的 Node 环境动态加载。Turbopack 生成的 server runtime 很传统,用的是 CommonJS 的 require() 去拉这些外部模块。

但问题来了。我项目中用的 jsdom@30,它的底层依赖 html-encoding-sniffer@7,又引出了 @exodus/bytes。这个包是个纯 ESM 模块,压根没留 CJS 的入口文件。Vercel 的函数运行时为了安全起见,严格限制了 require() 加载 ESM 的行为。于是,CJS 的 require() 撞上了纯 ESM 的墙,啪叽一下,抛出 ERR_REQUIRE_ESM。

最要命的是,这不是某个页面的小 bug。它是整个 Server Runtime 加载失败的连锁反应。jsdom 作为 Next.js 默认 external 的核心包之一,一旦启动报错,整个函数的生命周期直接熔断。所以你会看到所有路由集体 500,症状极具迷惑性,很容易让人误以为是路由守卫或 API 接口挂了。

那怎么精准定位呢?全靠一招"照镜子"。Vercel 的运行时其实是禁用了部分实验性 Node 特性的。我在本地复现时,发现正常启动完全没事。于是我在终端里加了个环境变量开关:NODE_OPTIONS=--no-experimental-require-module next start。这一加,本地开发服务器瞬间跟 Vercel 的环境一模一样,精确复现了 ERR_REQUIRE_ESM。这一步省了我无数遍对着空白文档发呆的时间。

降级与补丁:在依赖泥潭里走钢丝

既然找到了病根,思路就很清晰了:得让整条 jsdom 依赖链退回 CJS 时代。我查了一圈历史版本,jsdom@26.1.0 是全链路仍保持 CJS 兼容的最后稳定版。之后的 27.x 系列,要么引入了纯 ESM 的 @csstools/css-calc@3,要么干脆换了 @exodus/bytes。没办法,只能乖乖降级。

我把 packages/core 和 apps/admin 里的 jsdom 全部固定到了 26.1.0。更新锁文件,重新安装,看着 pnpm install 跑完,心里稍微踏实了点。

结果第二次踩坑来得比想象中快。降级之后,pnpm 的智能解析机制给 jsdom@26 匹配了 cssstyle@4.2.1。这玩意儿有个隐藏特性:它会极其粗暴地把内联样式里的 CSS 变量值(比如 var(--nav-h, 0px))整个丢掉。我的博客主题里有个布局补丁靠的就是 padding-top: var(--nav-h, 0px),这下直接失效,导航栏和内容的间距全乱了。更糟糕的是,packages/core 的单测数量直接从 5/5 掉到了 3/5。要是没跑测试,上线后绝对会被用户疯狂吐槽。

好在有惊无险。我迅速在 package.json 里加了个 pnpm 的 override 规则:

json 复制代码
"overrides": {
  "jsdom@26.1.0>cssstyle": "4.6.0"
}

版本号我特意选在了 ^4.2.1 的安全范围内,既修复了变量丢失的回归问题,又保证了依赖链依然维持在 CJS 兼容状态。单测一口气回到 5/5,布局补丁也乖乖回来了。

验证与复盘:给后来者的三条保命建议

修复方案定稿后,我没急着推生产,先在受限环境下做了最终验证。带上 --no-experimental-require-module 参数启动本地服务,直接 require('jsdom'),确认不再抛错,且正确加载了 26.1.0 版本。接着检查 /、/blog、/collections 全部返回 200,/api/stats 数据库连通性正常,渲染产物里也能抓到完整的 padding-top: var(--nav-h, 0px)。最后重新触发一次 Vercel 部署,Dashboard 显示 Ready,Function Logs 干干净净。尘埃落定。

回头看这一趟折腾,其实全是血泪换来的经验。如果你也在搞类似的项目,记住这三点,能少走很多弯路:

  • Next.js 的 external 列表是双刃剑 。给它配上纯 ESM 的依赖,在 Vercel 这种对 require(ESM) 管得严的运行时上,不会只是坏一个接口,而是直接掀翻整个函数。500 报一片的时候,先别急着查业务代码,去翻运行时日志。
  • 本地复刻运行时环境,比盲目猜谜高效十倍 。Vercel、Cloudflare Workers 这些平台都在悄悄收紧 Node 的权限。学会用 NODE_OPTIONS 或 Docker 镜像模拟它们的沙盒行为,排查效率直线上升。
  • 降级依赖永远是有副作用的 。别只看主包版本号,一定要顺着传递依赖链(transitive dependencies)看清楚模块格式有没有变。而且,跑测试!跑测试!跑测试! 像这次 cssstyle 丢 var() 的行为回归,不跑单测根本藏不住。

技术栈升级的路上,坑是填不完的,但每一次踩进去再爬出来,都会让你的工程直觉更敏锐一点。希望这篇复盘能帮你避开类似的雷区。

相关推荐
zach2 天前
next下生成sitemap的笔记
next.js
zach5 天前
RSC(React Server Components)详解
next.js
To_OC7 天前
同样是让 AI 写全栈,为什么别人一句话就够了
前端·前端框架·next.js
FungLeo11 天前
成为全栈·Next.js 网站前台篇·响应式、可访问性与错误状态:内容站不能只验桌面首页
react.js·响应式设计·next.js·移动端适配·成为全栈·web 可访问性
FungLeo12 天前
成为全栈·Next.js 网站前台篇·站点设置如何驱动页头、页脚与 SEO
react·isr·next.js·metadata·成为全栈·站点配置
FungLeo14 天前
成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护
react·csrf·next.js·bff·成为全栈·route handler
FungLeo14 天前
成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台
react.js·内容审核·next.js·评论系统·树形数据·成为全栈
cindershade17 天前
一条请求的接力赛:用运行时责任重画 RSC 边界
性能优化·next.js
zach18 天前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js