require() 一个 .mjs 之后:我把 ESM 和 CJS 的互操作账算了一遍
- 作者:Bougie
- 创建于:2026-10-09
- 更新于:2026-10-09
上周我干了两件事,都很疼。
第一件:一个跑了五六年的 CJS 老项目,要接一个只发 ESM 的依赖。我心想 Node 都能 require() ESM 了吧,直接写 const lib = require('lib'),跑起来 lib.someMethod is not a function。打印一看,返回的东西长得像个模块命名空间,里面还凭空多了一个我从来没导出过的 __esModule: true。
第二件:另一个包被我的 ESM 入口 import 了一次,又被某个插件用 require 加载了一次,两边各自的模块级缓存互不相通,拿 instanceof 一判断——false。查了两个小时才反应过来这是双包危险。

下面所有输出都是我在本机 Linux x86_64、Node v24.16.0 上跑出来的,脚本不依赖网络,你可以照着复现。
# 一、require() 一个 .mjs:Node 24 说可以,但返回的东西有讲究
先搭个最小的 ESM:
// esm-basic.mjs
export const a = 1
export default 42
export function f() {
return 'f'
}
然后在 CJS 里 require 它:
const m = require('./esm-basic.mjs')
console.log(m)
console.log(Object.keys(m))
[Module: null prototype] { __esModule: true, a: 1, default: 42, f: [Function: f] }
[ '__esModule', 'a', 'default', 'f' ]
三件事值得记下来。
第一,返回的不是普通对象,是模块命名空间对象(module namespace exotic object),原型是 null,所以 Object.getPrototypeOf(m) === null,m.hasOwnProperty 这种调用直接不存在。
第二,__esModule: true 是 Node 塞进去的,我源码里压根没写过。这不是 ESM 的规范内容,是 Node 为了让「require 一个 ESM」的结果能兼容那些被 Babel/TS 编译过的 CJS 消费者而加的标记——因为转译过的代码普遍靠 mod.__esModule 来判断要不要取 .default。
第三,这个对象是只读的,而且只读得有点反直觉:
Object.isFrozen(m) // false
Object.isSealed(m) // true
Object.isExtensible(m) // false
Object.getOwnPropertyDescriptor(m, 'a')
// { value: 1, writable: true, enumerable: true, configurable: false }
注意描述符里 writable 明明是 true,但真赋值:
m.a = 99 // 非严格模式:静默失败
console.log(m.a) // 1
;(function () {
'use strict'
m.a = 99
})() // TypeError: Cannot assign to read only property 'a' of object '[object Module]'
命名空间对象的 [[Set]] 内部方法无条件返回 false,跟 writable 标志无关。所以别指望靠读描述符判断能不能改。
# 顶层 await 是硬边界
// esm-tla.mjs
await new Promise((r) => setTimeout(r, 5))
export const x = 1
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead.
code: 'ERR_REQUIRE_ASYNC_MODULE'
关键在于 graph:不是你 require 的那个文件自己有 await 才报错,它的依赖链里任何一个有顶层 await 都不行。我试了「自己没 await、但 import 了一个带 await 的模块」的版本,一样报错。定位元凶要加个 flag:
node --experimental-print-required-tla -e "require('./esm-tla2.mjs')"
# Error: unexpected top-level await at file:///tmp/mod/esm-tla2.mjs:2
想回到旧世界(require ESM 直接失败)也行:
node --no-experimental-require-module -e "require('./esm-basic.mjs')"
# Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.
# Instead change the require of ... to a dynamic import() which is available in all CommonJS modules.
换句话说,Node 24 的默认值是「能 require 就 require」,但这条路的尽头永远站着顶层 await。
# 顺便:Node 现在会猜你的 .js 是什么
我以前记的规则是「.js 看最近的 package.json 的 type 字段」。现在多了一条:没有 type 字段时,Node 会看语法猜。
// detect/plain-esm.js —— 目录下没有 package.json
import { readFileSync } from 'node:fs'
export const x = 1
$ node detect/plain-esm.js
带 ESM 语法的 .js 直接跑起来了, x = 1
但一旦你显式写了 "type": "commonjs",它就不猜了,直接 SyntaxError: Unexpected token 'export';反过来 "type": "module" 目录下写 require(...),会得到那句熟悉的长报错:
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '.../package.json' contains "type": "module".
To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
改名成 .cjs 立刻正常。所以「猜」只是兜底,显式声明永远优先。

# 二、反方向:ESM import 一个 CJS,具名导出是"猜"出来的
这是我最想讲清楚的一节。CJS 的导出是运行时算出来的,ESM 的具名导入是静态的,两边对不上。Node 的办法是:用 cjs-module-lexer (opens new window) 静态扫描 CJS 源码文本,把看起来像导出名的东西挑出来,假装它们是具名导出。
既然是扫文本,那就看写法。我写了 13 个文件,每个用不同写法导出,然后 import * as m 看 keys:
| 写法 | 识别出的具名导出 |
|---|---|
exports.alpha = 1 | alpha ✅ |
exports['beta'] = 2 | beta ✅ |
module.exports = { gamma: 3 } | ❌ 只有 default |
const e = module.exports; e.delta = 4 | ❌ |
if (...) exports.epsilon = 5; exports.zeta = 6 | epsilon zeta ✅(分支里的也算) |
Object.defineProperty(exports, 'eta', {...}) | ❌ |
module.exports = function theta() {} | ❌(default 是那个函数) |
exports.__esModule = true; exports.default = 8 | __esModule default ✅ |
module.exports = require('./l3.cjs') | ❌ |
exports.foo = 1; module.exports = { bar: 2 } | foo ✅、bar ❌ |
exports.foo = void 0; exports.foo = 1 | foo ✅ |
for (const k of ['a','b']) exports[k] = k | ❌ |
exports.iota = 9; module.exports.iota2 = 10 | iota iota2 ✅ |
13 种里 6 种能全中。几个最坑的:
module.exports = { a, b } 猜不中。 这大概是现实中最常见的写法,扫不出来。它的后果是——
SyntaxError: Named export 'state' not found. The requested module 'mainonly' 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 'mainonly'; const { User, state } = pkg;
报错信息很贴心,但如果你写的是 import { User, state } from 'pkg',编译期(其实是链接期)就炸了。
exports.foo = 1 后面又 module.exports = { bar: 2 },会认到 foo 却认不到 bar——也就是说它认的是「被覆盖掉的那个」,不是最后真正生效的那个。
命名空间里永远有个 module.exports 键。 Node 给每个被 import 的 CJS 模块都加了这个别名,指向 module.exports 本身,所以极端情况下你甚至可以 import { 'module.exports' as x } from './c.cjs'(虽然没人这么写)。
那 default 是什么? 是整个 module.exports:
import def, * as ns from './lex/l3.cjs' // l3.cjs: module.exports = { gamma: 3 }
console.log(def) // { gamma: 3 }
console.log(Object.keys(ns)) // [ 'default', 'module.exports' ]

# 三、猜错不要紧,双包危险要命
上一节说的是「能不能 import 到名字」,这一节说的是「你 import 到的可能和你同事 require 到的不是同一个东西」。
现代库流行在 package.json 里这么写:
{
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
}
同一个包,ESM 消费者拿 index.mjs,CJS 消费者拿 index.cjs。于是:
import { User, state } from 'dualpkg'
const cjs = createRequire(import.meta.url)('dualpkg')
console.log('同一个 User 类?', User === cjs.User) // false
console.log('同一个 state 对象?', state === cjs.state) // false
console.log('ESM 实例 instanceof CJS 类?', new User() instanceof cjs.User) // false
state.n = 1
console.log('CJS 侧的 n =', cjs.state.n) // 0
全军覆没。原因很朴素:那是两个文件,被执行了两次。给两个入口各加一行 console.log,一眼就能看到:
ESM 分支模块体执行
CJS 分支模块体执行
后果分两类。类是轻的:instanceof 失效、Object.assign 出来的对象对不上原型。重的是状态:模块级的计数器、连接池、注册表、缓存 Map,全都变成两份,而且互相看不见对方改了什么。我那两个小时就花在这上面。
那如果只写 main 不写 exports 呢? 两边会解析到同一个文件,实例就一致了——我实测 exports.X = ... 写法 + 只有 main 字段的包:
exports.X 写法:两边同一个类? true | 同一个 state? true
但这里有个陷阱:我第一次做这个实验时,那个包的 index.cjs 用的是 module.exports = { User, state } 写法,结果连 import 都失败(就是上一节那个 Named export 'state' not found)。同一个 CJS 文件能不能被 ESM 具名导入,跟它的导出写法有关,跟 main/exports 无关。 想让 CJS 被 ESM 好好导入,就写成 exports.User = ...,别写成 module.exports = { ... }。

# 四、exports 字段:地图、路障和路口
exports 还有几个容易踩的点,我一个个测了。
main 和 exports 同时写,exports 赢。 我造了个包,main 指向 main.js、exports 指向 exports.js,两边打印各自的来源:
exports
exports
require 和 import 都走 exports。
写了 exports 就等于把包的内部路径封死了。 以前 require('pkg/dist/deep.js') 这种深路径随便捅,现在不行:
ERR_PACKAGE_PATH_NOT_EXPORTED | Package subpath './deep.js' is not defined by "exports" in .../package.json
注意这条对 CJS 也生效——不是只有 ESM 才守规矩。没写 exports 的包,深路径照旧能捅进去(我实测 require('mainonly/index.cjs') 正常返回)。这是好事:包作者终于能控制公开边界了,代价是「以前能 import 的路径现在不能了」成了 breaking change 的重灾区。
条件分支按书写顺序匹配,不是按"谁更具体"。 这个包:
{
"exports": {
".": {
"node": { "import": "./node-esm.mjs", "require": "./node-cjs.cjs" },
"browser": "./browser.js",
"default": "./fallback.js"
}
}
}
$ node -e "import('condpkg')" → node-esm
$ node -e "require('condpkg')" → node-cjs
$ node --conditions=browser -e "..." → node-esm ← 没变成 browser!
第三条是我一开始没想到的:我加了 --conditions=browser,结果还是走 node-esm。因为 node 写在 browser 前面,先匹配到就停了。想让浏览器条件生效,就得把它写在 node 前面。顺序即优先级,没有智能合并。
imports 字段的 # 只能在包内用。 包内 #secret 解析正常,包外立刻炸:
ERR_PACKAGE_IMPORT_NOT_DEFINED | Package import specifier "#secret" is not defined

# 五、循环依赖的三种死法
同一份互相引用的代码,在两套模块系统里死法完全不同。
CJS:拿到半成品,不报错。
// ca.js
exports.n = 1
const b = require('./cb.js')
console.log('ca 看到的 b.n =', b.n)
exports.desc = 'ca'
// cb.js
exports.n = 2
const a = require('./ca.js')
console.log('cb 看到的 a.n =', a.n, '| a.desc =', a.desc)
cb 看到的 a.n = 1 | a.desc = undefined
ca 看到的 b.n = 2
a.n 拿到了(因为赋值在 require 之前),a.desc 是 undefined(因为赋值在 require 之后)。CJS 的循环依赖结果取决于你把 exports.x = 写在 require 前面还是后面,这个顺序只要被谁重排一次,bug 就来了,而且不报错。
ESM:const 会掉进 TDZ。
// ea.mjs
import { b } from './eb.mjs'
export const a = 1
console.log('ea 看到的 b =', b)
// eb.mjs
import { a } from './ea.mjs'
export const b = 2
console.log('eb 看到的 a =', a)
ReferenceError: Cannot access 'a' before initialization
ESM:换成函数声明就能活。
// fa.mjs
import { fb } from './fb.mjs'
export function fa() {
return 'fa'
}
console.log('fa 调用 fb():', fb())
fb 调用 fa(): fa
fa 调用 fb(): fb
因为函数声明在实例化阶段就被初始化了,而 const 要等到求值阶段。所以 ESM 不是"修好了循环依赖",是把静默的半成品变成了响亮的报错,同时给了函数声明一条活路。
顺带看一眼求值顺序的区别。CJS 是边跑边 require:
main 开始 → a 求值 → c 求值 → b 求值 → main 结束
ESM 是先把整张图链接完,再深度优先求值,所以入口文件的第一行代码最后才跑:
c 求值 → a 求值 → b 求值 → main 开始 → main 结束
由此还引出一个小知识点:import 声明是被提升的,写在文件末尾也能在第一行用到(console.log(typeof x) 打印 number,不是 undefined)。
# 六、活的绑定 vs 快照:这里最反直觉
我原以为「ESM 是活绑定」是一条普遍规则,测完发现要看对象是谁。
require 一个 ESM:是活的。
// live.mjs
export let counter = 0
export function inc() {
counter++
}
const m = require('./live.mjs')
console.log(m.counter) // 0
m.inc()
console.log(m.counter) // 1 ← 跟着变
ESM 具名导入一个 CJS 的字段:是快照。
import def, { counter } from './live.cjs'
import * as ns from './live.cjs'
def.inc()
console.log('具名导入 counter:', counter) // 0 ← 没变
console.log('ns.counter:', ns.counter) // 0 ← 没变
console.log('def.counter:', def.counter) // 1 ← 变了
def 是 module.exports 那个对象本身(引用没变,所以读得到新值),而 counter 和 ns.counter 都是 Node 在做静态扫描时取值拷出来的快照。想拿实时值就走 default 再解构属性。
CJS 里解构 require:也是快照。
const { counter, inc } = require('./live.cjs')
inc()
console.log(counter) // 0
console.log(require('./live.cjs').counter) // 1
这个老梗大家都熟,但和上面那条放一起看就很有意思:两边各有各的快照时刻,什么时候的值被固定下来,取决于你用哪套语法。
# 七、缓存:CJS 能删,ESM 不能
CJS 想重新执行一个模块,删缓存就行:
require('./cache-counter.cjs') // CJS 模块体执行了
require('./cache-counter.cjs') //(缓存命中,不打印)
delete require.cache[require.resolve('./cache-counter.cjs')]
require('./cache-counter.cjs') // CJS 模块体执行了 ← 又跑了一遍
ESM 没有等价物。import() 的缓存挂在 loader 内部,不暴露、不可写、不可删。想重跑只能改 URL:
await import('./cache-counter.mjs') // ESM 模块体执行了
await import('./cache-counter.mjs') //(缓存命中)
await import('./cache-counter.mjs?v=2') // ESM 模块体执行了 ← 靠 query 骗过缓存键
另外我确认了一下:被 import 加载的 ESM 不会出现在 require.cache 里(实测匹配到 0 条)。两套注册表各记各的。
那些「改文件自动热重载」的工具在 ESM 下必须自己维护版本号 query 或者起子进程,不是它们偷懒。
# 八、性能账:每个模块几十微秒,攒起来就是启动时间
我生成了 500 个和 1000 个小模块,每档跑 15 次取中位数,再扣掉空进程基线(空 CJS 进程 13.4ms,空 ESM 进程 12.3ms):
| 场景 | 500 个模块 | 折合每模块 |
|---|---|---|
CJS require() | 净 9.9 ms | 19.7 µs |
ESM 静态 import | 净 20.7 ms | 41.4 µs |
require() 一个 ESM | 净 28.1 ms | 56.3 µs |
加到 1000 个模块时,边际成本(500→1000 的增量):CJS 17.2 µs/模块,ESM 22.8 µs/模块。也就是说规模变大后差距会从 2.1 倍缩到 1.3 倍,固定开销占比更高。
这里有个测量陷阱值得单独说:我第一版在 main.mjs 里这么写——
const t = process.hrtime.bigint()
// ... 用 n0..n499
console.log(Number(process.hrtime.bigint() - t) / 1e6)
结果打印出 0.01 毫秒。因为静态 import 在你第一行代码执行之前就已经全部解析链接求值完了,你在模块体里打点,根本测不到加载耗时。ESM 的加载时间只能从进程外部测(我上面的数字是用父进程掐表算的)。
顺手也测了动态导入:循环 await import() 500 次,模块加载部分 23.22 ms,是同步 require 循环(10.01 ms)的 2.3 倍。每次 import() 都要走一遍异步 loader 的微任务队列。

# 九、所以现在我的写法
把上面这些坑收成几条能直接用的规则:
- 发库时,
exports里给import/require两支可以,但别让状态跨两份存在。 一旦模块里有计数器、连接池、注册表这类模块级状态,双包就是 Bug 制造机。宁可只发 ESM(CJS 消费者走 Node 的require(esm),反正 Node 22.12+ 支持),也别发两份带状态的。 - 写 CJS 又希望被 ESM 具名导入时,用
exports.foo = ...,别用module.exports = { foo }。 前者能被静态扫描认出来,后者认不出。 - 不要在 ESM 里
import { someMutableField } from './some.cjs'。 那是快照,会静默过期。走import pkg from './some.cjs'再读属性。 - 循环依赖别依赖"能跑"。 CJS 的能跑是运气(取决于赋值顺序),ESM 的能跑只对函数声明成立。真要互相引用,把引用推迟到函数体里。
- 别指望删 ESM 的模块缓存做热重载。 要么加 query 版本号,要么起子进程。
- 接一个不熟的包之前,先看它的
exports写了什么。node -e "console.log(JSON.stringify(require('pkg/package.json').exports, null, 2))"或者import.meta.resolve('pkg'),一分钟能省两小时。 - 不要在
.js里靠 Node 猜语法。 要么写type字段,要么老实改.mjs/.cjs。
# 最后
这套东西之所以别扭,是因为 ESM 和 CJS 不是「同一个功能的两个版本」,而是两套世界观:一个是静态的、先链接后求值、绑定是活的;一个是运行时的、边跑边算、导出就是个普通对象。Node 花了七八年把它们缝在一起,缝得已经相当不错了——好到你平时感觉不到它。
但缝线还在那儿。你总会在某一天,因为一个 instanceof false 或者一个静默过期的值,顺着线头摸到它。
那时候希望这篇文章能帮你省下两个小时。