KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 双格式发布的账单 — keel 龙骨
这一章回答:一个库同时发布 ESM 和 CJS 两份,为什么消费者会同时拿到两份?「同一个类」的 instanceof 为什么会返回 false?
这一章回答:一个库同时发布 ESM 和 CJS 两份,为什么消费者会同时拿到两份?「同一个类」的 instanceof 为什么会返回 false?
现场:同一个 getStore(),两次调用拿到两个 id
一个内部状态包 duallib,当初为了「两边都能用」发了两份:
// node_modules/duallib/package.json
{
"name": "duallib",
"version": "1.0.0",
"exports": { ".": { "import": "./index.mjs", "require": "./index.cjs" } }
}
// node_modules/duallib/index.mjs
let singleton = null;
let counter = 0;
export class Store {
constructor() { this.id = Math.random().toString(36).slice(2, 8); this.fmt = "esm"; }
}
export function getStore() {
if (!singleton) singleton = new Store();
return singleton;
}
export function bump() { counter += 1; return counter; }
// node_modules/duallib/index.cjs —— 同样逻辑,另一种写法
let singleton = null;
let counter = 0;
class Store {
constructor() { this.id = Math.random().toString(36).slice(2, 8); this.fmt = "cjs"; }
}
function getStore() { if (!singleton) singleton = new Store(); return singleton; }
function bump() { counter += 1; return counter; }
module.exports = { Store, getStore, bump };
你的启动脚本是 CJS(早就 require 过一次),业务代码是 ESM。于是你在同一个进程里把两种方式各取一次:
// probes/s01-dual-identity.cjs
const cjs = require("duallib");
import("duallib").then((esm) => {
console.log(" cjs.Store === esm.Store ?", cjs.Store === esm.Store);
console.log(" cjs.getStore() === esm.getStore() ?", cjs.getStore() === esm.getStore());
console.log(" cjs 单例 id =", cjs.getStore().id, "| esm 单例 id =", esm.getStore().id);
console.log(" cjs 侧 fmt =", cjs.getStore().fmt, "| esm 侧 fmt =", esm.getStore().fmt);
});
实测输出(Node 22.22.2):
cjs.Store === esm.Store ? false
cjs.getStore() === esm.getStore() ? false
cjs 单例 id = er2l00 | esm 单例 id = 793evj
cjs 侧 fmt = cjs | esm 侧 fmt = esm
getStore() 的全部意义就是「永远返回同一个」,它在一侧确实做到了。但你在同一个进程里叫了两次,拿到两个 id。
停在这里——你会怎么判断?
三个最省事的解释都不成立:Math.random() 没坏(id 只在构造时算一次);getStore 逻辑没改(两侧就是同一段逻辑);缓存也没「失效」,恰恰相反,它工作得太好了。要解释最后那行 cjs 侧 fmt = cjs | esm 侧 fmt = esm,你只能接受一个更难接受的可能:这是两份被分别加载、分别求值的模块。
一、把三份现场摆齐:两个函数对象、两个单例、两份计数器
再补两个探针。
// probes/s02-instanceof.cjs
const cjs = require("duallib");
import("duallib").then((esm) => {
const s = new esm.Store();
console.log(" new esm.Store() instanceof cjs.Store ?", s instanceof cjs.Store);
console.log(" new esm.Store() instanceof esm.Store ?", s instanceof esm.Store);
console.log(" s.constructor.name =", s.constructor.name, "(两边同名,但不是同一个函数对象)");
});
new esm.Store() instanceof cjs.Store ? false
new esm.Store() instanceof esm.Store ? true
s.constructor.name = Store (两边同名,但不是同一个函数对象)
// probes/s03-state-double.cjs
const cjs = require("duallib");
import("duallib").then((esm) => {
console.log(" cjs.bump() =", cjs.bump());
console.log(" cjs.bump() =", cjs.bump());
console.log(" esm.bump() =", esm.bump(), " <- 从 1 重新数:计数器有两份");
console.log(" esm.bump() =", esm.bump());
});
cjs.bump() = 1
cjs.bump() = 2
esm.bump() = 1 <- 从 1 重新数:计数器有两份
esm.bump() = 2
五条对照到这里就齐了:两个函数对象、两个单例、两个不同的 id(er2l00 与 793evj)、instanceof 为 false、计数器从 1 重新数。(id 由 Math.random().toString(36).slice(2, 8) 生成,每次跑都不一样——要记的形状是「两个 id 不相等」。)
instanceof 那条只补一句解释:它沿原型链往上走,看有没有节点等于右边那个函数的 .prototype。new esm.Store() 的链上是 esm.Store.prototype,而 cjs.Store 是另外一个对象,自然不在链上。false 不是 instanceof 出错,是两个 Store 从来不是同一个东西。
而 s.constructor.name 仍然是 Store——这是最阴的一点:所有靠名字判断的代码都看不出来,日志、typeof、toString() 全部正常。
三个探针还全部 exit=0:双格式事故是静默的——try/catch 拦不住,lint 和类型检查也看不见(两份的类型声明通常还是同一份 .d.ts)。
二、成因而非症状:模块图上是两个键
机制三步。
① require("duallib") 命中 require 条件,加载 index.cjs。
② import "duallib" 命中 import 条件,加载 index.mjs。
③ 这是两个不同的文件,在模块图里就是两个不同的键,各自求值一次。singleton 和 counter 是模块顶层的 let,每份实例各持一份。
①② 是 E4 直接量到的:同一个 fakepkg,require("fakepkg") 加载 dist/index.cjs,import "fakepkg" 加载 dist/index.mjs。E4 还量到:在 CJS 文件里写 import("fakepkg") 照样走 import 条件——这正是本章探针能成立的原因,一个 .cjs 探针靠 import() 就能把两份都拿齐。
判据在 ③:
exports 里的 import 与 require 不是「同一个包的两种写法」,而是两个独立的条件分支。解析器只按你发出的那个动词选一支,选完没有回头路——被选中的那一支是一个完整的模块实例,有自己的顶层求值、自己的缓存键。
所以「同一份路径」根本不存在。你脑中的模型是「一个模块被加载了两次,状态被复制了一份」;实际发生的是「从来没有过一个原件」,也就没有「共用」可以在运行时被恢复。
更反直觉的是:这不是缓存失效,是缓存没有失效。 缓存是好的、必要的(同一份模块被要十次,顶层只跑一次),它只是按两个键分别缓存,两个键都很稳定地各持一份。(E4 还量到:条件靠键序决定,不靠精确度——{ "default", "import" } 这个顺序下 import "orderpkg" 会加载 default.mjs。)
三、为什么包作者非得发两份:一个合规困境
只发 CJS 会怎样。 ESM 消费者写 import { Store } from "duallib",能不能成取决于 CJS 那份长什么样。E5 量到的规则是:ESM lexer 只认赋值形态(exports.x = 、module.exports.x = );而「把整个 module.exports 换成一个对象字面量」这种最常见的写法,命名导出直接不认:
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;
本章的 index.cjs 用的正是 module.exports = { Store, getStore, bump }。只发 CJS,ESM 消费者就只能 import pkg from "duallib" 再手动解构;想让 import { Store } 成立,包作者得把内部结构拆成散装赋值去迎合一个静态分析器。代价不止难看:E5 量到,从 CJS 进来的命名绑定是快照(exports.x 先置 1、5 毫秒后置 2,ESM 侧 30 毫秒后读到的仍是 1),import * as ns 更是只有 ["default"] 一个键,ns.a === undefined。
只发 ESM 会怎样。 CJS 消费者写 require("duallib")。Node 22.12 起 require(esm) 默认可用(本实验室 22.22.2 实测通过:拿到键 ["__esModule","default","x"],m.x = 1),但有两堵墙:一堵是旧 Node(22.12 之前要 flag,更早的版本直接不行),另一堵是硬的——E5 量到,require 一个含顶层 await 的 ESM 图直接失败:
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.
顶层 await 恰恰是 ESM 比 CJS 多出来的能力。只发 ESM,等于说:我在库里用它一下,CJS 消费者就永远进不来。
两个方向各有一堵墙,包作者选了「发两份」这个绕法。 所以 dual publish 的准确定性是:
它不是谁设计出来的好东西,它是「两个方向各有一堵墙」时最不坏的折中。而它的账单被转移了——从包作者手里转到消费者手里:包作者省下的是「选边」的功课,你付的是「同一份逻辑在进程里有两份实例」。
四、怎么证实自己踩了:一个探针 + 一个读 package.json 的判据
别等线上出事再查。探针:同一进程里两种方式各取一次,比 === 与单例 id。
// probe.cjs —— 把 <pkg> 换成你怀疑的包名
const cjs = require("<pkg>");
import("<pkg>").then((esm) => {
console.log("Store 是同一个函数对象?", cjs.Store === esm.Store);
console.log("单例是同一个对象?", cjs.getStore() === esm.getStore());
console.log("cjs id", cjs.getStore().id, "| esm id", esm.getStore().id);
});
两条都跑出 false 加两个不同 id,就确诊了。这个探针成立的前提是包里同时有 import 和 require 两支且两支导出同名成员;单格式的包,两种加载方式会落到同一个文件,你会看到 true。
判据:不动运行时,只读 node_modules/<pkg>/package.json。
"exports": {
".": {
"import": "./index.mjs", ← 一个文件
"require": "./index.cjs" ← 另一个文件
}
}
两条都成立才确诊:一是同一子路径下同时存在 import 与 require 两个条件,且指向两个不同的文件;二是包内有模块级可变状态——顶层 let、const cache = new Map()、单例函数、计数器或注册表。
第二条是「症状会不会发作」的开关:纯函数库(一堆 format() 之类)双格式发布也不疼,没有状态可以分成两份。疼痛只发生在「有状态」与「有两个文件」的交叉处。 症状按 E6 实测的三类:单例变成两套(表现为「配置改了不生效」、缓存命中率莫名下降)、计数器从 1 重新数、instanceof 返回 false;顺着「顶层 let 各持一份」再推一步,模块级注册表也会变成两份(这一类是推论,本实验室没有单独量到)。
五、怎么避免:两条路,各自的代价
出路 A:单实现 + 外观——真实现只写一份,另一份是一层薄壳,全部导出从同一个对象转发。选哪边当真实现,由你的最低支持 Node 版本决定。真实现放 CJS、外壳是 ESM:
// index.mjs —— 壳。一行真实现,薄转发
import cjs from "./index.cjs";
export const Store = cjs.Store;
export const getStore = cjs.getStore;
export const bump = cjs.bump;
import cjs from "./index.cjs" 这句是安全的:E5 量到,import def from 一个用对象字面量赋值的 CJS 模块时,def 就是整个 module.exports;再逐条重新导出成命名导出,ESM 消费者照常 import { Store }。代价三条:加一个导出要改两个文件(单实现省掉的是状态,不省掉文件);两份类型声明仍要维护;那层 import 是真实开销(多一次模块求值,但只求值一次,底层是同一份真实现——换来的正是「只有一份状态」)。
反过来的方向(真实现放 ESM、外壳是 CJS)在新 Node 上也走得通,但有一个 E5 实测的坑:外壳别写成 module.exports = require("./index.mjs")。「转发一层就丢」——lexer 认不出被转发进来的命名导出,ESM 消费者对这个壳的 import { Store } 会重新变成 Named export 'Store' not found。安全写法是逐条赋值(lexer 认 exports.x = 这个形态):
// index.cjs —— 壳
const impl = require("./index.mjs"); // Node 22.12+ 默认可用
exports.Store = impl.Store;
exports.getStore = impl.getStore;
exports.bump = impl.bump;
出路 B:把状态挪出模块级变量。 真实现仍可发两份,但状态只放一处:globalThis 上的注册表(globalThis.__duallib_registry ??= new Map(),两份实例共用同一个 Map),或由调用方注入(createStore({ store }))。代价同样要讲清:globalThis 是全局命名空间,同一个应用里跑两个大版本的这个库时会撞同一个键——你从「同一版本被加载两次」换成了「不同版本互相踩」;注入要求消费者改 API,而包被间接依赖时(你不在那段调用链上)根本没有注入的机会;而且状态外置只管状态、不管身份,cjs.Store === esm.Store 仍然是 false,instanceof 该 false 还是 false。
一条判据收尾:你要么接受两份模块实例,要么接受一个全局单例。没有任何选项能让「两份实例」自动变成「一份」。
六、__esModule 与 __toESM 在这件事里的位置
讲到这里容易生出一个直觉:既然跨规范有互操作标志,它能不能顺手把双份实例也修了?不能——它们工作在另一层:在「模块已经被选定并求值之后」,在形状层面打补丁。而第二节 ② 那一步的决定(走哪个条件、加载哪个文件)发生在解析阶段,在任何一个互操作标志被看到之前就做完了(E4:require → index.cjs,import → index.mjs)。
__esModule 是「这个对象是一个被转译过的 ES 模块的外观」这块牌子。E5 量到一个有意思的点:CJS 侧 require 一个 .mjs 时拿到的键是 ["__esModule","default","x"],而 m.__esModule = true 是 Node 注入的,不是包作者写的——运行环境会替一份真 ESM 挂上这块牌子。包作者也可以自己挂(E5:手写 Object.defineProperty(exports,"__esModule",{value:true}) 加 exports.default / exports.named,ESM 侧 import { named } 就能拿到 7)。它决定的是**default 指向谁**,E7 产物里那个 __toESM 前导把逻辑写得很直白(下面是从 e4.js 产物抄的原文):
var __toESM = (mod, isNodeMode, target) => (
target = mod != null ? __create(__getProtoOf(mod)) : {},
__copyProps(
isNodeMode || !mod || !mod.__esModule
? __defProp(target, "default", { value: mod, enumerable: true })
: target,
mod
));
那句条件读起来就是:对方没挂 __esModule 这块牌子,我就把整个 module.exports 挂到 default 上。 所以两个角色的分工是一句话:
__esModule 是「我长得像 ES 模块」的牌子;__toESM 是「你没挂牌,我按老规矩解释你」的补丁。它们保证你 import 进来的那份形状对,不保证你只拿到一份。
形状问题用 __esModule 治;身份问题只能靠「只有一个文件」治——也就是上一节那两条路。
本章脉络
flowchart TD
A["同一个进程 · 同一个包名 duallib"] --> B["① require('duallib')<br/>命中 require 条件"]
A --> C["② import('duallib')<br/>命中 import 条件"]
B --> D["加载 index.cjs<br/>模块图键 #1"]
C --> E["加载 index.mjs<br/>模块图键 #2"]
D --> F["③ 两次顶层求值<br/>singleton / counter 各持一份"]
E --> F
F --> G["两个单例 id:er2l00 / 793evj"]
F --> H["new esm.Store() instanceof cjs.Store 为 false"]
F --> I["计数器从 1 重新数"]
H --> J{"怎么证实?"}
J -- 运行时探针 --> K["同进程 require + await import<br/>比 === 与单例 id"]
J -- 只看 package.json --> L["两个条件指向两个不同文件<br/>且包内有模块级状态"]
H -.-> M["只发 CJS<br/>ESM 侧命名导出拿不到"]
H -.-> N["只发 ESM<br/>旧 Node 与顶层 await 图 require 不了"]
M -.-> O["dual publish:最不坏的折中"]
N -.-> O
K -.-> P["出路 A 单实现 + 外观<br/>代价:两个文件、双份类型"]
L -.-> Q["出路 B 状态外置<br/>代价:全局键冲突、需注入,且救不了 instanceof"]
style H fill:#ffebee,color:#b71c1c
style O fill:#fff3e0,color:#e65100
style P fill:#e8f5e9,color:#1b5e20
style Q fill:#e8f5e9,color:#1b5e20
图里在说什么。 从上往下:同一个进程、同一个包名,只因为动词不同就分成两条路(① 与 ②,也就是第二节的前两步),各自落到不同的模块图键上,然后在 ③ 处汇合——汇合的结果不是「一份」,而是「各有一份顶层求值结果」。三个红框是它的三个可观察出口。中间 怎么证实? 是个分岔:运行时探针告诉你「风险有没有发作」,纯静态读 package.json 告诉你「有没有风险」。右下那条虚线链是这本账单的来路:只发 CJS、只发 ESM 各撞一堵墙(虚线=没被选中的失败路径),两堵墙夹出 dual publish;再从两个诊断出口分出两条出路,代价写在节点里——别只读好处。
生产边界
- 单例 id 是随机的(两侧都是
Math.random().toString(36).slice(2, 8)),要记的形状是「两个 id 不相等」。 - 双格式事故不报错:E6 三个探针全部
exit=0,CI、lint、类型检查都不会替你发现它,只有你主动跑一次探针。 require(esm)默认可用(22.12 起)改变了天平:「只发 ESM」的代价比几年前小得多,但「含顶层await的 ESM 图不能被require」这条边界是硬的。- 条件选择的依据是键序,不是精确度,所以「加个
default兜底」会改变谁被加载——改之前先读一遍键序。 - 本实验室没单独测的两件事,所以第四节那条判据只覆盖「两个文件」的情形:一是两个条件指向同一个文件时两侧是否共用一份实例;二是同一个 dual package 进了打包器会不会又变回两份(E7 只证明了打包器按
--platform选一支)。 - 单机、小文件、无网络:结论的形状跨版本成立,字段名与行为请在自己的 Node 版本上复核。
动手:可观察结果
自己造一个最小 dual package,把这五条对照跑出来。
| 产出 | 判断标准 |
|---|---|
一个最小 duallib(两个条件指向两个文件,两侧各带模块级单例与计数器) |
在 package.json 里能逐字指出是哪两个条件、哪两个文件 |
一份同进程双侧探针(require 加 import()) |
五条对照全部拿到:两个函数对象、两个单例、两个 id、instanceof 为 false、计数器从 1 重新数 |
| 把一侧换成「单实现 + 外观」后再跑同一个探针 | 单例变回同一个,而两个 Store 是不是同一个取决于壳怎么写——能说清哪条变了、哪条没变 |
一份只读 package.json 的预判 |
不开运行时,只看 exports 的键与文件,就能说出「这个包会不会疼」 |
完成标志:随便给你一个 node_modules 里的包,你能只看它的 package.json 说出「它是不是 dual package、里面有没有模块级状态、我会不会踩」,再跑一个探针把结论验掉——而不是等线上出现「配置改了不生效」再回头猜。
故障注入
| 注入方式 | 观察 |
|---|---|
删掉 exports 里的 require 条件,只留 import |
CJS 消费者的症状(22.22.2 上可能不报错);再往 .mjs 里加一句顶层 await 重试 |
反过来只留 require |
ESM 消费者的命名导入撞上哪条报错;对照 E5 那条 Named export ... not found 原文 |
| 两个条件指向同一个文件 | 两边的 id 还一样吗——这是生产边界里「未测」那一项的现场验证 |
把 let singleton 换成 globalThis 上的注册表 |
id 是否一致;instanceof 有没有跟着变好(提示:不会) |
调换 exports 里两个条件的键序 |
加载的是哪个文件——验证「键序决定」而非「精确度决定」 |
真实现挪到 ESM,CJS 侧写成 module.exports = require("./index.mjs") |
ESM 消费者对这个壳的命名导入还成不成立(E5:转发一层就丢) |
自测题
getStore()在一侧确实每次都返回同一个对象。那为什么同一个进程里两种加载方式会拿到两个 id?用「模块图上有几个键」回答。- 「同一个模块被加载了两次,所以状态被复制了一份」这句话错在哪?正确说法是什么?
new esm.Store() instanceof cjs.Store是false,但s.constructor.name是Store。这两个事实为什么不矛盾?它对排错方式有什么影响?- 只发 CJS 的包,为什么 ESM 消费者的
import { Store }会失败?包作者要怎么写才能让它成功,写了之后又付出什么代价? - 只发 ESM 的包,CJS 消费者会在哪两种情况下进不来?哪一种在 Node 22.12 之后仍然进不来?
__esModule和__toESM各工作在哪一层?为什么它们修不了双份实例?- 「单实现 + 外观」这条路里,外壳为什么不能写成
module.exports = require("./index.mjs")?该写成什么样?
现在能解释什么
- 为什么同一个
getStore()会返回两个不同 id——不是缓存失效,是两个缓存键各自稳定地缓存了一份; - 为什么
instanceof为false而constructor.name仍是Store,以及这对所有「靠名字判断」的代码意味着什么; - 为什么这类事故一声不响,以及十行探针怎么主动把它找出来;
- 为什么包作者要发两份——只发 CJS 会让 ESM 消费者的命名导出落空,只发 ESM 会把旧 Node 和含顶层
await的 CJS 消费者挡在门外; - 为什么
__esModule/__toESM修不了双实例——它们工作在形状层,而「加载哪个文件」在解析层就定了; - 为什么「单实现 + 外观」必须选边(选边由最低支持 Node 版本决定),以及为什么「状态外置」救得了数据、救不了身份。
下一步:06 章 · 打包器能看见什么 —— 现在你知道同一行 import 在 Node 里会因为条件落到两个文件上。接下来换个工具问同一个问题:打包器面对你这份源码时,能看见什么、又看不见什么。