require() 一个 .mjs 之后:我把 ESM 和 CJS 的互操作账算了一遍

上周我干了两件事,都很疼。

第一件:一个跑了五六年的 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 的微任务队列。

秒表旁边堆着上千个小木块垒成的墙,带运动模糊

# 九、所以现在我的写法

把上面这些坑收成几条能直接用的规则:

  1. 发库时,exports 里给 import/require 两支可以,但别让状态跨两份存在。 一旦模块里有计数器、连接池、注册表这类模块级状态,双包就是 Bug 制造机。宁可只发 ESM(CJS 消费者走 Node 的 require(esm),反正 Node 22.12+ 支持),也别发两份带状态的。
  2. 写 CJS 又希望被 ESM 具名导入时,用 exports.foo = ...,别用 module.exports = { foo }。 前者能被静态扫描认出来,后者认不出。
  3. 不要在 ESM 里 import { someMutableField } from './some.cjs'。 那是快照,会静默过期。走 import pkg from './some.cjs' 再读属性。
  4. 循环依赖别依赖"能跑"。 CJS 的能跑是运气(取决于赋值顺序),ESM 的能跑只对函数声明成立。真要互相引用,把引用推迟到函数体里。
  5. 别指望删 ESM 的模块缓存做热重载。 要么加 query 版本号,要么起子进程。
  6. 接一个不熟的包之前,先看它的 exports 写了什么。 node -e "console.log(JSON.stringify(require('pkg/package.json').exports, null, 2))" 或者 import.meta.resolve('pkg'),一分钟能省两小时。
  7. 不要在 .js 里靠 Node 猜语法。 要么写 type 字段,要么老实改 .mjs / .cjs。

# 最后

这套东西之所以别扭,是因为 ESM 和 CJS 不是「同一个功能的两个版本」,而是两套世界观:一个是静态的、先链接后求值、绑定是活的;一个是运行时的、边跑边算、导出就是个普通对象。Node 花了七八年把它们缝在一起,缝得已经相当不错了——好到你平时感觉不到它。

但缝线还在那儿。你总会在某一天,因为一个 instanceof false 或者一个静默过期的值,顺着线头摸到它。

那时候希望这篇文章能帮你省下两个小时。