当 Vercel 禁用了 require(esm):一次 Waline 升级的破案记录
前言
我的博客评论区用的是 Waline,服务端部署在 Vercel 上。
前几天在给博客做一轮插件优化:PWA、图床、字体、缓存,清单一项项划掉。最后一项是顺手升级评论系统的依赖,服务端还停在六月份部署的 1.40.3,官方最新已经到了 1.43.4。
我以为这是十分钟的活。改个版本号,重新部署,收工。
结果这个「十分钟的活」折腾了三个小时,线上两次翻车,中间还把整个依赖树扫了一遍,就为了找到一行 require。
破案之后回头复盘,这个坑很值得写下来:Waline 没有 bug,平台也没有出故障,纯粹是规则变了。Vercel 关掉了 require(esm),而一堆依赖树里还留着对它的隐形假设。
如果你也在托管平台上跑 Node 服务,这篇应该能帮你省下那两个小时。
一、例行升级变事故
Waline 的 Vercel 部署是当初照官方模板搭的,一年多来很省心:写完评论提交,服务端平时没有任何存在感。
这次是我主动去动它:把六月那次部署重新构建了一下,让依赖里的 latest 重新解析,服务端从 1.40.3 拉到了最新的 1.43.4。
构建很顺利,几十秒就绿了。
然后我刷了一下页面:评论区出不来。直接请求服务端任意路由:
$ curl -i "https://<评论服务>/api/article?path=/&type=time"HTTP/2 500x-vercel-error: FUNCTION_INVOCATION_FAILED全都 500,连根路径也是。FUNCTION_INVOCATION_FAILED 的意思很明确:函数在业务代码跑起来之前就死了。
先别急着修,第一步是止血:Vercel 上可以把域名随时切回任意一个历史部署。我把评论服务的域名切回六月那份能工作的版本,几十秒后评论区恢复。
「先回滚再排查」这条老规矩,这一天救了我两次。

二、第一层根因:一行 require
回滚之后,终于可以安心看日志了。Vercel 的运行日志写得很直白:
Error [ERR_REQUIRE_ESM]: require() of ES Module/var/task/node_modules/@mdit/plugin-emoji/dist/index.jsfrom /var/task/node_modules/@waline/vercel/src/service/markdown/index.js not supported.翻译一下:Waline 的代码用 CommonJS 的 require() 去加载 @mdit/plugin-emoji,而这个包是纯 ESM 的。在当前运行时里,这种加载方式不被允许。
去 Waline 源码里翻,文件第一行就是「凶手」:
const { fullEmoji } = require('@mdit/plugin-emoji');有意思的是,同一个文件往下看,其他 @mdit 插件全都已经改成了动态 import:
const { sub } = await import('@mdit/plugin-sub');const { sup } = await import('@mdit/plugin-sup');const { spoiler: spoilerPlugin } = await import('@mdit/plugin-spoiler');对比之下,emoji 这一行像是某次重构时漏改的。

去 Waline 的 issue 区搜了下,确实有先例:两个 issue(#3736、#3801)描述的报错和我遇到的完全一样。
官方给的修复方案是改 vercel.json,打开一个环境变量:
{ "env": { "NODE_OPTIONS": "--experimental-require-module" }}原理:Node 早期版本的 require(esm) 能力藏在实验开关后面,用这个 flag 手动打开,CJS 代码就能正常加载 ESM 包。issue 下面还有用户回复说「改完部署就好了」。
我照着改了,重新部署。
还是 500。
三、官方方案翻车:NODE_OPTIONS 为什么没用了
报错一模一样,说明 NODE_OPTIONS 根本没生效。把新版运行时的日志再翻一遍,两件事进入视野。
第一,调用栈里出现了这样的帧:
at /opt/rust/nodejs.js:2:14482at Function.Eo (/opt/rust/nodejs.js:2:14860)/opt/rust/ 这个前缀说明 Vercel 换了运行时引擎:新版用 Rust 重写了 Node 的模块加载器。这不是个普通 Node 环境。
第二,在 Vercel 社区里翻到有人把同类问题挖到了底(原帖):新版运行时会在进程启动时,强制往参数里塞一个 --no-experimental-require-module。具体是哪次平台更新换的规则,我没翻到公告,但复现出来的行为和这条帖子完全对得上。
这个细节是整个案子的转折点。Node 的参数优先级里,命令行参数(execArgv)压过环境变量(NODE_OPTIONS)。也就是说:
- 我通过 NODE_OPTIONS 说:打开
require(esm); - 运行时通过
execArgv说:关掉; - 后者赢。
官方 issue 里那个方案写在运行时更新之前。在被验证「有效」的时间点和被我遇到的时间点之间,Vercel 换了运行时,旧的 workaround 就此过期。
结论:在当前运行时下,这条路是死的。想升级,就得让依赖树自己兼容「禁用 require (esm)」的环境。
四、修一层崩一层
既然平台不肯打开开关,那就把依赖树里所有「CJS 加载 ESM」的地方找出来处理掉。它们分两类:一类在 Waline 自己的源码里(比如那行 emoji),一类藏在依赖深处:某个库的某个子依赖在 require 别的 ESM 包。
先修了 emoji 那行(改成懒加载的动态 import,后面细说)。部署。
还是 500,但报错变了:
Error [ERR_REQUIRE_ESM]: require() of ES Module/var/task/node_modules/@exodus/bytes/encoding-lite.jsfrom /var/task/node_modules/html-encoding-sniffer/lib/html-encoding-sniffer.js not supported.修掉一层,暴露下一层。这次是 html-encoding-sniffer(jsdom 的子依赖)在加载 @exodus/bytes。如果这样一层层修,不知道要打几只地鼠。
于是我停下来,换了个思路:写个小脚本,把整个 node_modules 扫一遍:解析每个包里的 require('xxx') 调用,判断目标包是不是 ESM-only,是的话就标记出来。核心逻辑就十几行:
// 伪代码:遍历所有 .js 文件里的 require() 字面量for (const spec of file.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { const target = resolvePackage(spec); // 找到目标包 if (target.type !== 'module') continue; // 目标是 CJS,安全 if (hasRequireEntry(target, spec)) continue; // exports 里有 require 条件,安全 flag(file, spec, target); // 剩下的就是断边}跑出来 22 条断边。分布很有规律:
| 来源 | 数量 | 情况 |
|---|---|---|
| Waline 源码 | 1 | emoji 那行,已修 |
| jsdom@30 依赖链 | 一大片 | @exodus/bytes、@asamuzakjp/css-color、@asamuzakjp/dom-selector 等 |
| 其它 | 几条 | 复核后是误报(包内有 cjs 子目录 / cli 文件运行时不会加载) |

大部分断边指向同一个源头:jsdom。而 Waline 全站只有一个地方用它:xss.js 里做评论内容的 XSS 清洗,用的还是最基础的 new JSDOM(...)。
于是决策变得简单:给 jsdom 加一个版本覆盖,钉在最后一个「全 CJS 依赖树」的大版本上。选 24.1.1 的依据:
- jsdom 从 27 之后才开始引入
@exodus/bytes这类新式 ESM 依赖,24.x 还没沾上; - Waline 的
xss.js在 1.40.3 和 1.43.4 之间一字未改,而 1.40.3 配的正是 jsdom 19,API 完全兼容; - 我们只用它一个 API(
new JSDOM()),降级风险接近零。
{ "overrides": { "jsdom": "24.1.1" }}改完之后再扫一遍,断边清零,剩下的只有人工复核过的误报。
五、搭一个「本地 Vercel」
改是改完了,但我不想再拿线上试错,两次翻车已经够本。接下来要在本地证明一件事:「修复后的依赖树」不会再撞 require (esm)。
这里的关键其实一句话:Vercel 运行时的行为 = Node 加一个 --no-experimental-require-module。把同样的组合在本地拼出来,就得到一个「本地 Vercel」。
选 Node 22.11 是因为它默认已开启 require(esm),加上禁用的 flag 强行关掉,行为与 Vercel 运行时一致。一条命令就能拉起这个环境:
然后搭了个三级验证环,从窄到宽:
第一级,单点复现。 直接加载 Waline 入口:先在本地复现出和线上一模一样的报错(说明环境可信),修复后再跑,通过。
第二级,全树扫描。 就是上面那个脚本:修复前 22 条断边,修复后清零。
第三级,全量功能模拟。 写了个几十行的脚本,真启动整个 Waline 应用,用 mock 的 HTTP 请求打真实路由,还顺手跑一遍完整渲染管线(emoji、公式的 MathJax 渲染、XSS 清洗)。数据库用占位配置(连接失败是预期),只要不出现模块加载崩溃就算过。
第三关全绿的那一刻比较爽:
[sim] boot OK[sim] RENDER DEFAULT OK: "<p>hi 😄 and <svg ..." ← MathJax 渲染出了 SVG[sim] SANITIZE OK[sim] GET / -> 200这套环后来证明值回票价:它把「上线」从赌博变成了过闸机。

六、修复三件套上线
最终上线的是三个文件。看着不多,但每一条都是前面几层排查换来的:
| 文件 | 内容 | 解决什么 |
|---|---|---|
patch-waline.cjs + postinstall | 把 emoji 那行静态 require 改成懒加载的 await import | Waline 源码里唯一的 CJS→ESM 断边 |
package.json overrides | jsdom 钉在 24.1.1 | jsdom@27+ 的整条 ESM 子依赖链 |
vercel.json | 官方新版模板(includeFiles 打包 MathJax 字体等运行时数据) | 动态 import 的资源文件在构建时被漏打包 |
postinstall 补丁有个小心思:它不改别人的仓库,只在部署流程里(npm install 之后)对依赖文件做一次定点替换,找不到目标就跳过(no-op)。将来 Waline 官方把源码修掉,这个补丁自然退役,不需要谁来记得删。
还有一个反直觉的决定:把依赖从 latest 改成了写死的 1.43.4。以前我也喜欢 latest,自动吃新版本多省心。这次它教育了我:latest 给你的是「未知的组合」,而排障最需要的是「确定的组合」。升级可以手动来,一次一个版本。
第三次上线,验证清单全过:
- ✅ 服务端响应头里的版本号:1.43.4
- ✅ 评论计数、浏览量读写正常(真实数据库)
- ✅ 评论 RSS 正常渲染(完整跑过 Markdown 渲染管线)
- ✅ 运行日志零错误
评论区回来了,而且比之前更快,因为顺带把评论包的懒加载也做了(那是另一场优化,改天单独写)。

七、复盘
三个小时换来的经验,逐条记录:
1. 平台运行时是个隐形依赖。 你的服务实际跑在「Node + 平台私货」上。平台升级运行时不会通知你的依赖树,但会改变它成立的条件。require(esm) 只是其中一种「方言差异」。
2. 依赖连锁问题,扫全树,别打地鼠。 修一层崩一层的时候,最诱人的做法是继续修下一层。正确姿势是停下来,先把问题边界摸清楚(全树扫描),再找最小的干预点,这次最后只动了一个版本覆盖。
3. 把线上复刻回本地,再动手。 「本地 Vercel」听起来麻烦,其实就是一条命令加一个 flag。有了它,验证从赌运气变成确定性动作。两次翻车的教训是一致的:没在本地复现过的修复,都是在拿线上做实验。
4. 官方 workaround 有保质期。 七月有效的方案,十月失效,因为中间的运行时换了。搜到旧帖时,先看时间,再想它假设的前提今天还成不成立。
5. 回滚能力要在事故前练熟。 这天里最亮的一个动作发生在第一次翻车后 30 秒:把域名切回旧部署。故障最终能造成多大伤害,很多时候就看你的回滚速度。
最后一点碎碎念:有人说「不要重复造轮子」,这次偏是靠「重复造轮子」破的案。那个扫描器和模拟脚本市面上没有现成货,加起来花了半小时,省下好几轮线上试错。
平台的便利是租的;真出了事,能救场的只有自己搭出来的排查能力。
十分钟的活,三小时的课。值。

