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 同步 ESM4242同步模块导出 answer,require 后读取命名属性
关闭 require(ESM) 后重跑ERR_REQUIRE_ESMERR_REQUIRE_ESM同一 Node 二进制,仅切换功能开关
require 顶层 awaitERR_REQUIRE_ASYNC_MODULEERR_REQUIRE_ASYNC_MODULE入口直接包含顶层 await
require 间接顶层 awaitERR_REQUIRE_ASYNC_MODULEERR_REQUIRE_ASYNC_MODULE入口转导出,await 位于依赖文件
改用动态 import4242等待同一条异步模块图完成
node 排在 require 前面nodenode两个条件都匹配,先出现的 node 被选中
require 选择独立分支requirerequire明确的 require 条件指向 .cjs 文件
import 选择独立分支importimport同一导出表的 import 条件指向 .mjs 文件
加载未导出的子路径ERR_PACKAGE_PATH_NOT_EXPORTEDERR_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) 和条件导出说明解释观察结果: