发布包
所有提供的 package.json
配置(未特别标记为“不起作用”)在 Node.js 12.22.x (v12 最新版,支持的最旧版本) 和 17.2.0 (撰写时的当前最新版本)1 中都可以工作,并且对于 webpack 5.53.0 和 5.63.0 也是如此。 这些配置可在此处获得:JakobJingleheimer/nodejs-module-config-examples。
对于好奇的同学,我们是如何走到这一步的 和 深入兔子洞 提供了背景和更深入的解释。
选择你的修复方案
主要有两个选项,涵盖几乎所有用例
- 使用 CJS 编写源代码并发布 (使用
require()
);CJS 可被 CJS 和 ESM 消费(在所有版本的 node 中)。 跳到 CJS 源代码和分发。 - 使用 ESM 编写源代码并发布(使用
import
,且不使用顶层await
);ESM 可被 ESM 和 CJS 消费(在 node 22.x 和 23.x 中;参见 使用require()
加载 ES 模块)。 跳到 ESM 源代码和分发。
通常最好只发布一种格式,无论是 CJS 还是 ESM。 不要同时发布两种格式。 发布多种格式可能会导致双重包的危害,以及其他缺点。
还有其他选项可用,主要用于历史目的。
作为包作者,你编写 | 你的包的消费者以以下方式编写他们的代码 | 你的选项 |
---|---|---|
使用 require() 的 CJS 源代码 | ESM:消费者 import 你的包 | CJS 源代码和仅 ESM 分发 |
CJS & ESM:消费者既可以 require() 也可以 import 你的包 | CJS 源代码和 CJS & ESM 两种分发 | |
使用 import 的 ESM 源代码 | CJS:消费者 require() 你的包(且你使用顶层 await ) | 仅使用 CJS 分发的 ESM 源代码 |
CJS & ESM:消费者既可以 require() 也可以 import 你的包 | 同时使用 CJS & ESM 两种分发的 ESM 源代码 |
CJS 源代码和分发
最少的配置可能只有 "name"
。 但越不神秘越好:本质上只需通过 "exports"
字段/字段集声明包的导出。
工作示例:cjs-with-cjs-distro
{
"name": "cjs-source-and-distribution"
// "main": "./index.js"
}
请注意,packageJson.exports["."] = filepath
是 packageJson.exports["."].default = filepath
的简写形式
ESM 源代码和分发
简单、经过尝试且真实。
请注意,自 Node.js v23.0.0 起,可以 require
静态 ESM(不使用顶层 await
的代码)。 有关详细信息,请参见 使用 require()
加载 ECMAScript 模块。
这与上面的 CJS-CJS 配置几乎完全相同,只有一个很小的区别:"type"
字段。
工作示例:esm-with-esm-distro
{
"name": "esm-source-and-distribution",
"type": "module"
// "main": "./index.js"
}
请注意,ESM 现在确实“向后”兼容 CJS:从 23.0.0 和 22.12.0 开始,CJS 模块现在可以 require()
ES 模块而无需任何标志。
CJS 源代码和仅 ESM 分发
这需要一点技巧,但也非常简单。 这可能是针对较新标准的较旧项目的选择,或者仅仅是更喜欢 CJS 但正在为不同环境发布代码的作者的选择。
工作示例:cjs-with-esm-distro
{
"name": "cjs-source-with-esm-distribution",
"main": "./dist/index.mjs"
}
.mjs
文件扩展名是一张王牌:它将覆盖任何其他配置,并且该文件将被视为 ESM。 使用此文件扩展名是必要的,因为 packageJson.exports.import
不表示该文件是 ESM(与常见的,如果不是普遍的,误解相反),而仅表示该文件是在导入包时使用的文件(ESM 可以导入 CJS。 参见下面的 陷阱)。
CJS 源代码和 CJS & ESM 两种分发
为了直接向两个受众群体提供(以便你的分发在两者中“原生”地工作),你有几个选择
将命名导出直接附加到 exports
经典但需要一些技巧和手段。 这意味着将属性添加到现有的 module.exports
上(而不是重新分配整个 module.exports
)。
{
"name": "cjs-source-with-esm-via-properties-distribution",
"main": "./dist/cjs/index.js"
}
优点
- 更小的包体积
- 简单容易(如果你不介意遵守一些次要的语法规定,这可能是最省力的)
- 避免了双重包的危害
缺点
- 需要非常特定的语法(无论是在源代码中还是在打包器的技巧中)。
有时,CJS 模块可能会像这样将 module.exports
重新分配给其他内容(无论是对象还是函数)
const someObject = {
foo() {},
bar() {},
qux() {},
};
module.exports = someObject;
Node.js 通过 静态分析来检测 CJS 中的命名导出,静态分析会查找某些模式,上面的示例避免了这些模式。 为了使命名导出可检测,请执行以下操作
module.exports.foo = function foo() {};
module.exports.bar = function bar() {};
module.exports.qux = function qux() {};
使用简单的 ESM 包装器
设置复杂,难以保持平衡。
工作示例:cjs-with-dual-distro(包装器)
{
"name": "cjs-with-wrapper-dual-distro",
"exports": {
".": {
"import": "./dist/esm/wrapper.mjs",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
}
}
}
优点
- 更小的包体积
缺点
- 可能需要复杂的打包器技巧(我们无法在 Webpack 中找到任何现有的自动化此操作的选项)。
当打包器生成的 CJS 输出避开了 Node.js 中的命名导出检测时,可以使用 ESM 包装器来显式重新导出 ESM 使用者的已知命名导出。
当 CJS 导出一个对象(该对象被别名为 ESM 的 default
)时,你可以将对象的所有成员的引用保存在包装器中的本地,然后重新导出它们,以便 ESM 使用者可以通过名称访问所有成员。
import cjs from '../cjs/index.js';
const { a, b, c /* … */ } = cjs;
export { a, b, c /* … */ };
但是,这确实会破坏实时绑定:对 cjs.a
的重新分配不会反映在 esmWrapper.a
中。
两个完整的分发
放入一堆东西,希望一切顺利。 这可能是 CJS 到 CJS & ESM 选项中最常见和最容易的一种,但你需要为此付出代价。 这很少是一个好主意。
{
"name": "cjs-with-full-dual-distro",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
}
}
}
优点
- 简单的打包器配置
缺点
- 更大的包体积(基本上是双倍)
- 容易受到双重包的危害的影响
或者,你可以使用 "default"
和 "node"
键,它们不太违反直觉:Node.js 将始终选择 "node"
选项(该选项始终有效),并且非 Node.js 工具将在配置为以 node 以外的任何内容为目标时选择 "default"
。 这避免了双重包的危害。
{
"name": "cjs-with-alt-full-dual-distro",
"exports": {
".": {
"node": "./dist/cjs/index.js",
"default": "./dist/esm/index.mjs"
}
}
}
仅使用 CJS 分发的 ESM 源代码
我们已经不在堪萨斯州了,托托。
这些配置(有两种选择)与 ESM 源代码和 CJS & ESM 两种分发几乎相同,只是排除了 packageJson.exports.import
。
💡 将 "type": "module"
2 与 .cjs
文件扩展名(对于 commonjs 文件)配对可产生最佳结果。 有关原因的更多信息,请参见下面的 深入兔子洞 和 陷阱。
工作示例:esm-with-cjs-distro
同时使用 CJS & ESM 两种分发的 ESM 源代码
当源代码以非 JavaScript 编写时(例如 TypeScript),由于需要使用特定于该语言的文件扩展名(例如 .ts
),并且可能没有等效的 .mjs
,因此选项可能会受到限制。
与 CJS 源代码和 CJS & ESM 两种分发类似,你有相同的选项。
仅发布带有属性导出的 CJS 分发
难以制作,需要良好的原料。
此选项与上面的 具有 CJS & ESM 分发的 CJS 源代码的属性导出几乎相同。 唯一的区别是在 package.json 中:"type": "module"
。
只有一些构建工具支持生成此输出。 Rollup 在以 commonjs 为目标时,可以开箱即用地生成兼容的输出。 从 v5.66.0+ 开始的 Webpack 使用新的 commonjs-static
输出类型可以做到这一点(在此之前,没有 commonjs 选项可以生成兼容的输出)。 使用 esbuild 当前不可能实现这一点(它生成非静态的 exports
)。
下面的工作示例是在 Webpack 最近发布之前创建的,因此它使用了 Rollup(我会尽快添加一个 Webpack 选项)。
这些示例假定其中的 javascript 文件使用扩展名 .js
;package.json
中的 "type"
控制如何解释它们
"type":"commonjs"
+ .js
→ cjs
"type":"module"
+ .js
→ mjs
如果你的所有文件 *都* 明确使用 .cjs
和/或 .mjs
文件扩展名(没有文件使用 .js
),则 "type"
是多余的。
工作示例: esm-with-cjs-distro
{
"name": "esm-with-cjs-distribution",
"type": "module",
"main": "./dist/index.cjs"
}
💡 使用 "type": "module"
2 与 .cjs
文件扩展名(对于 commonjs 文件)配对,可以获得最佳结果。有关更多信息,请参阅下面的深入研究和注意事项。
发布带有 ESM 包装器的 CJS 发行版
这里有很多事情要做,而且通常不是最好的选择。
这与 使用 ESM 包装器的 CJS 源代码和双重发行版几乎相同,但存在细微差别:"type": "module"
和 package.json 中的一些 .cjs
文件扩展名。
工作示例: esm-with-dual-distro (wrapper)
{
"name": "esm-with-cjs-and-esm-wrapper-distribution",
"type": "module",
"exports": {
".": {
"import": "./dist/esm/wrapper.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/cjs/index.cjs"
}
}
}
💡 使用 "type": "module"
2 与 .cjs
文件扩展名(对于 commonjs 文件)配对,可以获得最佳结果。有关更多信息,请参阅下面的深入研究和注意事项。
发布完整的 CJS 和 ESM 发行版
放入一堆东西(带有一个惊喜),并希望一切顺利。这可能是 ESM 到 CJS 和 ESM 选项中最常见和最简单的,但你需要为此付出代价。这很少是一个好主意。
在软件包配置方面,有一些选项主要在个人偏好上有所不同。
将整个软件包标记为 ESM,并通过 .cjs
文件扩展名专门将 CJS 导出标记为 CJS
此选项对开发/开发人员体验的负担最小。
这也意味着任何构建工具都必须生成带有 .cjs
文件扩展名的分发文件。这可能需要链接多个构建工具,或者添加后续步骤来移动/重命名文件以使其具有 .cjs
文件扩展名(例如 mv ./dist/index.js ./dist/index.cjs
)。可以通过添加后续步骤来移动/重命名这些输出文件来解决此问题(例如 Rollup 或 一个简单的 shell 脚本)。
对 .cjs
文件扩展名的支持是在 12.0.0 中添加的,使用它将使 ESM 正确地将文件识别为 commonjs (import { foo } from './foo.cjs'
可以工作)。但是,require()
不会自动解析 .cjs
,就像它对 .js
所做的那样,因此文件扩展名不能像在 commonjs 中那样省略:require('./foo')
将失败,但 require('./foo.cjs')
可以工作。在你的软件包的导出中使用它没有缺点:packageJson.exports
(和 packageJson.main
)都需要文件扩展名,并且使用者通过 package.json 的 "name"
字段引用你的软件包(所以他们很幸福地不知道)。
工作示例: esm-with-dual-distro
{
"type": "module",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/index.cjs"
}
}
}
或者,你可以使用 "default"
和 "node"
键,它们不太违反直觉:Node.js 将始终选择 "node"
选项(该选项始终有效),并且非 Node.js 工具将在配置为以 node 以外的任何内容为目标时选择 "default"
。 这避免了双重包的危害。
{
"type": "module",
"exports": {
".": {
"node": "./dist/index.cjs",
"default": "./dist/esm/index.js"
}
}
}
💡 使用 "type": "module"
2 与 .cjs
文件扩展名(对于 commonjs 文件)配对,可以获得最佳结果。有关更多信息,请参阅下面的深入研究和注意事项。
对所有源代码文件使用 .mjs
(或等效的)文件扩展名
此配置与 CJS 源代码以及 CJS 和 ESM 发行版相同。
非 JavaScript 源代码:非 JavaScript 语言自身的配置需要识别/指定输入文件为 ESM。
Node.js 版本低于 12.22.x
🛑 你不应该这样做:低于 12.x 的 Node.js 版本已达到生命周期终点,并且现在容易受到严重的安全漏洞攻击。
如果你是一名需要调查 v12.22.x 之前的 Node.js 的安全研究人员,请随时与我们联系以寻求配置方面的帮助。
通用说明
语法检测不能替代正确的软件包配置;语法检测并非万无一失,并且具有显著的性能成本。
在使用 package.json 中的 "exports"
时,通常建议包含 "./package.json": "./package.json"
,以便可以导入它(module.findPackageJSON
不受此限制的影响,但 import
可能更方便)。
建议使用 "exports"
而不是 "main"
,因为它阻止了对内部代码的外部访问(因此你可以相对确定用户没有依赖于他们不应该依赖的东西)。如果你不需要这样,"main"
更简单,可能更适合你。
"engines"
字段提供了对软件包兼容的 Node.js 版本的人工友好和机器友好的指示。根据使用的软件包管理器,当使用者使用不兼容的 Node.js 版本时,可能会抛出异常,导致安装失败(这对使用者非常有帮助)。包含此字段将为无法使用该软件包的旧版本 Node.js 的使用者节省很多麻烦。
深入研究
特别是与 Node.js 相关,有 4 个问题需要解决
-
确定源代码文件的格式(作者运行他/她自己的代码)
-
确定发行版文件的格式(代码使用者将收到)
-
发布发行版代码,以便
require()
(使用者期望 CJS) -
发布发行版代码,以便
import
(使用者可能想要 ESM)
⚠️ 前 2 个与后 2 个独立。
加载方法不会确定文件被解释为哪种格式
- package.json 的
exports.require
≠CJS
。require()
不会也不能盲目地将文件解释为 CJS;例如,require('foo.json')
正确地将文件解释为 JSON,而不是 CJS。当然,包含require()
调用的模块必须是 CJS,但它加载的内容不一定是 CJS。 - package.json 的
exports.import
≠ESM
。import
类似地不会也不能盲目地将文件解释为 ESM;import
可以加载 CJS、JSON 和 WASM,以及 ESM。当然,包含import
语句的模块必须是 ESM,但它加载的内容不一定是 ESM。
因此,当你看到引用或命名为 require
或 import
的配置选项时,请抵制假设它们用于确定 CJS 与 ES 模块的冲动。
⚠️ 向软件包的配置添加 "exports"
字段/字段集有效地阻止了对软件包的深入路径访问,除非在 exports 的子路径中显式列出。这意味着它可能是一个破坏性更改。
⚠️ 仔细考虑是否同时分发 CJS 和 ESM:如果配置错误,并且使用者试图变得聪明,则会产生 双重包风险 的可能性。这可能会导致使用项目中的一个非常令人困惑的错误,特别是当你的软件包没有得到完美配置时。使用者甚至可能被使用你的软件包的“其他”格式的中间软件包所蒙蔽(例如,使用者使用 ESM 发行版,并且使用者也在使用的某个其他软件包本身使用 CJS 发行版)。如果你的软件包在任何方面都是有状态的,则使用 CJS 和 ESM 发行版都会导致并行状态(这几乎肯定不是故意的)。
双重包风险
当应用程序使用提供 CommonJS 和 ES 模块源的软件包时,如果加载了该软件包的两个实例,则存在某些错误的风险。这种可能性来自以下事实:由 const pkgInstance = require('pkg')
创建的 pkgInstance
与由 import pkgInstance from 'pkg'
(或替代主路径,例如 'pkg/module'
)创建的 pkgInstance
不同。这就是“双重包风险”,其中同一软件包的两个实例可以在同一运行时环境中加载。虽然应用程序或软件包不太可能有意直接加载这两个实例,但应用程序加载一个副本,而应用程序的依赖项加载另一个副本是很常见的。之所以会发生这种危险,是因为 Node.js 支持混合使用 CommonJS 和 ES 模块,并且可能导致意外和令人困惑的行为。
如果软件包主导出是一个构造函数,则由两个副本创建的实例的 instanceof
比较返回 false
,如果导出是一个对象,则添加到其中一个的属性(如 pkgInstance.foo = 3
)不存在于另一个上。这与 import
和 require
语句在所有 CommonJS 或所有 ES 模块环境中的工作方式不同,因此用户感到惊讶。这也与用户在使用通过 Babel 或 esm
等工具进行转译时所熟悉的行为不同。
我们是如何走到这一步的
CommonJS (CJS) 是在 ECMAScript 模块 (ESM) 很久之前 创建的,当时 JavaScript 仍然处于青春期——CJS 和 jQuery 仅相隔 3 年创建。 CJS 不是官方的 (TC39) 标准,并且受到少数几个平台(最著名的是 Node.js)的支持。 作为标准的 ESM 已经到来好几年了; 目前受到所有主要平台(浏览器、Deno、Node.js 等)的支持,这意味着它几乎可以在任何地方运行。 随着 ESM 显然将有效地取代 CJS(CJS 仍然非常流行和广泛),许多人试图尽早采用,通常是在 ESM 规范的特定方面最终确定之前。 因此,这些随着时间的推移而发生了变化,因为有更好的信息可用(通常是由那些渴望成为海狸的人的学习/经验告知的),从最佳猜测到与规范保持一致。
另一个复杂因素是打包器,它们历史上管理着大部分领域。 但是,我们以前需要 bundle(r)s 管理的很多东西现在都是原生功能。 然而,对于某些事情来说,打包器仍然(并且可能永远都是)是必要的。 不幸的是,打包器不再需要提供的功能已深深扎根于旧打包器的实现中,因此有时它们过于有用,在某些情况下,是反模式(打包库通常不被打包器作者自己推荐)。 其中的原因和方式本身就是一篇文章。
注意事项
package.json
文件的 "type"
字段会改变 .js
文件扩展名的含义,使其分别代表 commonjs
或 ES module
。在双模式/混合模式的包(同时包含 CJS 和 ESM)中,经常会错误地使用这个字段。
{
"type": "module",
"main": "./dist/CJS/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
},
"./package.json": "./package.json"
}
}
这样做行不通,因为 "type": "module"
会导致 packageJson.main
、packageJson.exports["."].require
和 packageJson.exports["."].default
被解释为 ESM (但它们实际上是 CJS)。
排除 "type": "module"
会产生相反的问题
{
"main": "./dist/CJS/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
},
"./package.json": "./package.json"
}
}
这样做行不通,因为 packageJson.exports["."].import
会被解释为 CJS (但它实际上是 ESM)。
脚注
-
Node.js v13.0–13.6 中存在一个错误,其中
packageJson.exports["."]
必须是一个数组,其中第一个项目是详细配置选项(作为对象),第二个项目是“默认”选项(作为字符串)。请参见 nodejs/modules#446。 ↩ -
package.json 中的
"type"
字段会更改.js
文件扩展名的含义,类似于 HTML script 元素的 type 属性。 ↩ ↩2 ↩3 ↩4