本篇指南并不是"从 0 到 1 教你如何创建并发布一个 npm 包"的教程,也没有介绍任何三方打包工具的使用,而是仅仅从 Node.js 包编写者的角度,学习和理解 Node.js 运行时行为。
这个指南一共 2 篇,这是第 2 篇。
- #1:模块系统与入口文件
- #2:互操作性、双模块包、以及运行时字段(本篇)
回顾
在上一篇文章中,我们介绍了包的基本概念,又介绍了 Node.js 支持两种模块系统(CommonJS 和 ES 模块)以及 "exports"
------这一替代 "main"
字段------的使用。本文,我们将继续深入了解,两种模块系统之间的互操作性、双模块包如何编写并介绍 Node.js 所能识别的为数不多的 package.json
文件中的几个字段。
互操作性
从 Node.js 开始支持 ES 模块开始,为了达到从 CommonJS 生态到 ES 模块生态的过渡, Node.js 支持在一定限制上的 CommonJS 和 ES 模块之间的互操作。
两个模块系统之间的互操作性的规则挺复杂的,我们也不打算在这里详细阐述。只介绍了 2 个简单、常见的互操作规则:
首先,CommonJS 中是无法引入 ES 模块的。
javascript
// in CJS
const pkg = require('esm-only-package')
// Error [ERR_REQUIRE_ESM]: require() of ES 模块esm-only-package not supported.
另一方面,在 ES 模块文件中,引入 CommonJS 则是可以的,不过不支持命名导入。
javascript
// in ESM
import { named } from 'esm-package'
import cjs from 'cjs-package'
双模块包
所谓的"双模块包(Dual module packages)",就是指一个包同时提供了支持 CommonJS 和 ES 模块版本,它们的功能相同,但是为不同环境准备的,这在 Node.js 中引入对 ES 模块的支持之前,是一种常见模式。
在 Node.js 中引入对 ES 模块的支持之前,双模块包通常这样提供:通过 package.json 文件的 "main"
字段指定 CommonJS 版本入口,通过 "module"
字段指定 ES 模块版本入口。如此一来,Node.js 使用包的 CommonJS 版本,而像 webpack、Rollup 这类打包工具则会使用 ES 模块版本。
需要注意的是,Node.js 并不识别 package.json 文件
"module"
字段,这是打包工具引入并使用的字段。
这是以前的做法。现在 Node.js 已经全面支持 ES 模块了。那么就可以通过条件导出或分别指定入口(比如:'pkg'
和 'pkg/es-module'
)这种原生方式,为你的包同时指定 CommonJS 和 ES 模块版本支持。
双包风险风险
Node.js 对 CommonJS 和 ES 模块语法混用的支持,会导致潜在的风险出现,这种风险来源于你的使用方同时使用了你一个包的两个版本。
举 2 个例子。
例子 1:const pkgInstance = require('pkg')
创建的 pkgInstance
与 import pkgInstance from 'pkg'
创建的 pkgInstance
其实是不一样的。
例子 2:如果包导出是一个构造函数,那么使用 instanceof
来比较两个版本创建的实例时,返回的是 false
(而非 true
);如果导出是对象,那么添加到其中一个对象上的属性(例如:pkgInstance.foo = 3
) 在另一个对象并不可见,因为是不同的对象。
以上的状况,就是所谓的"双包风险(dual package hazard)",即同一包的两个版本在同一运行时环境中被加载了。
虽然你的项目或包不太可能有故意加载同一个包的两个版本,但你项目中的一个依赖可能会加载同一个包的另一个版本。这是 Node.js 混合 CommonJS 和 ES 模块后的一个代价。这与 import
和 require()
语句分别在纯 CommonJS 或全 ES 模块环境中的工作方式是不同的,这一点要注意。
当然,我们也是有办法去尽量避免和减少这部分的危险的。下面就来介绍。
如何安全地编写
目前,我们有 2 种方案来尽可能避免双包风险。当然在避免问题的同时,也会带来一定程度的编写和优化限制。
- 方案一:采用 ES 模块包装文件(
wrapper.mjs
) - 方案二:对包进行状态隔离
方案一:使用 ES 模块包装文件
这种方法适应于现有基于 CommonJS 格式编写的包,同时又希望给外部介入方提供 ES 模块导入支持。
我们通过一个 ES Module 包装文件,导入 CommonJS 入口并重新导出,为我们的包提供 ES 模块导出支持。这里会用到条件导出(conditional exports)技术,"import"
字段指向 ES 模块入口文件,"require"
字段指向 CommonJS 入口文件。
json
// ./node_modules/pkg/package.json
{
type": "module",
"exports": {
"import": "./wrapper.mjs",
"require": "./index.cjs"
}
}
注意,package.json
文件中,我们显式声明了 "type": "module"
,表示项目中所有的 .js
文件都会被视为 ES 模块文件,这是现在创建 npm 包的推荐做法。不过为了便于看清,我们使用了 .cjs
、.mjs
这些后缀做突出说明。
javascript
// ./node_modules/pkg/index.cjs
exports.name = 'value';
javascript
// ./node_modules/pkg/wrapper.mjs
import cjsModule from './index.cjs';
export const name = cjsModule.name;
这样做的结果是,import { name } from 'pkg'
中的 name
与 const { name } = require('pkg')
中的 name
是同一个单例。当对两个 name
比较时, ===
返回的是 true
,这样就避免了双包危险。
当然,如果 cjsModule
本身作为默认导出(比如:一个函数,类似 module.exports = function () { ... }
),那么 ES 包装文件也要提供类似的默认导出。
javascript
import cjsModule from './index.cjs';
export const name = cjsModule.name;
export default cjsModule;
这种方法的一种变体,是通过添加子路径增加 ES 模块支持。
json
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
".": "./index.cjs",
"./module": "./wrapper.mjs"
}
}
如此,用户通过 import 'pkg/module'
引入的始终都是 ES 包装文件。
ES 模块包装文件的方式可以解放你的上层依赖(应用程序或是另一个 npm 包)所使用的模块语法,确保你的上层依赖既可以使用 CommonJS,也可以使用 ES 模块组织代码。你也不需要把你的 CommJS 包完全重写,由于 ES 包装文件和 CommonJS 入口文件底层都是运行的同一份 CommonJS 代码,也不会出现双包问题了。
方案二:对包进行状态隔离
这种方法适应于基于 ES 模块格式编写的包,同时又希望给外部介入方提供 CommonJS 导入支持。
与"使用 ES 模块包装文件"所使用的方法一样,通过在 package.json
文件,分别为 CommonJS 和 ES 模块单独定义入口实现。不同的是,"import"
字段不再是对应 CommonJS 的包装文件,而是我们的源文件。
json
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
当然,这仍会导致双包问题出现,那么如何避免呢?
在谈到避免出现双包问题之前,我们先讨论代码功能一致性问题。index.cjs
并不需要再单独编写了,我们可以直接通过转译工具(例如:esbuild、rollup、tsup、tsc、babel、swc 等)从 index.mjs
转换得到。这也是目前的流行做法。
那么双包问题如何避免呢?
双包问题本质上是指包在运行的过程当中内部存在一些内部状态,由于包的两个副本被意外加载到内存中,因此出现两个单独且隔离的状态,最终导致难以排除的 BUG。
那么一个解决方案就是编写无状态包。
所谓无状态包,就是不存在内部状态的包。比如 JavaScript 的 Math
API 就是一个无状态包,因为它的所有方法都是静态的。
另一个方法,是将原本包内的状态暴露到外部,举一个例子。
javascript
import Date from 'date';
const someDate = new Date();
// someDate contains state; Date does not
date
包没有暴露日期实例,而是暴露了构建函数。实例化发生在接入方一侧,就规避了在包内保存状态的问题。
当然你会说,那我的包里就是会有一些状态怎么办?这个时候还有一个办法就是将共享的内部状态,统一封装在一个 CommonJS 的文件当中。
javascript
// ./node_modules/pkg/index.cjs
const state = require('./state.cjs');
module.exports.state = state;
javascript
// ./node_modules/pkg/index.mjs
import state from './state.cjs';
export {
state,
};
这样不管你的 pkg
在应用程序中是通过通过 require()
或是 import
使用的,引入的都是同一份状态实例。
这种方法的一种变体,是通过添加子路径增加 ES 模块支持。
javascript
// ./node_modules/pkg/package.json
{
"type": "module",
"exports": {
".": "./index.cjs",
"./module": "./index.mjs"
}
}
如此,接入方通过 import 'pkg/module'
引入的始终都是 ES 包装文件。
ES 模块包装文件的方式可以解放你的上层依赖(应用程序或是另一个 npm 包)所使用的模块语法,确保你的上层依赖既可以使用 CommonJS,也可以使用 ES 模块组织代码。
虽然可以通过 CommonJS 文件共享内部状态,但也无可避免增加了运行开销(两个版本)。
运行时字段
平时我们的项目的 package.json
文件会特别长,但其实 Node.js 运行时使用的字段非常少。究其原因,就是很多前端开发阶段的工具(比如:bundler、linter 等)、发布工具(npm、github cli、CDN 服务)等都对 package.json 文件的字段做了扩展。
Node.js 运行时使用的字段有多少呢?一共就 6 个,少到我们可以一个个介绍,分别是:
"name"
"main"
"packageManager"
"type"
"exports"
"imports"
"name"
用于定义包名。另外通过 "exports
" 暴露的出口时,在包内文件中还可以通过这个 name
进行自引用(Self-referencing)。
json
// package.json
{
"name": "a-package",
"exports": {
".": "./index.mjs",
"./foo.js": "./foo.js"
}
}
javascript
// SUCCESS! ./a-package/a-module.mjs
import { something } from 'a-package'; // Imports "something" from ./index.mjs.
javascript
// ERROR! ./a-packageanother-module.mjs
// Imports "another" from ./m.mjs. Fails because
// the "package.json" "exports" field
// does not provide an export named "./m.mjs".
import { another } from 'a-package/m.mjs';
"main"
指定包被加载时默认使用的入口文件。是 "exports"
字段引入之前使用的字段。当一个包中同时定义 "exports"
和 "main"
字段时,"exports"
优先级更高。
javascript
{
"main": "./index.js"
}
"packageManager"
开发包时所使用的包管理器,被 Corepack shims 使用。目前还处在 Experimental 阶段,v16.9.0、v14.19.0 引入。
json
{
"packageManager": "<package manager name>@<version>"
}
type
决定当前包内,所有 .js 文件默认是 ES Modlule 文件还是 CommonJS 文件。
目前有两个可能取值,"comonjs"
和 "module"
。
如果 package.json
缺少 "type"
字段,或包含 "type": "commonjs"
,那么 .js
文件将被视为 CommonJS。如果 package.json
包含 "type": "module"
,则 .js
文件的 import
语句将被视为 ES 模块。
由于后续 Node.js 默认模块逐渐迁移至 ES 模块,因此官方团队推荐始终在你的 **package.json**
文件中声明 **"type"**
字段。
json
// package.json
{
"type": "module"
}
javascript
# In same folder as preceding package.json
node my-app.js # Runs as ES module
"exports"
"exports"
是 Node.js 12 引入用来替代 "main"
字段的一个方案,支持定义子路径导出和条件导出,同时避免未显式导出的内部模块被外部意外导入。
javascript
{
"exports": "./index.js"
}
还可以在 "exports"
中使用条件导出来定义每个环境的不同包入口点,包括是否通过 require()
还是通过 import 引用包。
javascript
// package.json
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
},
"type": "module"
}
需要注意的是,"exports"
中定义的所有路径必须是以 ./
开头的相对文件 URL。
"imports"
与 "exports"
对应,负责指定包内部文件使用的导出别名,仅限包内文件互相引用(以 #
号开头)。
如此定义:
json
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/*.js": "./src/features/*.js"
},
"imports": {
"#internal/*.js": "./src/internal/*.js"
}
}
这样使用:
javascript
import featureX from 'es-module-package/features/x.js';
// Loads ./node_modules/es-module-package/src/features/x.js
import featureY from 'es-module-package/features/y/y.js';
// Loads ./node_modules/es-module-package/src/features/y/y.js
import internalZ from '#internal/z.js';
// Loads ./node_modules/es-module-package/src/internal/z.js
需要注意的是,"imports"
字段中的条目必须是以 #
开头的字符串。
我们可以把 "imports"
字段看作是一种引用别名,有点类似于 tsconfig.json
中定义的 path
,不过这是 Node.js 原生支持的。
另外,与 "exports"
不同的是,"imports"
允许映射到外部包。
json
// package.json
{
"imports": {
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
},
"dependencies": {
"dep-node-native": "^1.0.0"
}
}
总结
本文我们先简单介绍了 Node.js 中两种模块系统之间的互操作性,并深入了解了双模块包的概念、所面临的双包问题。
然后,就安全编写双模快包提供了两个方案,其中"使用 ES 模块包装文件"是适应于旧 CommonJS 包的方案,而"对包进行状态隔离"则是适应于现代 ES 模块包的方案------在尽可能减少包状态的前提下,从 ES 模块源码使用转译工具导出 CommonJS 副本。
最后,介绍 Node.js 所能识别的为数不多的 package.json
文件中的几个字段。
希望本文所介绍的内容对你有所帮助,感谢阅读,Happy Coding!