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 的行为。
生产边界
- 这两套语义的接缝不是 bug,修不完。 CJS 是"一个可变对象 + 拍下的值",ESM 是"一组静态绑定的接线",两者对"一个模块长什么样"的定义就不同。指望 Node 把
import { a } from "cjs"变得总能成功,等于指望它把静态分析做成运行时求值——那会破坏 ESM 可静态分析的前提。 exports条件正是官方给出的唯一可控接缝。 包作者用import/require条件把两条入口分给两个文件(第 03 章),互操作问题就被推回"各自走各自那份产物",而不是在运行时猜。这也是双格式包必须配exports的原因(账单在第 05 章)。default是永远安全的通道。 迁移期拿不准对面 CJS 的导出形态,就import pkg from "cjs"; const { a } = pkg;——报错文案自己都这么建议。import { x }能不能用,取决于对方源码的写法,不取决于对方文档。 一个包把exports.x = 1改成module.exports = {...},对你就是破坏性变更,而它可能觉得自己只是"重构了一下"。require(esm)在 Node 22.12+ 默认可用,但顶层 await 仍是硬边界;更早的 Node 上它要么要 flag、要么不支持。结论的形状跨版本成立,具体报错文案与版本线请在自己的 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 章 |
自测题
- 为什么
import def from "cjs-pack"里的def恒等于整个module.exports,而import { a }里的a可能取不到?用"整体通道 / 静态识别"两个词回答。 - 同一个文件里写
exports.named = 7和exports.default = 42,为什么import { named }能拿到 7,而default却出现在import def from的整份对象里? import * as ns from "cjs"的ns为什么键只有["default"],且ns.default !== ns?mutate.cjs里 5ms 后改了exports.x,为什么 ESM 侧import { x }30ms 后仍是旧值?这和"ESM 有活绑定"冲突吗?require(esm)返回的对象里为什么多出一个你没写过的__esModule?它是给谁用的?- 为什么
require一个含顶层 await 的 ESM 会失败,而import()不会?报错里给的排查参数是什么? - 同样一份"CJS 对象字面量",为什么 Node 侧
import { a }报SyntaxError、而 esbuild 打包后能跑?
现在能解释什么
- 为什么
import { a } from "cjs"报Named export 'a' not found,而import pkg from "cjs"永远没事——命名导出是静态猜的,default是整体通道; - 为什么"把
module.exports = {...}改成exports.x = 1"或反过来,是会让下游炸的行为,而不是等价重构; - 为什么从 CJS 进来的命名绑定是快照,而真正的 ESM 导出是接线——判据是"导出那头是不是 ESM",不是"我用了哪种 import";
- 为什么
import * as ns看到的 CJS 模块只有一个default键,且ns.default !== ns; - 为什么
require(esm)的对象上有个你没写的__esModule,以及顶层 await 为什么是它过不去的硬边界; - 为什么"打包后能跑、直接跑不能跑"——Node 静态识别,打包器的
__toESM做运行时补全。
下一步:05 章 · 双格式发布的账单 —— 现在你知道两套规范在接口处的形状了,接着看一个包同时发两份时,进程里到底装载了什么:单例为什么变两份、instanceof 为什么失效。