在 Node.js 支持 ES 模块之前,要开发同时支持 CommonJS 和 ES 模块的 npm 包,常见的作法是,同时在 npm 包中使用 CommonJS 和 ES 模块语法将包的功能实现一遍,相当于要维护两套代码,然后在 package.json
的 main
字段中指定 CommonJS
入口文件,并在 module
字段中指定 ES 模块的入口文件来实现。
这样 Node.js 可以运行 CommonJS 入口文件,而支持 ES 模块的构建工具,则可以使用 ES 模块入口文件,借助打包工具,将 ES 模块的代码编译成 CommonJS 模块的代码在 Node.js 中执行。
不过,技术是不断地发展的,Node.js 早已原生支持了 ES 模块,并且一个包可以同时包含 CommonJS 和 ES 模块入口点(可以通过不同的标识符如 pkg
和 pkg/es-module
)。与 package.json
顶层 module
字段使用打包工具在 ES 模块文件在被 Node.js 解释和执行之前被即时转译为 CommonJS 的场景不同,由 ES 模块入口点引用的文件会直接作为 ES 模块进行解释和执行。也就是说,ES 模块文件可以直接在 Node.js 中运行,而不需要先转换为 CommonJS 格式。
同时支持 CommonJS 和 ES 模块包的风险
相对于纯粹的 CommonJS 包或 ES 模块包,同时支持 CommonJS 和 ES 模块环境下使用的 npm 包存在一些潜在的风险。
这种潜在的风险在于,虽然是同一个 npm 包,由于包内包含两套代码,通过 require
加载的包实例与通过 import
加载的包实例不是同一个实例:
const cjsPkgInstance = require('pkg')
import esPkgInstance from 'pkg'
// 虽然是同一个 npm 包,但是 ES 模块和 CommonJS 模块引入的包实例不是同一个
cjsPkgInstance !== esPkgInstance
我们要知道,在同一个运行时环境中,是可以加载同一包的两个不同版本的。虽然应用程序通常不会有意加载这两个版本,但在实际应用中,应用程序可能加载其中一个版本,而其依赖项则加载另一个版本。而现代的 Node.js 同时支持 ES 模块和 CommonJS 模块的语法,则可能导致应用程序意外行为。
由于在实际应用中,意外的加载了同一包的两个版本的代码,如果该包导出的是一个构造函数,那么通过这两种版本创建的实例进行 instanceof
比较将返回 false
;
如果导出的是一个对象,添加到其中一个实例上的属性(例如 pkgInstance.foo = 3
)不会出现在另一个实例上。这与在全 CommonJS 或全 ES 模块环境中 import
和 require
语句的工作方式不同,因此会让用户感到意外。这也不同于用户在使用 Babel 或 esm 等工具进行转译时所熟悉的行为。
编写同时支持 CommonJS 和 ES 模块包时最小化风险的方法
编写同时支持 CommonJS 和 ES 模块包时最小化风险的方法主要有两种,分别为使用 ES 模块包装器和状态隔离
这两种方法均满足以下条件:
- 这个包可以通过
require
和import
两种方式使用。 - 这个包可以在支持 ES 模块的 Node.js 中使用,也可以在那些不支持 ES 模块的旧版本 Node.js 中使用。
- 包的主入口点(例如
pkg
),如果使用require
,会解析到一个 CommonJS 文件,而import
会解析到一个 ES 模块文件。(同样的方法也适用于导出路径,例如pkg/feature
)。 - 该包提供了命名导出,例如
import { name } from 'pkg'
,而不是import pkg from 'pkg'; pkg.name
。 - 该包可能在其他 ES 模块环境中使用,例如浏览器。
- 前一节中描述的风险得以避免或最小化。
方法一:使用 ES 模块包装器
将包编写为 CommonJS,或者将 ES 模块源码转译为 CommonJS,并创建一个定义命名导出的 ES 模块包装文件。通过条件导出,使用 ES 模块包装文件来处理 import
,并使用 CommonJS 入口点来处理 require
。
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
"import": "./wrapper.mjs",
"require": "./index.cjs"
}
}
前面的例子使用了显式的扩展名 .mjs
和 .cjs
。如果你的文件使用 .js
扩展名,那么 "type": "module"
会使得这些文件被视为 ES 模块,同样地,"type": "commonjs"
会使它们被视为 CommonJS 模块。详见启用 ESM。
// ./node_modules/pkg/index.cjs
exports.name = 'value';
// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;
在这个例子中,从 import { name } from 'pkg'
导入的 name
和通过 const { name } = require('pkg')
获取的 name
是同一个单例。因此,当比较这两个 name
时,===
返回 true
,从而避免了不同的标识符带来的风险。
如果模块不仅仅是一个命名导出的列表,而是包含一个独特的函数或对象导出,比如 module.exports = function () { ... }
,或者如果希望在包装器中支持 import pkg from 'pkg'
这种模式,那么包装器就需要被改写为导出一个默认值,同时也可以包含其他命名导出,如下所示:
import cjsModule from './index.cjs';
export const name = cjsModule.name;
export default cjsModule;
这种方法适用于以下任何一种使用场景:
-
该 npm 包目前采用 CommonJS 模块规范编写,作者不愿将其重构为 ES 模块语法,但希望该包能在 ES 模块化环境中使用
-
这个 npm 包存储了内部状态,而包的作者不希望重构包以隔离其状态管理。具体内容请参见下一节。
这种方法的一个变体为不需要为使用者提供条件导出,而是可以添加一个导出路径,例如 ./module
,指向一个完全使用 ES 模块语法版本的包。用户可以通过 import 'pkg/module'
来使用它,前提是他们确定 CommonJS 版本不会在应用程序的任何地方被加载(例如通过依赖项);或者即使 CommonJS 版本被加载,也不会影响 ES 模块版本(例如,因为该包是无状态的):
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
".": "./index.cjs",
"./module": "./wrapper.mjs"
}
}
方法二:状态隔离
package.json 文件可以直接定义独立的 CommonJS 和 ES 模块入口点:
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
如果包的 CommonJS 版本和 ES 模块版本是等价的(例如,其中一个版本是另一个版本的转译输出),并且包的状态管理被严格隔离(或者包是无状态的),那么就可以这样做。
状态之所以成为问题,是因为包的 CommonJS 版本和 ES 模块版本可能会在同一个应用程序中被同时使用;例如,用户的应用程序代码可能导入 ES 模块版本,而某个依赖项却加载了 CommonJS 版本。如果发生这种情况,包的两个副本会被加载到内存中,从而导致两个独立的状态存在。这很可能会引发难以排查的 bug。
除了编写一个无状态的包(例如,如果 JavaScript 的 Math
是一个包,它将是无状态的,因为它的所有方法都是静态的)之外,还有一种方法就是状态隔离,将所有状态单独抽取到一个实例化的对象中或者将状态隔离在一个或多个 CommonJS 文件中,从而方便 npm 包的状态在可能加载的 CommonJS 和 ES 模块实例之间共享。
- 如果可能的话,将所有状态包含在一个实例化的对象中。例如,JavaScript 的
Date
需要被实例化以包含状态;如果它是一个包,将会像这样使用:
import Date from 'date';
const someDate = new Date();
// someDate contains state; Date does not
new
关键字不是必需的;包的函数可以返回一个新对象,或者修改传入的对象,以将状态保持在包外部。
- 将状态隔离在一个或多个 CommonJS 文件中,这些文件在包的 CommonJS 和 ES 模块版本之间共享。例如,如果 CommonJS 和 ES 模块入口点分别是
index.cjs
和index.mjs
:
// ./node_modules/pkg/index.cjs
const state = require('./state.cjs');
module.exports.state = state;
// ./node_modules/pkg/index.mjs
import state from './state.cjs';
export {
state,
};
即使在应用程序中通过 require
和 import
同时使用 pkg
(例如,在应用程序代码中通过 import
使用,而在依赖项中通过 require
使用),每个对 pkg
的引用都将包含相同的状态;并且从任一模块系统修改该状态都会同时影响两者。
任何附加到包的单例上的插件都需要分别附加到 CommonJS 和 ES 模块的单例上。要保证同一 npm 包中,CommonJS 版本与 ES 版本功能的一致性。
这种方法适用于以下使用场景:
-
该包目前使用 ES 模块语法编写,且包的作者希望在任何支持 ES 模块语法的地方都使用这个版本。
-
该 npm 包是无状态的,或者其状态可以相对容易地进行隔离。
与之前的方案一样,这种方案的一个变体是不需要开发者进行条件导出,可以通过添加一个导出(例如 ./module
)来指向包的全 ES 模块语法版本:
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
".": "./index.cjs",
"./module": "./index.mjs"
}
}
总结
目前 Node.js 同时支持 CommonJS 和 ES 模块的语法。开发者也可以编写同时支持 CommonJS 和 ES 模块的 npm 包,比如在 package.json
文件的 exports
字段中,使用条件导出,同时导出 CommonJS 和 ES 模块的入口文件。
但是,同时支持 CommonJS 和 ES 模块的 npm 包存在一些潜在的风险,因为在同一运行时环境中,应用程序是可以加载同一包的 CommonJS 和 ES 模块的两个版本的,比如应用程序可能加载 ES 模块版本的,而其依赖项却加载 CommonJS 模块版本的,从而导致难以排查的 bug 。
有两种方法可以最小化这个风险,分别为使用 ES 模块包装器和状态隔离。