KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 两套规范相遇时的形状 — keel 龙骨

这一章回答:ESM 去 import 一个 CJS 包,default 到底是什么?import { named } 里的 named 是真有的还是猜出来的?反过来 require 一个 ESM 又会怎样?

这一章回答:ESM 去 import 一个 CJS 包,default 到底是什么?import { named } 里的 named 是真有的还是猜出来的?反过来 require 一个 ESM 又会怎样?

现场:导出里明明写着 a,Node 说 named export not found

一个同学把一个项目从 CJS 迁到 ESM。他改的最普通的一行是:

import { a, b } from "../libs/plain.cjs";

而 plain.cjs 的内容简单到只有一行:

module.exports = { a: 1, b: 2 };

导出里明明白白有 a 和 b。启动却直接炸:

SyntaxError: Named export 'a' not found. The requested module '../libs/plain.cjs' is a CommonJS module,
which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from '../libs/plain.cjs';
const { a, b } = pkg;

你会怎么判断? 对象里有 a,为什么说 'a' not found?

先排掉两个省事的解释:不是文件名写错(报错里的路径就是 plain.cjs),不是对象里没有 a(源码摆在那儿)。方向只剩一个——ESM 侧的 { a, b } 不是在运行时去对象上"取属性",而是在加载时静态地"认名字"。这一步认不出来,就报 Named export 'a' not found,哪怕运行起来对象上真有 a。

有意思的是,这条报错自己就给了逃生方案:末尾三行把改法写好了——import pkg from ...; const { a, b } = pkg;,用 default 拿整个对象、再解构。报错替你写好了补丁,这在 Node 的错误文案里不常见,值得记住。

下面每条现象都来自 E5 系列的探针,全部跑在 Node 22.22.2 上。

一、先钉死:default 恒等于整个 module.exports

这件事拆三步:① 整体加载 CJS,得到那份 module.exports,它就是 default;② Node 静态扫这份源码,认出有哪些命名导出;③ default 始终可用。先钉住最容易忽略的第 ③ 条。ESM 侧 import def from "cjs" 拿到的 default,永远是那整个 module.exports,跟 CJS 侧写的是对象、函数还是别的无关:

CJS 侧写法 import def from 拿到的 default 探针
module.exports = { a: 1, b: 2 } 整个对象 {"a":1,"b":2},且 def === 整体 exports q01
module.exports = function hello(){} 那个函数,能直接调用 q03
const api = {...}; module.exports = api 整个对象(标识符是否"透明"不影响 default) q05

三个探针一起说同一句话:default 是一条永不失效的通道——不管 CJS 侧写成什么形态,import def from 一定能拿到完整的那份东西。报错文案里那句 CommonJS modules can always be imported via the default export 是字面意义上永远成立的。

flowchart TD
    A["ESM 侧:import 一个 CJS 包"] --> B["① 整体加载 CJS<br/>default = 整个 module.exports"]
    B --> C["② 静态扫 CJS 源码<br/>只认赋值形态"]
    C -- 命中赋值形态 --> D["named x 可用"]
    C -. 整体替换或转发一层 .-> E["named 取不到<br/>SyntaxError: Named export not found"]
    B --> F["③ default 恒可用<br/>对象、函数都一样"]
    F -. import 星号 as ns .-> G["ns 键只有 default<br/>且 ns.default !== ns"]
    E --> H["逃生:import pkg 再解构"]
    style D fill:#e8f5e9,color:#1b5e20
    style E fill:#ffebee,color:#b71c1c
    style G fill:#fff3e0,color:#e65100
    style H fill:#e3f2fd,color:#0d47a1

图里在说什么:从左边一行 ESM import 进,先做 ①——把整个 CJS 模块加载成一份 module.exports,它就是 default。② 是关键分岔:Node 要静态地在 CJS 源码里认出命名导出——认出 exports.x =、module.exports.x = 这种赋值形态,named x 就能用(绿边);认不出(整体换成对象字面量、Object.assign、defineProperty 的 getter,或转发一层)就是红边,报 Named export 'x' not found。红框给的补救正是蓝框:用 default 拿整体再解构。右下虚线是命名空间的形状:import * as ns 拿到的键只有 default,且它不是自己。

命名空间那条单独说清楚。import * as ns from "cjs"(q09)拿到的 ns,键只有 ["default"],且 ns.default !== ns、ns.a === undefined:即便 plain.cjs 里真有 a,从 ESM 看这个模块的"形状"就是只有 default 的单键对象——CJS 的导出不是 ESM 命名空间的成员,它是被装进 default 里递过来的。

__esModule 那一组更值得看。CJS 侧手工写:

Object.defineProperty(exports, "__esModule", { value: true });
exports.default = 42;
exports.named = 7;

从 ESM 侧 import def from(q06)拿到的是整个对象,def.default = 42、def.named = 7、def.__esModule = true;而 import { named }(q07)能取到 named = 7。两点:default 依然是整个对象(__esModule 没把 def 换成 def.default);而 named 因为写成了 exports.named = 的赋值形态,被静态认出来了。划重点:__esModule: true 对 Node 的 default 语义没有任何影响——它是给另一类消费者(转译产物、打包器)看的,第五节展开。

二、命名导出是静态"猜"出来的

把九种常见 CJS 写法摆在一起,两种导入方式并排:

CJS 侧写法 import def from import { x } from 探针
module.exports = { a, b }(对象字面量) def = 整个对象 ❌ Named export 'a' not found q01/q02
module.exports = function hello(){} def 是函数 ❌ 'hello' not found q03/q04
const api = {...}; module.exports = api def = 整个对象 ❌ 'a' not found q05
module.exports = require("./plain.cjs")(转发一层) def = 整个对象 ❌ 'a' not found q08
exports.a = 1; exports.b = 2; def = 整个对象 ✅ a=1 b=2 r01
module.exports.b = 2(跟在一句整体赋值后) — b 取得到;整体赋值里的 a 取不到 r03/r07
Object.assign(module.exports, {...}) def = 整个对象 ❌ r04
Object.defineProperty(exports, "dyn", { get }) def = 整个对象 ❌ dyn 取不到;同文件 exports.stat = 6 单独取得到 r05/r06
Object.defineProperty(exports,"__esModule",...); exports.default=42; exports.named=7 def = 整个对象 ✅ named = 7 q06/q07

九行压成一句话:认赋值形态(exports.x =、module.exports.x =),不认整体替换成对象字面量、不认 Object.assign、不认 defineProperty 的 getter;而且中间转发一层(module.exports = require(...))就丢。 表里每一条失败都不是"运行到一半才失败",而是加载时就被判了死刑——是 SyntaxError,不是 TypeError。

q08 单独点名:module.exports = require("./plain.cjs") 在 CJS 里很常见(把另一个模块整体再导出),但从 ESM 看它是"整体替换"、不是赋值形态——被转发的 plain.cjs 里明明就是 { a, b },命名导出还是取不到。静态分析不追进 require 的返回值。

三、从 CJS 进来的命名绑定也是快照

第 02 章讲过 ESM 的绑定是"接线":导出方改了,导入方跟着变。从 CJS 进来的命名绑定不同。mutate.cjs 里 exports.x = 1、5ms 后置 2;ESM 侧 import { x }(q10)立刻读是 1,30ms 之后还是 1。

这不矛盾:第 02 章那条"改了跟着变"(E2 的 cjs/main-late.cjs)是 require 拿到的同一个对象,查属性当然看到新值;而 ESM 的 import { x } 拿到的是一份被提前拍下的值。所以:ESM 的活绑定只对真正的 ESM 导出成立;从 CJS 进来的名字,命中的是 CJS 加载那一刻的 exports.x。判据不是"我用的是不是 import",而是"导出的那一头是不是 ESM"。

四、反过来:require 一个 ESM

Node 22.12+ 起,require(esm) 默认可用(不用 flag)。探针 q20 在一个 .cjs 里 require("../esm-lib.mjs"),而 esm-lib.mjs 是:

export const x = 1;
export default "esm-default";

拿到的结果:

键 = ["__esModule","default","x"]
m.x = 1 | m.default = "esm-default" | m.__esModule = true

三件事。一,ESM 的命名导出 x 和 default 都在,键名直白。二,Node 往这个对象上注入了一个 __esModule: true——你自己没写,是加载器加的。为什么注入?大量 CJS 生态代码(及其依赖的转译产物、打包器 helper)用 __esModule 判断"对面是不是转译好的模块命名空间",据此决定取 default 还是取整体;Node 把 ESM 交给 CJS 消费者时顺手补上这个标记,让这批老代码按预期工作。三,解构可用,q23 里 const { x } = require("../esm-lib.mjs") 拿到 x = 1,没有静态识别那套限制。

但顶层 await 是硬边界。 如果被 require 的 ESM 里有顶层 await(esm-tla.mjs 里 export const y = await Promise.resolve(2)),q21 直接失败:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await.
Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
  code: 'ERR_REQUIRE_ASYNC_MODULE'

原因很直白:require 是同步的,而带 TLA 的模块图要等异步求值完成才能给出导出。报错同样自带建议——Use import() instead,还顺手给了排查手段 --experimental-print-required-tla(告诉你这条顶层 await 是从哪儿继承进来的)。换 q22 的 import("../esm-tla.mjs"),正常拿到 y = 2、default = "tla-default"。

五、__esModule 那组,与 bundler 的 __toESM 不一样

同一份 CJS 模块,Node 和打包器的处理不是一回事。Node 这边 import { a } 是 SyntaxError(第二节表的头两行);而 E7 里 esbuild 对同样的"CJS 对象字面量"打包成功了,产物还带约 1.4 KB 的 __commonJS / __toESM 互操作前导。

差别在各自做了什么:打包器自己写了一套互操作 helper——__commonJS 把 CJS 模块包成函数、需要时求值,__toESM 在 ESM 消费端构造一个带 __esModule 标记、把整体挂到 default 上的命名空间对象。所以打包产物里 import { a } 能成立,是 helper 在运行时把 a 从整体里取出来放进了命名空间;Node 不做这层补全。"打包后能跑、直接跑不能跑" 的一大来源就在这里。

本章脉络

并排放就清楚了:CJS 侧只有"一个 module.exports 对象",ESM 侧却要同时提供"整份 default"和"一组命名导出"。default 是唯一不需要翻译的通道——它恒等于整个 module.exports;命名导出是 Node 在加载时静态扫源码猜出来的,只有赋值形态才算数,猜不到就报 Named export 'x' not found(并顺手教你用 default 救)。反方向 require(esm) 相对简单:命名导出直接可见,Node 再注入一个 __esModule: true 哄老代码,唯一过不去的是顶层 await。而 __esModule 的"另一套语义"属于打包器的 __toESM,不是 Node 的行为。

生产边界

动手:可观察结果

写一个探针,把一个 CJS 模块"从 ESM 看是什么样"和"从 CJS 看是什么样"同时打印出来:

// tools/interop-probe.mjs
const esm = await import("../libs/plain.cjs");
console.log("ESM 视角 键 =", Object.keys(esm));        // 期望 ["default"]
console.log("ESM 视角 default 类型 =", typeof esm.default);
console.log("ESM 视角 ns.default === ns ?", esm.default === esm); // 期望 false

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const cjs = require("../libs/plain.cjs");
console.log("CJS 视角 键 =", Object.keys(cjs));        // 整个对象

对 ESM 源文件反过来问一次 require:把结果 Object.keys 打出来,看是不是 ["__esModule","default",...],并确认那个 __esModule 你没写过。再准备一个带顶层 await 的 ESM,分别用 require 和 import() 各取一次,把两种结果并排看。

产出 判断标准
一次命名空间键打印 能说出为什么从 ESM 看 CJS 只有 ["default"],且 ns.default !== ns
九种写法的对照 能预测每种写法下 import { x } 成不成立,再跑探针核对
一次 require(esm) 能指出 __esModule 是 Node 注入的,并解释它给谁用
一次 TLA 对照 能并排给出 require 的 ERR_REQUIRE_ASYNC_MODULE 与 import() 的成功

完成标志:拿到一个 CJS 包,你能先说出"default 是什么、import { x } 能不能用、为什么",再跑探针核对——而不是看到 Named export 'x' not found 就把它当成包的 bug。

故障注入

一个 CJS 包在你依赖它期间换了一次导出写法,import { x } 从能用变成不能用。按这个清单一条条排除:

注入(改动对方源码) 预期现象 判据
把 exports.x = 1 改成 module.exports = { x: 1 } import { x } 从能用到 SyntaxError 赋值形态 → 整体替换
加一句 Object.assign(module.exports, {...}) 新的键取不到 E5 r04
把 exports.x = 1 改成 Object.defineProperty(exports,"x",{get}) 该键取不到 E5 r06
插入一层 module.exports = require("./impl.cjs") 原本能取的命名导出全丢 E5 q08
在被 require 的 ESM 里加顶层 await require 侧 ERR_REQUIRE_ASYNC_MODULE E5 q21
两边都改成走 exports 的 import/require 条件 各走各的产物,import { x } 恢复 第 03 章

自测题

  1. 为什么 import def from "cjs-pack" 里的 def 恒等于整个 module.exports,而 import { a } 里的 a 可能取不到?用"整体通道 / 静态识别"两个词回答。
  2. 同一个文件里写 exports.named = 7 和 exports.default = 42,为什么 import { named } 能拿到 7,而 default 却出现在 import def from 的整份对象里?
  3. import * as ns from "cjs" 的 ns 为什么键只有 ["default"],且 ns.default !== ns?
  4. mutate.cjs 里 5ms 后改了 exports.x,为什么 ESM 侧 import { x } 30ms 后仍是旧值?这和"ESM 有活绑定"冲突吗?
  5. require(esm) 返回的对象里为什么多出一个你没写过的 __esModule?它是给谁用的?
  6. 为什么 require 一个含顶层 await 的 ESM 会失败,而 import() 不会?报错里给的排查参数是什么?
  7. 同样一份"CJS 对象字面量",为什么 Node 侧 import { a } 报 SyntaxError、而 esbuild 打包后能跑?

现在能解释什么

下一步:05 章 · 双格式发布的账单 —— 现在你知道两套规范在接口处的形状了,接着看一个包同时发两份时,进程里到底装载了什么:单例为什么变两份、instanceof 为什么失效。

进入 keel 阅读