双 CommonJS/ES 模块包(Dual CommonJS/ES module packages)

264 阅读5分钟

在 Node.js 引入 ES 模块支持之前,包作者通常会在包中同时包含 CommonJS 和 ES 模块的 JavaScript 源,包中的 package.json"main" 字段指定 CommonJS 入口点,"module" 字段指定 ES 模块入口点。

比如 zustand 1.0.0

zustand 1.0.0's package.json snapshot

这样,Node.js 就能运行 CommonJS 入口点,而打包工具(webpackRollupParcel 等)则使用 ES 模块入口点,因为 Node.js 会忽略(现在仍然忽略) package.json"module" 字段。

至于为什么包要提供 ES 模块的 JavaScript 源,可以参考:Why are ES modules better than CommonJS Modules?

双模块包风险(Dual package hazard

当应用程序使用同时提供 CommonJS 和 ES 模块源的包时,如果同时加载两个版本的软件包,就有可能出现某些错误。这种风险来自于这样一个事实,即通过 const pkgInstance = require('pkg') 创建的 pkgInstance 与通过 import pkgInstance from 'pkg' 创建的 pkgInstance 并不相同。这就是 "dual package hazard",即在同一运行环境中可以加载同一包的两个版本。

比如 zustand 1.0.0,通过 const create = require("zustand") 导入的 create 来自 index.cjs.js 模块,而通过 import create from "zustand" 导入的 create 来自 index.js 模块。

虽然应用程序或包不太可能故意直接加载这两个版本,但应用程序加载一个版本而应用程序的依赖加载另一个版本的情况却很常见。

例如,应用程序中直接使用 utilities 包和 utilities-plus 包。而 utilities-plus 包为 utilities 增加了一些功能,其依赖 utilities 包。应用程序使用 ES 模块,utilities-plus 包只提供 CommonJS 模块源, utilities 包同时提供 CommonJS 和 ES 模块源。这种情况下,应用程序加载 utilities 包的 ES 模块版本,而 utilities-plus 包加载 utilities 包的 CommonJS 模块版本。

由于 Node.js 16 及以上支持将 CommonJS 和 ES 模块混合使用,因此可能会发生这种情况,并可能导致意想不到的行为。

如果包导出一个构造函数,则两个版本创建的实例的 instanceof 比较会返回 false;如果导出是一个对象,则添加到其中一个版本的属性(如 pkgInstance.foo = 3)不会出现在另一个版本中。

如何避免或减少风险

方法 1:使用 ES 模块包一层

  1. 源代码使用 CommonJS 模块或源代码使用 ES 模块,将源代码转译为 CommonJS 模块。

  2. 创建一个 ES 模块文件包一层,来定义已命名的导出。

参考如下示例:

// ./node_modules/pkg/index.cjs
exports.name = { firstName: "", lastName: "" };
// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;

在本例中,import { name } from 'pkg' 中的 nameconst { name } = require('pkg') 中的 name 是同一个单例,均来自 index.cjs 模块。因此,在比较这两个 name 时,=== 返回 true,从而避免了指定符不一致的风险。

// ./node_modules/pkg/package.json
{
  "type": "module",
  "exports": {
    "import": "./wrapper.mjs",
    "require": "./index.cjs"
  }
}

前面的示例使用了显式扩展名 .mjs.cjs。如果你的文件使用 .js 扩展名,"type":"module" 会导致此类文件视为 ES 模块,正如 "type":"commonjs" 会导致其视为 CommonJS 模块。参考 Enabling

缺点:

  • 包只是表面上的 ESM

  • ......

方法 2:隔离状态

package.json 文件可以直接定义单独的 CommonJS 和 ES 模块入口点:

// ./node_modules/pkg/package.json
{
  "type": "module",
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

如果软件包的 CommonJS 模块版本和 ES 模块版本是等价的,例如其中一个是另一个的转译输出,并且包的状态管理被隔离(或者包是无状态的),就可以这样做。

状态之所以会成为问题,是因为在应用程序中可能会同时使用 CommonJS 和 ES 模块版本的包;例如,用户的应用程序代码可能会导入 ES 模块版本,而依赖则需要 CommonJS 版本。如果出现这种情况,内存中将加载包的两个副本,因此会出现两种不同的状态。这很可能会导致难以解决的错误。

除了编写无状态包(例如,如果 JavaScript 的 Math 是一个包,那么它就是无状态的,因为它的所有方法都是静态的),还有一些方法可以隔离状态,使其在可能加载的 CommonJS 和 ES 模块实例之间共享:

  1. 如果可能,将所有状态都包含在一个实例化对象中。例如,JavaScript 的 Date 需要实例化才能包含状态;如果它是一个包,就可以这样使用:
import Date from 'date';
const someDate = new Date();
// someDate 包含状态; Date 没有

new 关键字不是必需的;包的函数可以返回一个新对象,或修改一个传入的对象,以保持状态在包的外部。

  1. 将状态隔离在包的 CommonJS 和 ES 模块版本之间共享的一个或多个 CommonJS 文件中。例如,如果 CommonJS 和 ES 模块的入口点分别是 index.cjsindex.mjs,那么就可以将状态隔离在共享的 state.cjs 中:
// ./node_modules/pkg/index.cjs
const state = require('./state.cjs');
module.exports.state = state;
// ./node_modules/pkg/index.mjs
import state from './state.cjs';
export {
  state,
};

即使在应用程序中同时通过 requireimport 使用 pkgpkg 的每个引用都将包含相同的状态;从任一模块系统修改该状态都将适用于这两个系统。

缺点:

  • 即使是隔离状态(或者包是无状态的),但仍然可能出现重复代码被执行/打包的情况。

  • 包大小会增大

  • ......

纯 ES 模块包(Pure ESM package)

纯 ES 模块包只提供 ES 模块源。

  • 其无法在 CommonJS 项目中使用。

  • 如果项目的运行时环境是 Node.js,须 Node.js 版本 >= 16 才可使用。

建议转用 ES 模块。ES 模块仍可导入 CommonJS 模块包,但 CommonJS 模块包不能同步地导入 ES 模块包。

总结

本文介绍了 Dual CommonJS/ES module packages 是什么以及如何避免或减少其带来的风险。

参考