先记住这个答案
require ESM 在 Node 22.0.0 引入,并回移到 20.17.0,但引入不等于当时默认启用:22.12.0 和 20.19.0 取消了所需实验开关,22.13.0 与 20.19.0 默认不再发出该实验警告。它仍要求被加载模块及其依赖图没有顶层 await,通常返回模块命名空间,默认导出放在 .default。遇到异步模块图应使用 import() 并等待结果,而不是把 await 藏到间接依赖里。发布包时应声明并测试具体运行范围,不能只写“Node 22 都支持”。
- 引入、默认启用和默认警告是不同版本节点
- 同步要求覆盖间接导入的整个模块图
- 普通默认导出通常从返回值的 .default 访问
先核对版本细节而不是只看主版本
本地开发、CI 和生产容器可能使用同一主版本下不同补丁,因此互操作在一处正常并不说明另一处也可用。记录 node --version 与启动参数,才能判断是能力未启用、解析失败还是异步求值限制。
在支持该检测项的版本可以查看 process.features.require_module,但能力检测不能代替包的最低版本声明。还需要确认启动参数没有关闭特性,并让兼容性测试运行实际发布产物,避免只验证转译前源码。
用独立文件观察返回命名空间
下面的 ESM 同时导出默认函数和命名常量,CommonJS 通过 .default 调用默认函数。它没有顶层异步初始化,所以在支持同步 require ESM 的环境里可以直接完成加载。
示例采用明确扩展名以排除 package.json 的影响。若真实依赖用了特殊 module.exports 互操作导出,返回形状可能由作者定制;消费方应查看包的公开契约,不能盲目追加 .default。
// file: format.mjs
export const separator = ' / ';
export default function format(parts) {
return parts.join(separator);
}
// file: main.cjs
const formatModule = require('./format.mjs');
console.log(formatModule.default(['Node', 'ESM']));
console.log(formatModule.separator === ' / ');在具备相应能力的 Node 中执行 node main.cjs,输出 Node / ESM 与 true。这里验证命名空间访问方式,函数名和扩展名并不会消除间接依赖里的顶层 await。
异步依赖需要改变调用链的完成契约
假设 format.mjs 静态导入配置模块,而配置在顶层 await 远端结果,即使 format 自己没有 await,同步 require 仍可能因异步模块图抛出 ERR_REQUIRE_ASYNC_MODULE。只搜索入口文件会漏掉这个原因。
改用 import() 后,调用方得到的是 Promise,需要把等待传播到初始化入口,并处理失败与超时等业务条件。若公共接口必须同步,可以预先完成初始化或调整模块边界,而不能把 Promise 伪装成已准备好的导出对象。
容易答错的地方
- 把默认启用误写成首次支持
- 这会错误排除带实验开关的旧版本,也可能让早期同主版本用户以为无需配置。应分别说明引入、开关和警告节点,并明确回答针对的是哪一条版本维护线。
- 捕获加载异常后直接返回空对象
- 错误被吞掉后,业务会在更远处报方法不存在,定位反而困难。应区分模块找不到、语法不兼容与异步求值限制,保留错误原因并在启动阶段决定如何失败或选择受支持入口。
面试官还会怎么问?
把顶层 await 包进 async 函数就能解决吗?
只有模块求值本身确实不再等待时,模块图才可能同步,但业务初始化并不会因此自动完成。若函数返回的 Promise 才代表配置可用,消费者仍需要等待它,不能以加载成功代替准备完成。
动态 import 可以在 CommonJS 里直接用吗?
可以,CommonJS 支持 import() 表达式,它返回 Promise,不要求先把整个文件改成 ESM。需要在异步函数或 Promise 链中处理结果,并注意这与同步 require 返回值的完成时机不同。
一个包应该同时提供两份实现吗?
取决于支持的运行环境和消费方式,可以选择兼容构建或明确提高最低版本。双入口也会增加状态实例、导出一致性和测试成本,应验证实际需求,不要只为绕过一个版本错误就复制整套代码。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。