发布包

所有提供的 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.js 版本中)消费。跳转到 CJS 源码和分发
  • 用 ESM 编写源代码并发布(你使用 import,且不使用顶层 await);ESM 可以被 ESM 和 CJS(在 Node.js 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() 你的包(并且你使用顶层 awaitESM 源码,仅 CJS 分发
CJS 和 ESM:消费者可以 require()import 你的包ESM 源码,同时分发 CJS 和 ESM

CJS 源码和分发

最精简的配置可能只需要 "name"。但越不晦涩越好:本质上只需通过 "exports" 字段/字段集声明包的导出。

工作示例cjs-with-cjs-distro

{
  "name": "cjs-source-and-distribution"
  // "main": "./index.js"
}

请注意,packageJson.exports["."] = filepathpackageJson.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)。

工作示例cjs-with-dual-distro (properties)

{
  "name": "cjs-source-with-esm-via-properties-distribution",
  "main": "./dist/cjs/index.js"
}

优点

  • 包体积更小
  • 简单易行(如果你不介意遵守一些微小的语法规定,这可能是最省力的方法)
  • 避免了双重包危害

缺点

  • 需要非常特定的语法(无论是在源代码中还是通过构建工具的复杂操作)。

有时,一个 CJS 模块可能会将 module.exports 重新赋值为其他东西(可能是一个对象或一个函数),就像这样:

const  = {
  () {},
  () {},
  () {},
};

. = ;

Node.js 通过寻找特定模式的静态分析来检测 CJS 中的命名导出,而上面的示例避开了这种检测。为了使命名导出可被检测到,请这样做:

.. = function () {};
.. = function () {};
.. = function () {};

使用一个简单的 ESM 包装器

设置复杂,难以找到正确的平衡点。

工作示例cjs-with-dual-distro (wrapper)

{
  "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  from '../cjs/index.js';

const { , ,  /* … */ } = ;

export { , ,  /* … */ };

但是,这会破坏实时绑定:对 cjs.a 的重新赋值不会反映在 esmWrapper.a 中。

两个完整的分发包

塞进一堆东西,然后祈祷一切顺利。这可能是 CJS 到 CJS 和 ESM 选项中最常见和最简单的一种,但你需要为此付出代价。这很少是个好主意。

工作示例cjs-with-dual-distro (double)

{
  "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.js 环境时会选择 "default"这样可以避免双重包危害。

{
  "name": "cjs-with-alt-full-dual-distro",
  "exports": {
    ".": {
      "node": "./dist/cjs/index.js",
      "default": "./dist/esm/index.mjs"
    }
  }
}

ESM 源码,仅 CJS 分发

我们不再是在堪萨斯了,托托。

配置(有两种选项)与ESM 源码同时分发 CJS 和 ESM几乎相同,只是排除了 packageJson.exports.import

💡 使用 "type": "module"2 并配合 .cjs 文件扩展名(用于 CommonJS 文件)可以获得最佳效果。有关原因的更多信息,请参见下面的深入探究注意事项

工作示例esm-with-cjs-distro

ESM 源码,同时分发 CJS 和 ESM

当源代码使用非 JavaScript 语言(例如 TypeScript)编写时,由于需要使用该语言特定的文件扩展名(例如 .ts),并且可能没有 .mjs 的等效扩展名,选项可能会受到限制。

CJS 源码,同时分发 CJS 和 ESM类似,你有相同的选项。

仅发布带有属性导出的 CJS 分发包

制作起来很棘手,需要好的原料。

此选项几乎与上述CJS 源码,分发 CJS 和 ESM 的属性导出选项相同。唯一的区别在于 package.json 中:"type": "module"

只有一些构建工具支持生成这种输出。Rollup 在目标为 commonjs 时可以直接生成兼容的输出。Webpack 从 v5.66.0+ 版本开始,通过新的 commonjs-static 输出类型也支持此功能(在此之前,没有 commonjs 选项可以生成兼容的输出)。目前无法使用 esbuild 实现(它会生成一个非静态的 exports)。

下面的工作示例是在 Webpack 最新版本发布之前创建的,所以它使用了 Rollup(我稍后会添加一个 Webpack 的选项)。

这些示例假设内部使用的 JavaScript 文件扩展名为 .jspackage.json 中的 "type" 控制了这些文件如何被解释。

"type":"commonjs" + .jscjs
"type":"module" + .jsmjs

如果你的文件明确*全部*使用 .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.js 环境时会选择 "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。

12.22.x 之前的 Node.js

🛑 你不应该这样做: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)

⚠️ 前两点与后两点是独立的。

加载方法并不能决定文件被解释的格式

  • package.json 的 exports.require CJSrequire() 不会也不能盲目地将文件解释为 CJS;例如,require('foo.json') 会正确地将文件解释为 JSON,而不是 CJS。包含 require() 调用的模块当然必须是 CJS,但它加载的内容不一定也是 CJS。
  • package.json 的 exports.import ESM。同样地,import 不会也不能盲目地将文件解释为 ESM;import 可以加载 CJS、JSON 和 WASM,以及 ESM。包含 import 语句的模块当然必须是 ESM,但它加载的内容不一定也是 ESM。

因此,当你看到引用或命名为 requireimport 的配置选项时,请抵制住认为它们是用于*确定* 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)在另一个对象上是不存在的。这与在纯 CommonJS 或纯 ES 模块环境中 importrequire 语句的工作方式不同,因此对用户来说是出乎意料的。这也与用户在使用像 Babelesm 这样的工具进行转译时所熟悉的行为不同。

我们是如何走到这一步的

CommonJS (CJS) 是在 ECMAScript 模块 (ESM) 出现*很久*之前创建的,当时 JavaScript 还处于发展初期——CJS 和 jQuery 的创建仅相隔 3 年。CJS 并非官方(TC39)标准,仅得到少数平台的支持(最著名的是 Node.js)。ESM 作为一个标准已经发展了好几年;它目前得到所有主流平台(浏览器、Deno、Node.js 等)的支持,这意味着它几乎可以在任何地方运行。随着 ESM 实际上将取代 CJS(CJS 仍然非常流行和广泛使用)的趋势变得明朗,许多人试图尽早采用,通常是在 ESM 规范的某个特定方面最终确定之前。因此,随着更好的信息(通常来自那些先行者的学习/经验)的出现,这些做法也随之改变,从最初的最佳猜测演变为与规范保持一致。

另一个复杂因素是打包工具(bundler),它们在历史上管理了这方面的许多工作。然而,我们以前需要打包工具管理的许多功能现在已经成为原生功能;但打包工具对于某些事情仍然是(而且可能永远是)必要的。不幸的是,打包工具不再需要提供的功能已经深深地根植于旧版打包工具的实现中,所以它们有时可能会帮倒忙,甚至在某些情况下成为反模式(打包一个库通常不被打包工具的作者们自己推荐)。这其中的来龙去脉本身就可以写成一篇文章。

注意事项

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.mainpackageJson.exports["."].requirepackageJson.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)。

脚注

  1. 在 Node.js v13.0–13.6 中存在一个错误,即 packageJson.exports["."] 必须是一个数组,第一个元素是带有详细配置选项的对象,第二个元素是“默认”的字符串。参见 nodejs/modules#446

  2. package.json 中的 "type" 字段改变了 .js 文件扩展名的含义,类似于 HTML script 元素的 type 属性 2 3 4