发布包

所有提供的 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 还是 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["."] = 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(属性)

{
  "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 选项中最常见和最容易的一种,但你需要为此付出代价。 这很少是一个好主意。

工作示例cjs-with-dual-distro(双重)

{
  "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 文件使用扩展名 .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 以外的任何内容为目标时选择 "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 CJSrequire() 不会也不能盲目地将文件解释为 CJS;例如,require('foo.json') 正确地将文件解释为 JSON,而不是 CJS。当然,包含 require() 调用的模块必须是 CJS,但它加载的内容不一定是 CJS。
  • package.json 的 exports.import ESMimport 类似地不会也不能盲目地将文件解释为 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)不存在于另一个上。这与 importrequire 语句在所有 CommonJS 或所有 ES 模块环境中的工作方式不同,因此用户感到惊讶。这也与用户在使用通过 Babelesm 等工具进行转译时所熟悉的行为不同。

我们是如何走到这一步的

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.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