Node.js · 模块加载实验
Node 模块加载实录:require、顶层 await 与条件导出
用 9 个自建小例子,记录 Node 22.22.3 的模块加载结果:为什么 require 能加载部分 ESM,传递依赖的顶层 await 如何失败,以及 exports 顺序如何改变入口。附完整脚本、源码和两轮输出。
记录时间: · Node v22.22.3 · macOS 27.0.1 / arm64
同一个 require,为什么会有三种结果
遇到 ESM 加载错误,先区分三件事:运行环境是否启用 require(ESM)、入口是否解析成功、整条模块图是否同步。仅看到 package.json 的 type: module,不能直接得出“CommonJS 一定不能用”的结论。
本次使用自建模块,完全不安装 npm 包。每轮在一个新临时目录创建相同源码,再为每个例子启动独立 Node 子进程。两轮共运行 18 次;退出码和结果先通过断言,再写入记录。下面的结果来自这份记录,不是模型生成的预期值。
下载一个脚本,就能复跑全部例子
脚本要求 Node 22.12 或更高版本;本页只记录当前机器上的 v22.22.3。下载脚本到自己的目录后运行。它会生成自建模块、执行例子并清理自己创建的临时目录,不需要安装依赖,也不会下载第三方源码。
node --version
node run.mjs
# 把你这次的环境与输出保存为独立记录:
node run.mjs --record my-results.json脚本只向明确指定的记录路径写入结果。若用另一版本的 Node 运行,输出可能不同;断言失败会终止,不会把失败记录为成功。这也是重新验证运行环境差异的起点。
两轮观察结果
| 例子 | 第一轮 | 第二轮 | 读取结果时要注意 |
|---|---|---|---|
| require 同步 ESM | 42 | 42 | 同步模块导出 answer,require 后读取命名属性 |
| 关闭 require(ESM) 后重跑 | ERR_REQUIRE_ESM | ERR_REQUIRE_ESM | 同一 Node 二进制,仅切换功能开关 |
| require 顶层 await | ERR_REQUIRE_ASYNC_MODULE | ERR_REQUIRE_ASYNC_MODULE | 入口直接包含顶层 await |
| require 间接顶层 await | ERR_REQUIRE_ASYNC_MODULE | ERR_REQUIRE_ASYNC_MODULE | 入口转导出,await 位于依赖文件 |
| 改用动态 import | 42 | 42 | 等待同一条异步模块图完成 |
| node 排在 require 前面 | node | node | 两个条件都匹配,先出现的 node 被选中 |
| require 选择独立分支 | require | require | 明确的 require 条件指向 .cjs 文件 |
| import 选择独立分支 | import | import | 同一导出表的 import 条件指向 .mjs 文件 |
| 加载未导出的子路径 | ERR_PACKAGE_PATH_NOT_EXPORTED | ERR_PACKAGE_PATH_NOT_EXPORTED | 磁盘上存在文件,exports 中未公开它 |
完整记录还包含每次启动的参数、退出码、源码清单和文件摘要。表中的错误是实验主动触发的失败路径;它们的退出码为 1,不代表脚本漏检了异常。
1. 同步 ESM:先看 Node 的功能开关
export const answer = 42;
// CommonJS 调用端
console.log(require('./sync.mjs').answer);在这次运行中,普通 require 输出 42。给同一命令加上 --no-experimental-require-module 后得到 ERR_REQUIRE_ESM。变化来自加载器功能开关,源码和 Node 二进制都没有变。这个开关实验不能冒充“已经测试旧 Node 版本”。
返回值的形状同样需要核对。这里访问的是命名导出 answer,不是把整个返回值当函数调用;真实库可能有默认导出或不同入口,照搬 require 后的调用方式仍可能出错。
2. 顶层 await:入口文件之外还要继续追
// async.mjs
await Promise.resolve();
export const answer = 42;
// bridge.mjs
export { answer } from './async.mjs';
require async.mjs 和 require bridge.mjs 都得到 ERR_REQUIRE_ASYNC_MODULE。第二个结果尤其容易漏掉:bridge.mjs 自己没有 await,但它转导出的文件有。检查入口存在和文件扩展名,只走到了排错的一半。
import('./bridge.mjs').then(module => console.log(module.answer));
// 本次输出:42动态 import 等待模块完成后得到 42。这说明异步加载边界解决了这个自建例子的阻塞,不等于可以给整个应用机械替换字符串。调用者也要处理 Promise、错误传播和初始化时机。
3. 条件导出:require 不是优先级最高的分支
{
"name": "lab-order",
"type": "module",
"exports": {
".": {
"node": "./node.cjs",
"require": "./require.cjs",
"default": "./fallback.mjs"
}
}
}
这个小包把 node 条件放在 require 前面。CommonJS 调用 require('lab-order') 时两个条件都适用,实际输出是 node,不是 require。不能把导出表理解成按条件名称自动排序的配置。
另一个小包 lab-split 使用独立 import 和 require 条件,同一轮中分别输出 import 和 require。不同分支成功加载,只证明入口选择不同,不能推导两份实现的 API 或状态完全一致。
4. 文件存在,也不等于子路径已经公开
脚本确实写入了 lab-split/private.cjs,但导出表只公开根入口。require('lab-split/private.cjs') 仍得到 ERR_PACKAGE_PATH_NOT_EXPORTED。因此,看到磁盘里有文件时,还要核对你使用的 specifier 是否属于公开入口。
处理真实升级问题时,可以依次记录 node --version、锁文件中的精确版本、失败的调用路径和 exports 表。再判断问题属于功能开关、异步模块图、条件分支还是子路径封装,最后运行受影响的应用流程。
记录范围与复现依据
这是一台 macOS/arm64 机器、一个 Node 版本和九个自建例子的记录。没有测试 Windows、Linux、其他 Node 版本、真实 npm 包、打包器、原生扩展或部署平台。所有“本次输出”都限定在这份环境;实际项目仍需要自己的锁文件和应用测试。
- 脚本 SHA-256
- 1dc3994348f35248920f0d51ed6ad81b2f096f46a1a276d77eb7365b88e9589e
- 自建源码清单 SHA-256
- cc9734499b36b5de32063be1f437e3392c47d69f9b90c421d140502ad6d4c781
依据 Node 官方的 require(ESM) 和条件导出说明解释观察结果: