CJS 与 ESM 互操作:default 陷阱

本节目标

当你在 ESM 里 import 一个 CJS 包,Node 会把 module.exports 整个当成「default 导出」。

// 运行环境:Node.js 16+(.mjs 引入 .cjs)
// legacy.cjs
module.exports = { add(a, b) { return a + b } }
// 也常见:module.exports = function(){}  (导出函数本身)

// consumer.mjs
import pkg from './legacy.cjs'   // pkg === module.exports(整个对象)
// 注意:CJS 没有「命名导出」,所以不能 import { add } from './legacy.cjs'
//       (除非该包同时提供 ESM 构建 / 有 exports 字段映射)
console.log(pkg.add(1, 2))       // 3

// 反向:CJS 引入 ESM 只能动态(ESM 是静态异步)
// async function f(){ const m = await import('./m.mjs'); m.add(1,2) }

default 陷阱:很多库文档写 import axios from 'axios',是因为 axios 是 CJS,default 就是 module.exports。如果你自己写库,想让 ESM 用户能 import { add },最好同时发 ESM 构建或用 exports 字段区分。

名词解释

default 陷阱:CJS 模块没有「命名导出」概念,它的整个 module.exports 在 ESM 里被当作 default。于是 ESM 侧只能用 import pkg from 'cjs'(拿整体),不能 import { x } from 'cjs'(除非包做了 ESM 适配)。新手常写 import { add } 去引 CJS 包而报错,根因在此。

互操作(Interop):让「不同模块系统(CJS/ESM)能互相 import」。Node 通过「把 CJS 的 module.exports 映射成 ESM 的 default」实现单向兼容;ESM 引入 CJS 用静态 import 即可,CJS 引入 ESM 必须用动态 import()(因为 ESM 是异步的)。理解这条边界,混用就不慌。

课后练习

练习 1:为什么 import { add } from 'some-cjs-pkg' 常报错?

答案:CJS 包只有 module.exports(被当作 ESM 的 default),没有命名导出。import { add } 想取「命名导出 add」,但 CJS 没提供,于是 undefined/报错。正确写法是 import pkg from 'some-cjs-pkg'; pkg.add(...),或换用该包的 ESM 版本。

练习 2:CJS 怎么引入一个 ESM 模块?

答案:CJS 里 require() 是同步的,而 ESM 加载是异步的,不能直接 require('esm')。必须用动态 import():const m = await import('./m.mjs'),且该函数需处于 async 上下文。这是「同步系统引不动异步系统」的必然限制。

总结

互操作是「历史包袱」带来的现实问题,我的态度是:新代码一律 ESM,老 CJS 依赖就当黑盒用 default 引入。别试图在 ESM 里 import { x } 去抠 CJS 包的命名导出——它根本没那东西,只会报错。如果你自己发库,想被两边都舒服地使用,最佳实践是「同时提供 ESM 构建 + 在 package.json 用 exports 字段按环境分发」(见 jsm-l13)。结论:混用不可怕,可怕的是不清楚「CJS 只有 default」。记住 import pkg from 'cjs' 这个姿势,能省你无数调试时间。