本指南并不是"从 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!