本指南并不是"从 0 到 1 教你如何创建并发布一个 npm 包"的教程,也没有介绍任何三方打包工具的使用,而是仅仅从 Node.js 包编写者的角度,学习和理解 Node.js 运行时行为。
这个指南计划发 2 篇,这是第 1 篇。
- #1: 模块系统与入口文件(本篇)
- #2:双模块包、互操作性以及运行时字段(待写)
什么是包?
对 Node.js 来说, 包(Package)就是一个包含 package.json 文件的目录 。package.json 文件使用 JSON 格式描述 Node.js 项目的基本信息。
一个包内还支持包含 node_modules 目录,存放当前包所依赖的其他包。
模块系统
Node.js 支持两种模块系统:CommonJS 和 ES Module。CommonJS 模块语法是在 Node.js 创建之初就支持的,ES Module 则是 JavaScript 官方模块语法,Node.js 也在 v14.17.0/v12.22.0 版本中正式支持了。
启用 ES Module 模块语法
Node.js 中,有 2 种方式将一个文件视为使用 ES Module 文件:
- 以
.mjs后缀的文件。或者 - 在
package.json文件中将type字段显式指定为"module"。如此,当前项目所有的.js文都会被认为是 ES Module 文件
推荐始终在 package.json 中指定 type 字段
package.json 文件中的 type 字段用于定义项目中 .js 文件所使用的模块语法。当 type 字段值为 "module" 时,项目内所有 .js 文件都会被视为 ES Module 文件。
json
// package.json
{
"type": "module"
}
bash
# In same folder as preceding package.json
node my-app.js # Runs as ES module
同理,当将 type 字段值为 "commonjs" 时,项目内所有 .js 文件都会被视为 CommonJS 文件。
bash
// package.json
{
"type": "commonjs"
}
bash
# In same folder as preceding package.json
node my-app.js # Runs as CommonJS
注意:虽然目前 Node.js 会将 .js 默认视为 CommonJS 文件(这是因为 type 字段默认值即 "commonjs"),但未来默认模块语法会迁移至 ES Module(即 type 字段默认值会改为 "module"),对齐 JavaScript 官方模块语法。因此,Node.js 官方团队推荐始终在 package.json 中指定 type 字段。
.mjs & .cjs 文件
对于那些旧版本的 CommonJS 包,为了向后兼容。为了避免修改整个软件包内的文件,也可以通过将文件后缀改成 .cjs来解决。
对 Node.js 来说:
- 不管当前项目的
package.json的type字段值是什么,.cjs都会被视为 CommonJS 文件 - 不管当前项目的
package.json的type字段值是什么,.mjs都会被视为 ES Module 文件
包的入口文件
当你编写的包作为别人项目的三方包加载时,Node.js 是通过包根目录下的 package.json 文件,查看要加载的入口文件。由于历史原因,入口文件可以通过 2 个字符指定。分别是:
"main",和"exports"
"main" 是 Node.js 创建之初就支持的指定入口文件的字段。在 Node.js 没支持 ES Moudle 之前,"main" 字段始终对应项目中的一个 CommonJS 文件;在 Node.js 支持 ES Module 之后(假设,为 package.json 指定 "type": "module"),"main" 指定的就是 ES Module 文件了。
"exports" 字段
"exports" 字段是跟 ES Module 模块语法一起引入的,在 v14.13.0/v12.20.0 版本中就正式支持了。Node.js 引入 "exports"字段是替代 "main" 字段。创建新包时,推荐都使用 "exports"字段:
json
{
"exports": "./index.js"
}
当前,除了 Node.js,现代构建工具都支持 "exports" 字段。而对于仍旧使用旧版本 Node.js 或相关构建工具的项目,可以通过同时指定 "exports" 和 "main" 字段来实现功能兼容。
json
{
"main": "./index.js",
"exports": "./index.js"
}
定义 "exports" 字段后,包的所有子路径都将被封禁,任何引入内部文件的声明都hi报错。为了引用内部文件,需要在 "exports" 字段上设置子路径导出实现;而且,"exports" 还支持 条件导出------为使用 require() 或 import() 的使用方分别指定要引入的文件,后续介绍。
子路径导出和条件导出其实为了弃用文件夹模块功能而准备的。我们先来看看文件夹模块是怎么一回事。
文件夹模块
文件夹模块(Folders as modules)其实是 Node.js 针对加载 CommonJS 文件所推出的一套查找算法。这套算法由于需要一些学习成本,还存在一定性能问题,因此 Node.js 团队在开始开发 ES Module 支持时,就准备弃用这套算法,改用通过扩展 "exports" 字段的方式,提供一套更加通用和简单的加载方式。
不过,由于多年积攒下来的庞大 CommonJS 代码库的存在,这套算法还会在很长一段时间内存在(虽然标记为 Legacy 了)。因此,学习这套文件夹模块的查找算法,对目前工作来说还是很有帮助的。
注意:文件夹模块查找算法只对通过
require()函数 CommonJS 的加载生效,对通过import()ES module 的加载无效。
Node.js 中,支持require() 以下 3 种情况下的文件夹加载:
一种情况,当前文件夹下存在一个 package.json 文件,并且还指定了 "main" 字段。
json
{
"name" : "some-library",
"main" : "./lib/some-library.js"
}
假设,我们现在就位于 some-library 目录下,那么 require('./some-library') 将会尝试加载 ./some-library/lib/some-library.js 文件。
如果当前目录下不存在 package.json 或者 "main" 未指定, 那么 require('./some-library') 将会尝试加载当前目录下的 index.js 或 index.node 文件。
./some-library/index.js./some-library/index.node
如果上述 3 种尝试都失败了,Node.js 就报告错误了。
bash
Error: Cannot find module 'some-library'
当然,针对在上述所有三种情况,import('./some-library') 都会导致出现 ERR_UNSUPPORTED_DIR_IMPORT 错误。
针对 node_modules 文件夹的特殊处理
node_modules 文件夹是一个特殊的文件夹,require() 会对它进行特殊处理。如果传递给 require() 的模块标识符不是核心模块,并且不以 '/'、'../' 或 './' 开头,那么 Node.js 就从当前模块的 node_modules 目录开始查找。如果找不到,就转移到父目录,依此类推,直到到达文件系统的根目录。
例如,我们在 '/home/ry/projects/foo.js' 文件中使用 require('bar.js'),则 Node.js 将按以下顺序查找文件:
/home/ry/projects/node_modules/bar.js/home/ry/node_modules/bar.js/home/node_modules/bar.js/node_modules/bar.js
以上,就是在 Node.js 中 require() 加载文件夹上的策略了。
但 Node.js 是支持双模块系统的,为了得到 require() 和 import() 双重支持,我们需要使用包子路径导出和条件导出功能。
包的子路径导出
使用 "exports" 字段时,还有另外一种子路径(subpath exports)的写法。比如我们上面的写法:
json
{
"exports": "./index.js"
}
其子路径等价写法如下:
json
{
"exports": {
".": "./index.js"
}
}
"." 指定的 key,称为主入口点(main entry point)。
当然,我们还可以定义其他路径,比如:
json
{
"exports": {
".": "./index.js",
"./submodule.js": "./src/submodule.js"
}
}
如此,使用方就可以以下 2 种方式导入我们的包:
javascript
import module from 'es-module-package';
import submodule from 'es-module-package/submodule.js';
就像我们之前说的,定义 "exports" 字段后,包的所有子路径都将被封禁,包括 package.json。
从 "main" 升级到 "exports"
当你将包从 "main" 升级到 "exports" 时,为了不引发破坏性改变,请确保导出每个先前支持的入口点,以便明确定义包的公共 API。
举个例子,如果之前的包导出了 main、lib、feature 和 package.json 的项目,可以使用以下 package.exports 进行平替。
json
{
"name": "my-package",
"exports": {
".": "./lib/index.js",
"./lib": "./lib/index.js",
"./lib/index": "./lib/index.js",
"./lib/index.js": "./lib/index.js",
"./feature": "./feature/index.js",
"./feature/index": "./feature/index.js",
"./feature/index.js": "./feature/index.js",
"./package.json": "./package.json"
}
}
或者,项目可以选择使用导出模式(export patterns)导出带有/不带有扩展子路径的整个文件夹:
json
{
"name": "my-package",
"exports": {
".": "./lib/index.js",
"./lib": "./lib/index.js",
"./lib/*": "./lib/*.js",
"./lib/*.js": "./lib/*.js",
"./feature": "./feature/index.js",
"./feature/*": "./feature/*.js",
"./feature/*.js": "./feature/*.js",
"./package.json": "./package.json"
}
}
这样, main、lib、feature目录下的所有文件都能引用了,不过并不推荐这种方式,还是要尽量减少对外入口的数量。
子路径导出模式
上面我们所提及的"导出模式"是一种类似于 glob pattern 的匹配算法。不过 pattern 中的 * 号不光匹配文件名,还匹配目录分隔符 /。
所以,下面的定义:
json
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/*.js": "./src/features/*.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
包的条件导出
条件导出(conditional exports)提供了一种根据特定条件映射到不同路径的方法。 这里的特定条件,既可以指引入方法(比如是通过 require() 导入还是 import 导入),还可以指执行环境(比如:node 还是其他(default))。
基于引入方法的导出
我们先从定义 require() 还是 import 方法的导出开始讲。例如,我们可以为 require() 和 import 提供不同的模块导出:
javascript
// package.json
{
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
},
"type": "module"
}
"import" 字段定义使用 import 或 import() 方法引入的文件;"require" 字段则用来定义使用require() 加载时引入的文件。
基于环境的文件导出
当然,"exports" 字段还支持基于环境的文件导出判断。
javascript
// package.json
{
"exports": {
".": "./index.js",
"./feature.js": {
"node": "./feature-node.js",
"default": "./feature.js"
}
}
}
当前的这个包,在 Node.js 环境下,require('pkg/feature.js') 和 import 'pkg/feature.js' 加载的会是同一个 feature-node.js 文件;而在 Node.js 环境之外的其他 JS 环境下则会加载 feature.js 文件。
需要注意的时,使用环境判断时,请尽可能包含 "default" 条件------可以确保任何 Node.js 之外的 JS 环境都能有一个通用实现可以使用。
基于引入方法 & 环境的导出
当然,我们还能针对特定环境,为 "import" 和 "require()" 指定加载文件。
json
{
"exports": {
"node": {
"import": "./feature-node.mjs",
"require": "./feature-node.cjs"
},
"default": "./feature.mjs"
}
}
以上,我们针对 Node.js 运行环境,分别对 require() 和 import 引入方式提供导出文件。
"imports":另一种导出语法(仅限包内使用)
Node.js 还可以通过 "imports" 字段支持另外一种导出语法,不过仅限包内文件互相引用(以 # 号开头)。
如此定义:
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" 字段看作是一种引用别名,有点类似于 tsconfig.json 中定义的 path,不过这是 Node.js 原生支持的。
在此不多赘述,有兴趣的同学,可以查看相关文档。
总结
本文首先介绍了 Node.js 中的模块系统。Node.js 里支持 CommonJS 和 ES Module 两种模块系统。有 2 种方法,可以判定一个文件使用的哪种模块语法:
- 通过文件后缀。
.cjs表示 CommonJS 模块文件,.mjs表示 ES Module 模块文件 - 通过
package.json文件的"type"字段。控制项目内.js文件使用的模块语法- 当值为
"module"时,.js文件表示 ES Module 文件 - 当值为
"comonjs"时(默认值,后续会改成"module"),.js文件表示 CommonJS 文件
- 当值为
针对第 2 种方式,Node.js 团队推荐始终声明 "type" 字段,指定 .js 所使用的模块语法。
然后,又介绍了如何定义一个 Node.js 包的导出。
Node.js 提供了 2 种导出方案:"exports" 和 "imports"。前者对应外部导入,后者对应内部导入。我们着重讲解了 "exports"。
"exports" 替代了之前的 "main" 字段,而且还额外提供了子路径导出、条件导出能力来替代之前的文件夹模块方案。
总之,只要你使用的是最新的 Node v12、v14 版本,就可以放心使用以上介绍双模块系统、文件导出功能。
感谢你的阅读,Happy Coding!