前言
本文将从 2 个视角、12 种场景来分析和使用 ESM 和 CJS 模块:
- NPM 库维护者
- 在 CJS 项目中导出 CJS 模块
- 在 CJS 项目中导出 ESM 模块
- 在 ESM 项目中导出 ESM 模块
- 在 ESM 项目中导出 CJS 模块
- 使用者
- 在 CJS 项目中使用内部的 CJS 模块
- 在 CJS 项目中使用外部 npm 库的 CJS 模块
- 在 CJS 项目中使用内部的 ESM 模块
- 在 CJS 项目中使用外部 npm 库的 ESM 模块
- 在 ESM 项目中使用内部的 ESM 模块
- 在 ESM 项目中使用外部 npm 库的 ESM 模块
- 在 ESM 项目中使用内部的 CJS 模块
- 在 ESM 项目中使用外部 npm 库的 CJS 模块
基础知识——CJS 模块导出
CJS 模块导出一共有两种方式:module.exports
和 exports
本质区别
module.exports
是模块系统实际导出的对象exports
只是module.exports
的一个引用(相当于let exports = module.exports
)
使用差异
// 以下两种方式是等价的:exports.foo = 'bar';// 等同于module.exports.foo = 'bar';
// 但直接赋值时会有区别:exports = { foo: 'bar' }; // ❌ 不会生效(改变了exports的引用)module.exports = { foo: 'bar' }; // ✅ 正确方式
注意事项
-
直接赋值时:
- 只能使用
module.exports
进行整体导出 ✅ - 使用
exports = ...
会切断引用关系,导致导出失败 ❌
- 只能使用
-
混合使用时:
- 如果同时使用了
module.exports
和exports.add
,只有最后设置的 module.exports 会生效。
// ... 其他代码 ...// ❌ 错误示例(exports赋值会被覆盖)exports = { a: 1 };module.exports = { b: 2 }; // 只有这个会生效// ✅ 正确混合使用方式module.exports = { b: 2 }; // 主导出module.exports.a = 1; // 添加属性 - 如果同时使用了
最佳实践
- 统一使用
module.exports
更安全可靠 - 如果要使用
exports
,只用于添加属性(exports.xxx = ...)
- 更具体的实践可参考下面的 npm 库维护者视角
基础知识——ESM 模块导出
esm 模块导出则简单很多,只需要使用 export
进行导出即可
命名导出(推荐多导出时使用)
// 导出变量export const name = 'value';
// 导出函数export function fn() {}
// 导出后重命名export { name as newName };
默认导出
// 导出单个值export default function() {}
// 或者const obj = {};export default obj;
混合导出
// 同时有默认导出和命名导出export default esmFnDefault;export { esmFn, esmVar, EsmClass };
基础知识——package.json 与 ESM 和 CJS 的关系
package.json
中type=commonjs
或不指定。该项目下所有.js
文件均被解释使用 CJS 模块加载。如果想使用 ESM 模块,则该文件必须采用.mjs
后缀名(外部 npm 包可省略)。package.json
中type=module
。该项目下所有.js
文件的独立加载均被解释使用 ESM 模块,如果想使用 CJS 模块,则该文件必须采用.cjs
后缀名(外部 npm 包可省略)。- 对于
NodeJs@22.12.0
以下版本,CJS 模块的require
命令不能加载.mjs
文件,会报错,只有动态import
命令才可以加载.mjs
文件。但动态import
命令是异步的,需要Promise
来处理,详细使用可参考下文使用者视角 - 对于
NodeJs@22.12.0
及以上版本,CJS 模块require
命令可以加载.mjs
文件,但需要指定后缀名(外部 npm 包可省略)。 - ESM 模块的
import
命令可以加载 CJS 模块,但加载任何 ESM 模块或 CJS 都需要指定文件后缀名(外部 npm 包可省略)。加载外部 npm 库时,其实main
或者export
字段都已经指定了文件后缀,所以不需要用户手动填写。这也解释了第 2 点中的独立加载,一旦要使用import
命令,则需要指定文件后缀。
NPM 库维护者视角
在 CJS 项目中导出 CJS 模块
作为 NPM 库来说
- 如果你的库只需要提供一个入口函数/对象/变量供外部使用。推荐使用
module.exports
直接导出这个入口函数/对象/变量。
function cjsFnDefault() { console.log("cjsFnDefault", "----cjs:module.exports") return "cjsFnDefault----cjs:module.exports"}
// 只导出一个默认导出即可module.exports = cjsFnDefault
- 如果你的库提供多个入口函数供外部使用。推荐使用
module.exports
导出多个对象。
// 单独变量导出const cjsVar = "cjsVar----cjs:module.exports"
// 单独函数导出function cjsFn() { console.log("cjsFn", "----cjs:module.exports") return "cjsFn----cjs:module.exports"}
module.exports = { cjsVar, cjsFn}
- 如果既有主入口函数需要导出,又有其他函数需要导出,推荐使用如下方式来导出。
// 单独变量导出const cjsVar = "cjsVar----cjs:module.exports"
// 单独函数导出function cjsFn() { console.log("cjsFn", "----cjs:module.exports") return "cjsFn----cjs:module.exports"}
// 默认函数导出function cjsFnDefault() { console.log("cjsFnDefault", "----cjs:module.exports") return "cjsFnDefault----cjs:module.exports"}
// 1. 用户可以直接使用 const cjsFnDefault = require('./module_exports') 来获取默认导出的函数module.exports = cjsFnDefault// 2. 这里也加一个 default 是为了兼容默认导出。当用户需要既使用默认函数,又使用其他函数时,可以使用 const { default: cjsFnDefault, cjsFn } = require('./module_exports') 更方便地获取module.exports.default = cjsFnDefault// 3. 选择导出其他函数module.exports.cjsFn = cjsFnmodule.exports.cjsVar = cjsVar
关于兼容默认导出,当用户使用如下代码时,兼容导出会更方便使用。
// CJS 项目中使用const { default: cjsFnDefault, cjsFn, cjsVar } = require('./module_exports')
// 用户可以只通过一个 require 来同时导出默认和其他对象cjsFnDefault()cjsFn()console.log(cjsVar)
// 如果没有设置 module.exports.default = cjsFnDefault// 则用户需要使用如下兼容代码来同时使用const cjsModule = require('./module_exports')const cjsFnDefault = cjsModule || cjsModule.defaultconst { cjsFn, cjsVar } = cjsModule
// ESM 项目则由 import 自动处理,加不加兼容导出都能使用如下代码import cjsFnDefault, { cjsFn, cjsVar, CjsClass } from 'cjs' // 两种方式都可以,import 会自动处理 default 导出
在 CJS 项目中导出 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/export/cjs
在 CJS 项目中导出 ESM 模块
这种场景不太常见,一般见于 CJS 项目过渡向 ESM 过程中的一些补丁场景。但这种场景下最关键的有两点:
- CJS 项目中如果想导出 ESM 模块,则需要指定 ESM 模块代码文件后缀为
.mjs
。 package.json
中需要使用exports
配置来导出 ESM 模块。可以使用exports
字段来适配该 CJS 项目的 ESM 模块加载入口,注意指定文件后缀名。
{ "name": "cjs_with_esm", "version": "1.0.0", "description": "", "main": "cjs.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "type": "commonjs", "exports": { ".": { "import": "./esm.mjs", "require": "./cjs.js" } }}
在 CJS 项目中导出 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/export/cjs_with_esm
在 ESM 项目中导出 ESM 模块
使用 ESM 模块的 export 命令即可导出。但需要注意 export { default: esmFnDefault }
和 export default esmFnDefault
不能同时使用,在别人引入的时候会报错。
// exports esm in esm
// 变量const esmVar = "esmVar----esm_module:export"
// 函数导出// 可以直接使用 export 导出函数export function esmFn() { console.log("esmFn", "----esm_module:export") return "esmFn:----esm_module:export"}// 类class EsmClass { esmClassFn() { console.log("esmClassFn", "----esm_module:export") return "esmClassFn----esm_module:export" }}
// export default function esmFnDefault() { 也可以直接使用export default function 来导出默认函数function esmFnDefault() { console.log("esmFnDefault", "----esm_module:export") return "esmFnDefault----esm_module:export"}
// ESM 选择导出export { esmVar, EsmClass, // esmFn, // 使用 export 导出的函数,和上面的选择其一,不能同时使用 // esmFnDefault as default, // esm 默认导出,和下面的选择其一,不能同时使用,使用时会报错 ❌}
export default esmFnDefault
在 ESM 项目中导出 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/export/esm_module
在 ESM 项目中导出 CJS 模块
这种场景和上面的在 CJS 项目中导出 ESM 模块类似,但蛮常见的,很多 NPM 库借助编译工具,同时,同时向外提供 CJS 模块和 ESM 模块
- ESM 项目中如果想导出 CJS 模块,则需要指定 CJS 模块代码文件后缀为
.cjs
。 package.json
中需要使用exports
配置来导出 CJS 模块。可以使用exports
字段来适配该 ESM 项目的 CJS 模块加载入口,注意指定文件后缀名。
{ "name": "esm_with_cjs", "version": "1.0.0", "description": "", "main": "esm.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "type": "module", "exports": { ".": { "import": "./esm.js", "require": "./cjs.cjs" } }}
在 ESM 项目中导出 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/export/esm_with_cjs
使用者视角
在 CJS 项目中使用内部的 CJS 模块
直接使用 require 进行加载即可,所有.js
文件均被解释为CJS 模块,require
CJS 模块可以不加.js
文件后缀。
// in cjs project to use cjs inner// 在 cjs 项目中使用 cjs 内部模块
const { default: cjsFnDefault, cjsVar, cjsFn, CjsClass } = require('./cjs_inner');cjsFnDefault()cjsFn()console.log(cjsVar)const cjsClass = new CjsClass()cjsClass.cjsClassFn()
在 CJS 项目中使用内部的 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_cjs_use_cjs_inner
在 CJS 项目中使用外部 npm 库的 CJS 模块
只要外部的 npm 库在 package.json
中提供了 CJS 模块入口,则可以直接使用require
即可
const { default: cjsFnDefault, cjsFn, CjsClass, cjsVar} = require("cjs")
cjsFnDefault()cjsFn()console.log(cjsVar)const cjsClass = new CjsClass()cjsClass.cjsClassFn()
在 CJS 项目中使用外部 npm 库的 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_cjs_use_cjs_outer
在 CJS 项目中使用内部的 ESM 模块
这种虽然不常见但比较复杂,完整解释了如何如何混用 ESM 和 CJS。
在 CJS 项目中使用内部的 ESM 模块一共有两种方式:
- 使用 esm npm 库来加载内部 ESM 模块代码(不推荐,这个库已经很多年不维护了)
// npm i esm 库const esm = require("esm")(module)const { default: esmFnDefault, esmFn, EsmClass, esmVar } = esm("./esm_mjs")esmFnDefault()esmFn()console.log(esmVar)const esmClass = new EsmClass()esmClass.esmClassFn()
2. 使用 import 命令来加载内部 ESM 模块代码(推荐)
需要注意的是
- 需要指定
.mjs
文件后缀来告诉 Node 使用 ESM 解释器加载。 - 因为
import
命令为异步代码,而 CJS 模块不允许顶层await,所以需要在异步函数中使用
// const esmModule = await import('./esm_mjs.mjs'); // ❌ 报错,CJS 模块不允许顶层 await
async function test() { const esmModule = import('./esm_mjs.mjs').then(esm => esm); const { default: esmFnDefault, esmFn, EsmClass, esmVar } = await esmModule esmFnDefault() esmFn() console.log(esmVar) const esmClass = new EsmClass() esmClass.esmClassFn()}
test();
3. 直接使用 require 命令加载 ESM 模块(Node 版本要求)
如果你的项目的 Node 版本高于 nodejs@v22.12.0
,则你也可以直接使用require
来加载 ESM 模块了。但是需要指定.mjs
文件后缀,且因为是在 CJS 项目中,所以不能加载.js
后缀的 ESM 模块
// 注意:此用例的 Node 版本需要>=22.12.0// 直接使用 require 加载 ESM 模块: https://nodejs.org/en/blog/release/v22.12.0const { default: esmFnDefault, esmFn, EsmClass, esmVar } = require('./esm_mjs.mjs')// const { default: esmFnDefault, esmFn, EsmClass, esmVar } = require('./esm_js.js') // ❌ 会报错
esmFnDefault()esmFn()console.log(esmVar)const esmClass = new EsmClass()esmClass.esmClassFn()
在 CJS 项目中使用内部的 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_cjs_use_esm_inner
在 CJS 项目中使用外部 npm 库的 ESM 模块(常见问题)
这个和上面的类似,只要该 npm 库提供了 ESM 模块就行。但值得注意的是,无法使用 esm 库来加载外部 npm 库,只能使用 import
命令来异步加载。或者Node 版本高于 nodejs@v22.12.0
,则也可以直接使用require
来加载 ESM 模块
// test cjs_with_esm(async () => { const esmModule = await import('cjs_with_esm') const { default: esmFnDefault, esmFn, EsmClass, esmVar } = esmModule esmFnDefault() esmFn() console.log(esmVar) const esmClass = new EsmClass() esmClass.esmClassFn()})();
在 CJS 项目中使用内部的 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_cjs_use_esm_outer
在 ESM 项目中使用内部的 ESM 模块
这里有个非常重要的点:在 ESM 项目中,该项目下所有.js
文件的独立加载均被解释使用 ESM 模块。一旦要使用 import
加载其他模块,不管是 ESM 还是 CJS 都需要添加文件后缀
import esmFnDefault, { esmVar, EsmClass, esmFn } from './esm_js.js'; // 需要指定文件后缀名// import esmFnDefault, { esmVar, EsmClass, esmFn } from './esm_mjs.mjs'; // 也需要指定文件名
esmFnDefault()esmFn()console.log(esmVar)const esmClass = new EsmClass()esmClass.esmClassFn()
在 ESM 项目中使用内部的 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_esm_use_esm_inner
在 ESM 项目中使用外部 npm 库的 ESM 模块
只要该 npm 库提供了 ESM 模块就行。而且在使用外部的 ESM npm 库时,因为外部的 npm 库在导出时都在 package.json
的 main
或者 exports
字段指定了入口文件后缀,所以 import
时不需要指定文件后缀。
import esmFnDefault, { esmVar, EsmClass, esmFn } from 'cjs_with_esm';
esmFnDefault()esmFn()console.log(esmVar)const esmClass = new EsmClass()esmClass.esmClassFn()
在 ESM 项目中使用外部 npm 库的 ESM 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_esm_use_esm_outer
在 ESM 项目中使用内部的 CJS 模块
这个场景和在 ESM 项目中使用内部的 ESM 模块类似,因为 import 本身支持加载 CJS 模块,但需要注意指定该文件的文件后缀。
// 在 ems 中使用 cjs,可以直接使用,但需要指定 cjs 的文件后缀
import { cjsFn, cjsVar, CjsClass, default as cjsFnDefault } from './cjs.cjs'// import cjsFnDefault, { cjsFn, cjsVar, CjsClass } from './cjs.cjs' // 两种方式都可以,import 会自动处理 default 导出
cjsFnDefault()cjsFn()console.log(cjsVar)const cjsClass = new CjsClass()cjsClass.cjsClassFn()
在 ESM 项目中使用内部的 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_esm_use_cjs_inner
在 ESM 项目中使用外部 npm 库的 CJS 模块
这个和上面的类似,由于外部的 npm 库在导出时都在 package.json
的 main
或者 exports
字段指定了入口文件后缀,所以 import
时不需要指定文件后缀。
// 可以直接使用
// import { cjsFn, cjsVar, CjsClass, default as cjsFnDefault } from 'cjs'import cjsFnDefault, { cjsFn, cjsVar, CjsClass } from 'cjs' // 两种方式都可以,import 会自动处理 default 导出
cjsFnDefault()cjsFn()console.log(cjsVar)const cjsClass = new CjsClass()cjsClass.cjsClassFn()
在 ESM 项目中使用外部 npm 库的 CJS 模块完整示例代码:https://github.com/LetTTGACO/esm-and-cjs/tree/master/packages/use/in_esm_use_cjs_outer
最佳实践
对于 CJS 项目维护者/使用者
如果你是 CJS 模块的 npm 库维护者,在开发时大部分情况下,既是使用者,也是维护者。
当 require
外部 npm 库时,经常出现的情况就是Error [ERR_REQUIRE_ESM]: require() of ES Module
,这个时候的解决方案就是
- 回退这个库到 CJS 模块的版本号
- 使用 import 异步加载 ESM 模块
- 升级 Node 到
Nodejs@22.12.0
,但对于npm 维护者库来说,需要限制用户的 node 版本
对于 ESM 项目维护者/使用者
无论是加载外部 ESM 模块还是 CJS 模块,import
外部库会变得几乎无感,对于需要添加文件后缀的问题,则可以通过 Typescript + 编译工具来解决
结论
总得来说,对于 npm 库维护者,全面拥抱 ESM 将是大势所趋,目前越来越多的库都 Pure ESM package
所以新建项目的时候。第一步:把 package.json
的 type
变为 module
;第二步:尽情的 import
吧。
CJS 转向 ESM
对于已经存在的 CJS 项目,这里有一份转向 ESM 的操作指南: