发布包

¥Publishing a package

所有提供的 package.json 配置(未特别标记为“不起作用”)均适用于 Node.js 12.22.x(v12 最新版本,支持的最旧版本)和 17.2.0(当时的最新版本)1,并且,为了好玩,分别使用 webpack 5.53.0 和 5.63.0。这些可用:JakobJingleheimer/nodejs-module-config-examples

¥All the provided package.json configurations (not specifically marked “does not work”) work in Node.js 12.22.x (v12 latest, the oldest supported line) and 17.2.0 (current latest at the time)1, and for grins, with webpack 5.53.0 and 5.63.0 respectively. These are available: JakobJingleheimer/nodejs-module-config-examples.

对于好奇的猫,我们是如何到达这里的进入兔子洞 提供了背景和更深入的解释。

¥For curious cats, How did we get here and Down the rabbit-hole provide background and deeper explanations.

选择你的修复

¥Pick your fix

有 2 个主要选项,几乎涵盖所有用例:

¥There are 2 main options, which cover almost all use-cases:

  • 编写源代码并在 CJS 中发布(你使用 require());CJS 可由 CJS 和 ESM 使用(在所有版本的节点中)。跳至 CJS 源和分发

    ¥Write source code and publish in CJS (you use require()); CJS is consumable by both CJS and ESM (in all versions of node). Skip to CJS source and distribution.

  • 编写源代码并在 ESM 中发布(你使用 import,而不使用顶层 await);ESM 可供 ESM 和 CJS 使用(在节点 22.x 和 23.x 中;参见 require() ES 模块)。跳至 ESM 源和分发

    ¥Write source code and publish in ESM (you use import, and don't use top-level await); ESM is consumable by both ESM and CJS (in node 22.x and 23.x; see require() an ES Module). Skip to ESM source and distribution.

通常最好只发布 1 种格式,即 CJS 或 ESM。不是两者。发布多种格式可能会导致 双重包危害,以及其他缺点。

¥It's generally best to publish only 1 format, either CJS or ESM. Not both. Publishing multiple formats can result in the dual-package hazard, as well as other drawbacks.

还有其他可用选项,主要用于历史目的。

¥There are other options available, mostly for historical purposes.

你作为包作者编写你的包的消费者在你的选择
使用 require() 的 CJS 源代码ESM:消费者 import 你的包CJS 源和仅 ESM 分发
CJS & ESM:消费者 require()import 你的包CJS 源和 CJS 与 ESM 分发
使用 import 的 ESM 源代码中编写他们的代码CJS:消费者 require() 你的包(并且你使用顶层 await仅带有 CJS 分发的 ESM 源
CJS & ESM:消费者 require()import 你的包ESM 源和 CJS 与 ESM 分发

CJS 源和分发

¥CJS source and distribution

最小配置可能只有 "name"。但越简单越好:本质上只需通过 "exports" 字段/字段集声明包的导出即可。

¥The most minimal configuration may be only "name". But the less arcane, the better: Essentially just declare the package’s exports via the "exports" field/field-set.

工作示例:cjs-with-cjs-distro

¥Working example: cjs-with-cjs-distro

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

请注意,packageJson.exports["."] = filepathpackageJson.exports["."].default = filepath 的简写

¥Note that packageJson.exports["."] = filepath is shorthand for packageJson.exports["."].default = filepath

ESM 源和分发

¥ESM source and distribution

简单、经过尝试且真实。

¥Simple, tried, and true.

请注意,自 Node.js v23.0.0 以来,可以 require 静态 ESM(不使用顶层 await 的代码)。有关详细信息,请参阅 使用 require() 加载 ECMAScript 模块

¥Note that since Node.js v23.0.0, it is possible to require static ESM (code that does not use top-level await). See Loading ECMAScript modules using require() for details.

这几乎与上面的 CJS-CJS 配置完全相同,只有 1 个小区别:"type" 字段。

¥This is almost exactly the same as the CJS-CJS configuration above with 1 small difference: the "type" field.

工作示例:esm-with-esm-distro

¥Working example: 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 模块

¥Note that ESM now is “backwards” compatible with CJS: a CJS module now can require() an ES Module without a flag as of 23.0.0 and 22.12.0.

CJS 源和仅 ESM 分发

¥CJS source and only ESM distribution

这需要一点技巧,但也相当简单。这可能是针对较新标准的旧项目的选择,或者只是喜欢 CJS 但为不同环境发布的作者的选择。

¥This takes a small bit of finesse but is also pretty straight-forward. This may be the choice pick of older projects targetting newer standards, or authors who merely prefer CJS but are publishing for a different environment.

工作示例:cjs-with-esm-distro

¥Working example: cjs-with-esm-distro

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

.mjs 文件扩展名是一张王牌:它将覆盖任何其他配置,并且文件将被视为 ESM。使用此文件扩展名是必要的,因为 packageJson.exports.import 并不表示该文件是 ESM(与常见的误解相反,如果不是普遍的误解),只表示它是导入包时要使用的文件(ESM 可以导入 CJS。请参阅下面的 陷阱)。

¥The .mjs file extension is a trump-card: it will override any other configuration and the file will be treated as ESM. Using this file extension is necessary because packageJson.exports.import does NOT signify that the file is ESM (contrary to common, if not universal, misperception), only that it is the file to be used when the package is imported (ESM can import CJS. See Gotchas below).

CJS 源和 CJS 与 ESM 分发

¥CJS source and both CJS & ESM distribution

为了直接供应两个受众(以便你的发行版在任一版本中都可以使用 "natively"),你有几个选项:

¥In order to directly supply both audiences (so that your distribution works "natively" in either), you have a few options:

将命名导出直接附加到 exports

¥Attach named exports directly onto exports

经典但需要一些复杂性和技巧。这意味着在现有的 module.exports 上添加属性(而不是重新分配整个 module.exports)。

¥Classic but takes some sophistication and finesse. This means adding properties onto the existing module.exports (instead of re-assigning module.exports as a whole).

工作示例:cjs-with-dual-distro (属性)

¥Working example: cjs-with-dual-distro (properties)

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

优点:

¥Pros:

  • 更小的包重量

    ¥Smaller package weight

  • 简单易用(如果你不介意遵守次要的语法规定,这可能是最省力的)

    ¥Easy and simple (probably least effort if you don't mind keeping to a minor syntax stipulation)

  • 排除 双包危害

    ¥Precludes the Dual-Package Hazard

缺点:

¥Cons:

  • 需要非常具体的语法(在源代码和/或打包器操作中)。

    ¥Requires very specific syntax (either in source code and/or bundler gymnastics).

有时,CJS 模块可能会将 module.exports 重新分配给其他内容(无论是对象还是函数),如下所示:

¥Sometimes, a CJS module may re-assign module.exports to something else (be it an object or a function) like this:

const someObject = {
  foo() {},
  bar() {},
  qux() {},
};

module.exports = someObject;

Node.js 通过 寻找某些模式的静态分析 检测 CJS 中的命名导出,上面的示例避开了这一点。要使命名导出可检测,请执行以下操作:

¥Node.js detects the named exports in CJS via static analysis that look for certain patterns, which the example above evades. To make the named exports detectable, do this:

module.exports.foo = function foo() {};
module.exports.bar = function bar() {};
module.exports.qux = function qux() {};

使用简单的 ESM 封装器

¥Use a simple ESM wrapper

设置复杂,难以取得平衡。

¥Complicated setup and difficult to get the balance right.

工作示例:cjs-with-dual-distro (封装器)

¥Working example: 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"
    }
  }
}

优点:

¥Pros:

  • 更小的包重量

    ¥Smaller package weight

缺点:

¥Cons:

  • 可能需要复杂的打包操作(我们找不到任何现有的选项来在 Webpack 中自动执行此操作)。

    ¥Likely requires complicated bundler gymnastics (we could not find any existing option to automate this in Webpack).

当打包器的 CJS 输出转义 Node.js 中的命名导出检测时,可以使用 ESM 封装器明确重新导出已知的命名导出以供 ESM 消费者使用。

¥When the CJS output from the bundler evades the named exports detection in Node.js, a ESM wrapper can be used to explicitly re-export the known named exports for ESM consumers.

当 CJS 导出一个对象(它被别名为 ESM 的 default)时,你可以在封装器中本地保存对该对象所有成员的引用,然后重新导出它们,以便 ESM 消费者可以访问所有按名称导出它们。

¥When CJS exports an object (which gets aliased to ESM's default), you can save references to all the members of the object locally in the wrapper, and then re-export them so the ESM consumer can access all of them by name.

import cjs from '../cjs/index.js';

const { a, b, c /* … */ } = cjs;

export { a, b, c /* … */ };

但是,这确实会破坏实时绑定:重新分配给 cjs.a 不会反映在 esmWrapper.a 中。

¥However, this does break live bindings: a reassignment to cjs.a will not reflect in esmWrapper.a.

两个完整的发行版

¥Two full distributions

投入一堆东西并希望获得最好的结果。这可能是 CJS 到 CJS 和 ESM 选项中最常见和最简单的,但你需要为此付出代价。这很少是一个好主意。

¥Chuck in a bunch of stuff and hope for the best. This is probably the most common and easiest of the CJS to CJS & ESM options, but you pay for it. This is rarely a good idea.

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

¥Working example: 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"
    }
  }
}

优点:

¥Pros:

  • 简单的打包器配置

    ¥Simple bundler configuration

缺点:

¥Cons:

或者,你可以使用 "default""node" 键,它们不那么违反直觉:Node.js 将始终选择 "node" 选项(始终有效),而非 Node.js 工具在配置为针对节点以外的目标时将选择 "default"。这排除了双包危险。

¥Alternatively, you can use "default" and "node" keys, which are less counter-intuitive: Node.js will always choose the "node" option (which always works), and non-Node.js tooling will choose "default" when configured to target something other than node. This precludes the dual-package hazard.

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

仅带有 CJS 分发的 ESM 源

¥ESM source with only CJS distribution

我们已经不在堪萨斯了,托托。

¥We're not in Kansas anymore, Toto.

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

¥The configurations (there are 2 options) are nearly the same as ESM source and both CJS & ESM distribution, just exclude packageJson.exports.import.

💡 使用 "type": "module"2.cjs 文件扩展名(用于 commonjs 文件)配对可获得最佳效果。有关原因的更多信息,请参阅下面的 进入兔子洞陷阱

¥💡 Using "type": "module"2 paired with the .cjs file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.

工作示例:esm-with-cjs-distro

¥Working example: esm-with-cjs-distro

ESM 源和 CJS 与 ESM 分发

¥ESM source and both CJS & ESM distribution

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

¥When source code is written in non-JavaScript (ex TypeScript), options can be limited due to needing to use file extension(s) specific to that language (ex .ts) and there may be no .mjs equivalent.

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

¥Similar to CJS source and both CJS & ESM distribution, you have the same options.

仅发布具有属性导出的 CJS 发行版

¥Publish only a CJS distribution with property exports

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

¥Tricky to make and needs good ingredients.

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

¥This option is almost identical to the CJS source with CJS & ESM distribution's property exports above. The only difference is in package.json: "type": "module".

只有一些构建工具支持生成此输出。Rollup 在针对 commonjs 时会生成开箱即用的兼容输出。从 v5.66.0+ 开始,Webpack 使用新的 commonjs-static 输​​出类型(在此之前,没有 commonjs 选项产生兼容的输出)。目前无法使用 esbuild(产生非静态 exports)。

¥Only some build tools support generating this output. Rollup produces compatible output out of the box when targetting commonjs. Webpack as of v5.66.0+ does with the new commonjs-static output type, (prior to this, no commonjs options produces compatible output). It is not currently possible with esbuild (which produces a non-static exports).

下面的工作示例是在 Webpack 最近发布之前创建的,因此它使用了 Rollup(我也会添加一个 Webpack 选项)。

¥The working example below was created prior to Webpack's recent release, so it uses Rollup (I'll get around to adding a Webpack option too).

这些示例假设 javascript 文件使用扩展名 .jspackage.json 中的 "type" 控制如何解释这些内容:

¥These examples assume javascript files within use the extension .js; "type" in package.json controls how those are interpreted:

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

如果你的文件明确使用 .cjs 和/或 .mjs 文件扩展名(没有使用 .js),则 "type" 是多余的。

¥If your files explicitly all use .cjs and/or .mjs file extensions (none use .js), "type" is superfluous.

工作示例:esm-with-cjs-distro

¥Working example: esm-with-cjs-distro

{
  "name": "esm-with-cjs-distribution",
  "type": "module",
  "main": "./dist/index.cjs"
}

💡 使用 "type": "module"2.cjs 文件扩展名(用于 commonjs 文件)配对可获得最佳效果。有关原因的更多信息,请参阅下面的 进入兔子洞陷阱

¥💡 Using "type": "module"2 paired with the .cjs file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.

发布带有 ESM 的 CJS 分发封装器

¥Publish a CJS distribution with an ESM wrapper

这里有很多事情要做,这通常不是最好的。

¥There's a lot going on here, and this is usually not the best.

这也几乎与 使用 ESM 封装器的 CJS 源和双重分发 相同,但 "type": "module" 和 package.json 中的一些 .cjs 文件扩展名有细微差别。

¥This is also almost identical to the CJS source and dual distribution using an ESM wrapper, but with subtle differences "type": "module" and some .cjs file extenions in package.json.

工作示例:esm-with-dual-distro(封装器)

¥Working example: 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 文件)配对可获得最佳效果。有关原因的更多信息,请参阅下面的 进入兔子洞陷阱

¥💡 Using "type": "module"2 paired with the .cjs file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.

发布完整的 CJS 和 ESM 发行版

¥Publish both full CJS & ESM distributions

投入一堆东西(带有惊喜)并希望获得最好的结果。这可能是 ESM 到 CJS 和 ESM 选项中最常见和最简单的,但你需要为此付出代价。这很少是一个好主意。

¥Chuck in a bunch of stuff (with a surprise) and hope for the best. This is probably the most common and easiest of the ESM to CJS & ESM options, but you pay for it. This is rarely a good idea.

在软件包配置方面,有几个选项主要在个人偏好上有所不同。

¥In terms of package configuration, there are a few options that differ mostly in personal preference.

将整个包标记为 ESM,并通过 .cjs 文件扩展名将 CJS 导出特别标记为 CJS

¥Mark the whole package as ESM and specifically mark the CJS exports as CJS via the .cjs file extension

此选项对开发/开发者体验的负担最小。

¥This option has the least burden on development/developer experience.

这也意味着无论使用哪种构建工具,都必须生成具有 .cjs 文件扩展名的分发文件。这可能需要链接多个构建工具或添加后续步骤以移动/重命名文件以具有 .cjs 文件扩展名(例如 mv ./dist/index.js ./dist/index.cjs)。可以通过添加后续步骤来移动/重命名这些输出文件(例如 Rollup一个简单的 shell 脚本)来解决此问题。

¥This also means that whatever build tooling must produce the distribution file with a .cjs file extension. This might necessitate chaining multiple build tools or adding a subsequent step to move/rename the file to have the .cjs file extension (ex mv ./dist/index.js ./dist/index.cjs). This can be worked around by adding a subsequent step to move/rename those outputted files (ex Rollup or a simple shell script).

12.0.0 中添加了对 .cjs 文件扩展名的支持,使用它将导致 ESM 正确识别文件为 commonjs(import { foo } from './foo.cjs' 有效)。但是,require() 不会像对 .js 那样自动解析 .cjs,因此不能省略文件扩展名,这在 commonjs 中很常见:require('./foo') 将失败,但 require('./foo.cjs') 可以工作。在包的导出中使用它没有任何缺点:无论如何,packageJson.exports(和 packageJson.main)都需要文件扩展名,并且消费者通过 package.json 的 "name" 字段引用你的包(因此他们完全不知道)。

¥Support for the .cjs file extension was added in 12.0.0, and using it will cause ESM to properly recognised a file as commonjs (import { foo } from './foo.cjs' works). However, require() does not auto-resolve .cjs like it does for .js, so file extension cannot be omitted as is commonplace in commonjs: require('./foo') will fail, but require('./foo.cjs') works. Using it in your package's exports has no drawbacks: packageJson.exports (and packageJson.main) requires a file extension regardless, and consumers reference your package by the "name" field of your package.json (so they're blissfully unaware).

工作示例:esm-with-dual-distro

¥Working example: esm-with-dual-distro

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

或者,你可以使用 "default""node" 键,它们不那么违反直觉:Node.js 将始终选择 "node" 选项(始终有效),而非 Node.js 工具在配置为针对节点以外的目标时将选择 "default"。这排除了双包危险。

¥Alternatively, you can use "default" and "node" keys, which are less counter-intuitive: Node.js will always choose the "node" option (which always works), and non-Node.js tooling will choose "default" when configured to target something other than node. This precludes the dual-package hazard.

{
  "type": "module",
  "exports": {
    ".": {
      "node": "./dist/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

💡 使用 "type": "module"2.cjs 文件扩展名(用于 commonjs 文件)配对可获得最佳效果。有关原因的更多信息,请参阅下面的 进入兔子洞陷阱

¥💡 Using "type": "module"2 paired with the .cjs file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.

对所有源代码文件使用 .mjs(或等效)文件扩展名

¥Use the .mjs (or equivalent) file extension for all source code files

此配置与 CJS 源和 CJS 与 ESM 分发 相同。

¥The configuration for this is the same as CJS source and both CJS & ESM distribution.

非 JavaScript 源代码:非 JavaScript 语言自己的配置需要识别/指定输入文件是 ESM。

¥Non-JavaScript source code: The non-JavaScript language’s own configuration needs to recognise/specify that the input files are ESM.

12.22.x 之前的 Node.js

¥Node.js before 12.22.x

🛑 你不应该这样做:12.x 之前的 Node.js 版本已终止使用,现在容易受到严重的安全漏洞的攻击。

¥🛑 You should not do this: Versions of Node.js prior to 12.x are End of Life and are now vulnerable to serious security exploits.

如果你是一名安全研究人员,需要在 v12.22.x 之前研究 Node.js,请随时联系我们以获取配置帮助。

¥If you're a security researcher needing to investigate Node.js prior to v12.22.x, feel free to contact us for help configuring.

一般说明

¥General notes

语法检测 不能替代正确的包配置;语法检测并非万无一失,它有 显著的性能成本

¥Syntax detection is not a replacement for proper package configuration; syntax detection is not fool-proof and it has significant performance cost.

当在 package.json 中使用 "exports" 时,通常最好包含 "./package.json": "./package.json" 以便可以导入它(module.findPackageJSON 不受此限制的影响,但 import 可能更方便)。

¥When using "exports" in package.json, it is generally a good idea to include "./package.json": "./package.json" so that it can be imported (module.findPackageJSON is not affected by this limitation, but import may be more convenient).

建议使用 "exports" 而不是 "main",因为它可以防止外部访问内部代码(因此你可以相对确定用户不会依赖他们不应该依赖的东西)。如果你不需要它,"main" 更简单,可能是你的更好选择。

¥"exports" can be advisable over "main" because it prevents external access to internal code (so you can be relatively sure users are not depending on things they shouldn't). If you don't need that, "main" is simpler and may be a better option for you.

"engines" 字段提供了人性化和机器友好的指示,表明该包兼容哪个版本的 Node.js。根据所使用的软件包管理器,当消费者使用不兼容版本的 Node.js 时,可能会引发异常导致安装失败(这对消费者非常有帮助)。包含此字段将​​为使用旧版本 Node.js 且无法使用该包的消费者省去很多麻烦。

¥The "engines" field provides both a human-friendly and a machine-friendly indication of which version(s) of Node.js the package is compatible. Depending on the package manager used, an exception may be thrown causing the installation to fail when the consumer is using an incompatible version of Node.js (which can be very helpful to consumers). Including this field will save a lot of headache for consumers with an older version of Node.js who cannot use the package.

进入兔子洞

¥Down the rabbit-hole

具体来说,对于 Node.js,有 4 个问题需要解决:

¥Specifically in relation to Node.js, there are 4 problems to solve:

  • 确定源代码文件的格式(作者运行她/他自己的代码)

    ¥Determining format of source code files (author running her/his own code)

  • 确定分发文件的格式(代码消费者将收到)

    ¥Determining format of distribution files (code consumers will receive)

  • 当它是 require() 时发布分发代码(消费者期望 CJS)

    ¥Publicising distribution code for when it is require()’d (consumer expects CJS)

  • 当它是 import 时发布分发代码(消费者可能想要 ESM)

    ¥Publicising distribution code for when it is import’d (consumer probably wants ESM)

⚠️ 前 2 个与后 2 个无关。

¥⚠️ The first 2 are independent of the last 2.

加载方法不决定文件解释为哪种格式:

¥The method of loading does NOT determine the format the file is interpreted as:

  • package.json 的 exports.requireCJSrequire() 不会也不能盲目地将文件解释为 CJS;例如,require('foo.json') 正确地将文件解释为 JSON,而不是 CJS。包含 require() 调用的模块当然必须是 CJS,但它加载的内容不一定也是 CJS。

    ¥package.json’s exports.require CJS. require() does NOT and cannot blindly interpret the file as CJS; for instance, require('foo.json') correctly interprets the file as JSON, not CJS. The module containing the require() call of course must be CJS, but what it is loading is not necessarily also CJS.

  • package.json 的 exports.importESMimport 同样不会也不能盲目地将文件解释为 ESM;import 可以加载 CJS、JSON 和 WASM,以及 ESM。包含 import 语句的模块当然必须是 ESM,但它加载的内容不一定也是 ESM。

    ¥package.json’s exports.import ESM. import similarly does NOT and cannot blindly interpret the file as ESM; import can load CJS, JSON, and WASM, as well as ESM. The module containing the import statement of course must be ESM, but what it is loading is not necessarily also ESM.

因此,当你看到配置选项引用或以 requireimport 命名时,请不要认为它们是用于确定 CJS 与 ES 模块的冲动。

¥So when you see configuration options citing or named with require or import, resist the urge to assume they are for determining CJS vs ES Modules.

⚠️ 将 "exports" 字段/字段集添加到包的配置中,对于未在导出的子路径中明确列出的任何内容,实际上 阻止深入到包中。这意味着它可能是一个重大变化。

¥⚠️ Adding an "exports" field/field-set to a package’s configuration effectively blocks deep pathing into the package for anything not explicitly listed in the exports’ subpathing. This means it can be a breaking change.

⚠️ 请仔细考虑是否要同时分发 CJS 和 ESM:它为 双重包危险 创造了潜力(尤其是在配置错误且消费者试图变得聪明的情况下)。这可能会导致使用项目中出现极其令人困惑的错误,尤其是当你的包配置不完美时。消费者甚至可能会被使用你软件包的 "other" 格式的中间软件包所蒙蔽(例如,消费者使用 ESM 发行版,而消费者自己也在使用的其他软件包使用 CJS 发行版)。如果你的软件包以任何方式有状态,则同时使用 CJS 和 ESM 发行版将导致并行状态(这几乎肯定是无意的)。

¥⚠️ Consider carefully whether to distribute both CJS and ESM: It creates the potential for the Dual Package Hazard (especially if misconfigured and the consumer tries to get clever). This can lead to an extremely confusing bug in consuming projects, especially when your package is not perfectly configured. Consumers can even be blind-sided by an intermediary package that uses the "other" format of your package (eg consumer uses the ESM distribution, and some other package the consumer is also using itself uses the CJS distribution). If your package is in any way stateful, consuming both the CJS and ESM distributions will result in parallel states (which is almost surely unintentional).

双包危害

¥The dual-package hazard

当应用使用同时提供 CommonJS 和 ES 模块源的包时,如果加载了包的两个实例,则存在某些错误的风险。这种潜力来自于这样一个事实:const pkgInstance = require('pkg') 创建的 pkgInstanceimport pkgInstance from 'pkg'(或 'pkg/module' 等替代主路径)创建的 pkgInstance 不同。这是“双包危险”,其中同一包的两个实例可以在同一运行时环境中加载。虽然应用或包不太可能故意直接加载两个实例,但应用加载一个副本而应用的依赖加载另一个副本是很常见的。这种危险可能发生,因为 Node.js 支持混合 CommonJS 和 ES 模块,并可能导致意外和令人困惑的行为。

¥When an application is using a package that provides both CommonJS and ES module sources, there is a risk of certain bugs if both instances of the package get loaded. This potential comes from the fact that the pkgInstance created by const pkgInstance = require('pkg') is not the same as the pkgInstance created by import pkgInstance from 'pkg' (or an alternative main path like 'pkg/module'). This is the “dual package hazard”, where two instances of the same package can be loaded within the same runtime environment. While it is unlikely that an application or package would intentionally load both instances directly, it is common for an application to load one copy while a dependency of the application loads the other copy. This hazard can happen because Node.js supports intermixing CommonJS and ES modules, and can lead to unexpected and confusing behavior.

如果包主导出是构造函数,则对两个副本创建的实例进行 instanceof 比较将返回 false,如果导出是对象,则添加到一个(如 pkgInstance.foo = 3)的属性不会出现在另一个上。这与 importrequire 语句在全 CommonJS 或全 ES 模块环境中的工作方式不同,因此让用户感到惊讶。它也不同于用户在使用 Babelesm 等工具进行转译时所熟悉的行为。

¥If the package main export is a constructor, an instanceof comparison of instances created by the two copies returns false, and if the export is an object, properties added to one (like pkgInstance.foo = 3) are not present on the other. This differs from how import and require statements work in all-CommonJS or all-ES module environments, respectively, and therefore is surprising to users. It also differs from the behavior users are familiar with when using transpilation via tools like Babel or esm.

我们是如何到达这里的

¥How did we get here

CommonJS (CJS) 是在 ECMAScript 模块 (ESM) 之前很久创建的,当时 JavaScript 还处于青春期 - CJS 和 jQuery 的创建时间仅相隔 3 年。CJS 不是官方(TC39)标准,并且受少数几个平台支持(最值得注意的是 Node.js)。ESM 作为标准已经存在好几年了;它目前受所有主要平台(浏览器、Deno、Node.js 等)支持,这意味着它几乎可以在任何地方运行。随着 ESM 显然将有效地取代 CJS(它仍然非常流行和广泛),许多人试图尽早采用,通常是在 ESM 规范的某个特定方面最终确定之前。因此,随着更好的信息的出现(通常是通过那些热切的人们的学习/经验得知的),这些随着时间的推移而发生了变化,从最佳猜测到与规范保持一致。

¥CommonJS (CJS) was created long before ECMAScript Modules (ESM), back when JavaScript was still adolescent—CJS and jQuery were created just 3 years apart. CJS is not an official (TC39) standard and is supported by a limited few platforms (most notably, Node.js). ESM as a standard has been incoming for several years; it is currently supported by all major platforms (browsers, Deno, Node.js, etc), meaning it will run pretty much everywhere. As it became clear ESM would effectively succeed CJS (which is still very popular and widespread), many attempted to adopt early on, often before a particular aspect of the ESM specification was finalised. Because of this, those changed over time as better information became available (often informed by learnings/experiences of those eager beavers), going from best-guess to the aligning with the specification.

另一个复杂因素是打包器,它过去管理着这方面的大部分工作。但是,我们以前需要 bundle(r)s 来管理的大部分内容现在是原生功能;然而,对于某些事情来说,打包器仍然(并且可能永远是)必要的。不幸的是,打包器不再需要提供的功能深深植根于旧打包器的实现中,因此它们有时可能过于有用,而在某些情况下则是反模式的(打包器作者自己通常不建议打包库)。其方式和原因本身就是一篇文章。

¥An additional complication is bundlers, which historically managed much of this territory. However, much of what we previously needed bundle(r)s to manage is now native functionality; yet bundlers are still (and likely always will be) necessary for some things. Unfortunately, functionality bundlers no-longer need to provide is deeply ingrained in older bundlers’ implementations, so they can at times be too helpful, and in some cases, anti-pattern (bundling a library is often not recommended by bundler authors themselves). The hows and whys of that are an article unto itself.

陷阱

¥Gotchas

package.json"type" 字段将 .js 文件扩展名更改为分别表示 commonjs 或 ES module。在双重/混合包(包含 CJS 和 ESM)中,错误使用此字段的情况非常常见。

¥The package.json's "type" field changes the .js file extension to mean either commonjs or ES module respectively. It is very common in dual/mixed packages (that contain both CJS and ESM) to use this field incorrectly.

{
  "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)。

¥This does not work because "type": "module" causes packageJson.main, packageJson.exports["."].require, and packageJson.exports["."].default to get interpreted as ESM (but they’re actually CJS).

排除 "type": "module" 会产生相反的问题:

¥Excluding "type": "module" produces the opposite problem:

{
  "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)。

¥This does not work because packageJson.exports["."].import will get interpreted as CJS (but it’s actually ESM).

Footnotes

  1. Node.js v13.0–13.6 中存在一个错误,其中 packageJson.exports["."] 必须是一个数组,其中详细配置选项作为第一项(作为对象),“默认”作为第二项(作为字符串)。请参阅 nodejs/modules#446

    ¥There was a bug in Node.js v13.0–13.6 where packageJson.exports["."] had to be an array with verbose config options as the first item (as an object) and the “default” as the second item (as a string). See nodejs/modules#446. 2

  2. package.json 中的 "type" 字段更改了 .js 文件扩展名的含义,类似于 HTML 脚本元素的类型属性

    ¥The "type" field in package.json changes what the .js file extension means, similar to to an HTML script element’s type attribute. 2 3 4 5 6 7 8