跳到内容

发布软件包

🌐 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 time1, 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 使用(在所有版本的 Node 中)。跳转到 CJS 源码和分发
  • 编写源代码并以 ESM 发布(你使用 import,并且不要在顶层使用 await);ESM 可以被 ESM 和 CJS 消费(在 Node 22.x 和 23.x 中;参见 require() 一个 ES 模块)。跳到 ESM 源代码和分发

通常最好只发布一种格式,要么是 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 的分发版本
ESM 源代码使用 importCJS:消费者使用 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

{
  "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 配置完全相同,只有一个小区别:"type" 字段。

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

示例: 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

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

为了直接向两类观众提供服务(使你的分发在任意一方都能“本地化”运作),你有几种选择:

🌐 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 (属性)

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

优点:

🌐 Pros:

  • 较小的封装重量
  • 简单容易(如果你不介意遵守一个小的语法规定的话,可能是最省力的)
  • 排除【双封装危险】(#the-dual-package-hazard)

缺点:

🌐 Cons:

  • 需要非常特定的语法(无论是在源代码中还是在打包工具的操作中)。

有时,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  = {
  () {},
  () {},
  () {},
};

. = ;

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:

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

使用一个简单的 ESM 封装器

🌐 Use a simple ESM wrapper

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

🌐 Complicated setup and difficult to get the balance right.

工作示例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"
    }
  }
}

优点:

🌐 Pros:

  • 较小的封装重量

缺点:

🌐 Cons:

  • 可能需要复杂的打包操作(我们找不到任何现有的方法在 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  from '../cjs/index.js';

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

export { , ,  /* … */ };

然而,这会破坏实时绑定:对 cjs.a 的重新赋值不会反映在 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)

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

优点:

🌐 Pros:

  • 简单的打包器配置

缺点:

🌐 Cons:

或者,你可以使用 "default""node" 键,这样更直观:Node.js 总是会选择 "node" 选项(总是可用的),而非 Node.js 的工具在配置为目标非 Node 环境时会选择 "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.

这些配置(有两种选项)几乎与 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 文件)可以获得最佳效果。关于原因的更多信息,请参见以下的 下兔子洞注意事项

示例: 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 为目标时可以开箱即用地生成兼容输出。Webpack 自 v5.66.0+ 版本起,使用新的 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 文件使用 .js 扩展名;package.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

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

💡 使用 "type": "module"2 配合 .cjs 文件扩展名(用于 commonjs 文件)可以获得最佳效果。关于原因的更多信息,请参见以下的 下兔子洞注意事项

使用 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 源代码和双重分发](#use-a-simple-esm-wrapper)完全相同,但在 package.json 中存在细微差别,比如“type”: “module”“和一些 ”.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(封装器)

{
  "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 版本

🌐 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

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

或者,你可以使用 "default""node" 键,这样更直观:Node.js 总是会选择 "node" 选项(总是可用的),而非 Node.js 的工具在配置为目标非 Node 环境时会选择 "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 文件)可以获得最佳效果。关于原因的更多信息,请参见以下的 下兔子洞注意事项

对所有源代码文件使用 .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。

Node.js 早于 12.22.x 版本

🌐 Node.js before 12.22.x

🛑 你不应该这样做:12.x 之前的 Node.js 版本已停止维护,现在存在严重的安全漏洞风险。

如果你是一名安全研究人员,需要在 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

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

在 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" 更简单,可能是更好的选择。

"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:

  • 确定源代码文件的格式(作者运行自己的代码)
  • 确定分发文件的格式(代码使用者将会收到的)
  • 公开分发代码以供 require() 时使用(消费者期望 CJS)
  • 公开分发代码以便在被 import 时使用(消费者可能想要 ESM)

⚠️ 前两个与后两个是独立的

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

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

  • 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 模块的。

🌐 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" 字段/字段集,实际上会阻止对包中未在 exports 子路径中明确列出的内容进行深层访问。这意味着它可能会引入破坏性更改。

⚠️ 仔细考虑是否同时分发 CJS 和 ESM:这可能导致双重包危险(尤其是在配置不当且使用者尝试“聪明操作”时)。这可能在使用你的包的项目中引发极其混乱的错误,尤其是当你的包配置不完美时。使用者甚至可能会被中间包所误导,该中间包使用了你包的“另一种”格式(例如,使用者使用 ESM 分发,而使用者同时还在使用的其他包则使用了 CJS 分发)。如果你的包以任何方式是有状态的,同时使用 CJS 和 ESM 分发将导致并行状态(几乎肯定不是你的本意)。

双封装危害

🌐 The dual-package hazard

当一个应用使用的包同时提供 CommonJS 和 ES 模块源时,如果两个实例的包都被加载,就有可能出现某些 bug。这个潜在问题来自于 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.

如果 package main 的导出是一个构造函数,那么由两个副本创建的实例之间的 instanceof 比较会返回 false;如果导出的是一个对象,则添加到其中一个对象的属性(例如 pkgInstance.foo = 3)不会出现在另一个对象上。这与在全 CommonJS 或全 ES 模块环境中 importrequire 语句的工作方式不同,因此会让用户感到惊讶。它也不同于用户通过使用像 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(尽管 CJS 依然非常流行且使用广泛)的趋势变得明显,许多人试图早期采用,往往是在 ESM 规范的某些具体方面尚未最终确定之前。因此,随着更好的信息的出现,这些做法会随着时间而变化(通常借鉴了那些积极尝试者的经验教训),从最初的最佳猜测逐渐与规范保持一致。

另一个复杂因素是打包工具,它们在历史上管理了这一字段的大部分内容。然而,以前我们需要打包工具管理的许多功能现在已成为原生功能;然而,打包工具在某些方面仍然(并且可能永远)是必要的。不幸的是,打包工具不再需要提供的功能深深植根于旧打包工具的实现中,因此它们有时可能过于“热心”,在某些情况下甚至是反模式(打包一个库通常不被打包工具的作者推荐)。关于其原因和方式本身就是一篇文章的内容。

🌐 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.main”, “packageJson.exports[”.“]。require',以及 'packageJson.exports[“.”]。默认“被误解为 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. 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. 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