跳到正文

ESM 产物为什么还有 require:CommonJS、external 与宿主加载器

把 format 设为 esm 后,程序仍可能因为 Dynamic require 或找不到模块而失败。需要先看那一段依赖已被内联,还是只保留了留给宿主的引用。下面用固定的 esbuild/Node 实验区分模块格式、依赖实现与加载器;受限边缘运行时另按它实际提供的能力判断。

普通 CommonJS 可以内联,原生模块通常外置,动态路径还需要运行时资源与加载器。ESA 作为受限宿主的例子,单独核对它提供的模块,不把 Node 的互操作能力直接套过去。

同样含有 CommonJS,加载责任可能不同

输出格式与宿主能力分别决定什么

import { build } from 'esbuild';

await build({
  entryPoints: ['src/index.js'],
  outfile: 'release/index.mjs',
  platform: 'node',
  format: 'esm',
  bundle: true,
});

这份配置表达「按 Node 规则解析依赖,输出 ESM」。它没有承诺所有依赖都已内联,也没有自动给 ESM 作用域安装 CommonJS 的 require。将同样的文件上传到 ESA,还要确认保留的外部模块和加载方式是否由宿主提供。

CJS 与 ESM 也不是天然的单向包含关系。Node 可以让 ESM 导入 CJS;较新的 Node 也能在条件满足时 require 同步 ESM,但顶层 await、版本和具体导出形态仍会影响结果。这些是 Node 加载器的能力,不能直接当作 ESA 的能力。Node 的模块互操作文档

内联 CommonJS 与保留外部加载的区别

platform: 'node' 不等于必须输出 CommonJS。Node 支持两种模块系统,但文件扩展名、包的 type 和依赖加载方式需要互相匹配。

ESM 与 CJS 的兼容需要区分内联的模块和留给宿主的模块。esbuild 支持 CommonJS:普通 JavaScript 模块可以被编进产物,通过内部包装函数保留 exports、缓存等行为,最终输出仍然可以是 ESM。实验中的纯 CJS 包内联进 ESM 后能正常执行。

但 Node 内置 crypto 的实现由 Node 宿主提供,不是一份可直接复制进 bundle 的普通 JS 包。platform: 'node' 默认将它 external,也就是保留加载引用,等程序执行时再取得实现。

例如某个 CJS 依赖写了:

const crypto = require('node:crypto');
module.exports = crypto.createHash('sha256').update('abc').digest('hex');

esbuild 将这个依赖内联进 ESM 时,关键加载仍可能表现为下面的形式。这里是省略包装细节的等价节选:

var crypto = __require('node:crypto');

SDK 的其余 JavaScript 已进入 bundle,crypto 本身却仍然 external。__require 会尝试使用宿主的 require;完整 Node 的 ESM 作用域也没有默认 require,所以会报:

Dynamic require of "node:crypto" is not supported

错误里的 Dynamic require 指运行时加载,不证明源码一定写了 require(variable)。字面量形式的 require('node:crypto') 也可以触发它。内部普通 CJS 包装函数可能同样含有 require 字样,也不能仅靠搜索单词就认定产物有问题。

esbuild 可以内联普通 CommonJS 模块;外置内置模块的加载引用仍需要宿主加载器

为什么不自动把它改成 import?静态 import 的依赖会在模块正文执行前初始化,而 require 可以在条件、函数或 try/catch 中执行。直接提升到静态 import,可能改变副作用顺序、失败捕获和惰性加载。esbuild 作者在 issue #1148 的讨论解释过这个语义取舍;0.28.2 的固定实验也确认,外部 require 不会因为 format: 'esm' 自动变成原生 import。

这个边界可以按目标分别处理:

需求 可以选择的处理 交付与语义边界
完整 Node,允许 CJS 输出 输出 .cjs Node 直接提供 require
完整 Node,保持 ESM,允许保留包 ESM import 引入外置 CJS 依赖 随包交付生产依赖,由 Node 为 CJS 文件提供加载器
完整 Node,ESM 产物仍需同步 require 用 Node 的 createRequire 提供局部 require 明确依赖 Node 的 module API
目标只适合 ESM import,模块名单固定 采用 ESM 依赖入口,或通过构建插件桥接已验证模块 核对导出形态、初始化顺序和宿主接口
大量依赖需要自动转换外部 CJS 加载 评估 Rollup 官方 CommonJS 插件 按配置验证转换语义与动态加载范围

createRequire 是 Node 官方 module API,本身是正式的加载器桥接。esbuild 的 banner 是在输出文件中插入这段声明的配置入口,并不等于伪造一个 Node 实现。例如完整 Node 的 ESM 产物可以显式配置:

const config = {
  banner: {
    js: 'import { createRequire as __nodeCreateRequire } from "node:module"; const require = __nodeCreateRequire(import.meta.url);',
  },
};

这段声明提供的是局部 require,没有修改全局对象。它适合明确运行于完整 Node 的产物;ESM 输出到边缘运行时时,应确认该宿主是否提供所需 module API,不能照搬这个方案。

成熟工具也需要选择加载语义。例如 Rollup 官方 CommonJS 插件的 requireNodeBuiltins 默认关闭;显式开启后,它同样使用 createRequire 保留内置模块的惰性加载,并提醒这会依赖 node:module。封装成插件可以减少配置细节,却不会消除宿主能力要求。业务项目可以集中维护一份构建适配,避免在业务源码中重复这些声明。

固定原生模块的桥接也可以由插件完成:把已知 CJS 引用解析到一个虚拟模块,该模块用原生 import 取得宿主实现,再通过 module.exports 保留 CJS 调用方期望的对象。实验里的 crypto 桥接产物只保留 import 类型的外部引用,不需要宿主 require;完整实现放在配套源码中。它没有自行实现 crypto,也不尝试改写任意动态 require,不能直接推广为已适配整个 SDK 或其他宿主。

普通 Node ESM 加载 CJS、打包器内联 CJS,以及 ESM 产物保留外部 require,是三个不同的场景。先看实际产物和 external 的加载类型,才能判断缺少的是依赖实现、模块绑定,还是宿主能力。

把模块问题放回交付物中检查

发布前应该检查真正的入口文件,而不是只确认构建命令退出为零:

产物中的内容 需要继续确认
内部 CJS 包装函数 对应依赖是否已内联,调用是否只经过内部包装
外部 ESM import 目标宿主是否提供模块,或交付包是否保留依赖文件
外部 require / __require 目标作用域是否有对应加载器,能否使用受控桥接
运行时目录扫描 发布后目录和资源是否仍存在
已转换的业务入口 框架路由、初始化和错误路径是否保持正确

文件即路由也属于这条边界。框架可以在构建时扫描文件并生成路由表,通用 bundler 则不会自动理解任意框架约定。运行时仍扫描目录的代码,需要保留目录或修改资源清单。Node.js 构建专题保留了文件路由与资源交付的示意,不能把这些行为归结为 esm / cjs 选项。

普通 CJS 内联、外部 require、Node createRequire 和固定原生模块桥接,都在构建实验源码工作区统一维护。19 项控制场景既检查成功,也检查预期的构建与运行失败;它们没有调用真实云服务。