Node.js 包编写指南#1:模块系统与入口文件

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

  1. .mjs 后缀的文件。或者
  2. 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 来说:

  1. 不管当前项目的 package.jsontype 字段值是什么,.cjs 都会被视为 CommonJS 文件
  2. 不管当前项目的 package.jsontype 字段值是什么,.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.jsindex.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。

举个例子,如果之前的包导出了 mainlibfeaturepackage.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"
  }
}

这样, mainlibfeature目录下的所有文件都能引用了,不过并不推荐这种方式,还是要尽量减少对外入口的数量。

子路径导出模式

上面我们所提及的"导出模式"是一种类似于 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" 字段定义使用 importimport() 方法引入的文件;"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 种方法,可以判定一个文件使用的哪种模块语法:

  1. 通过文件后缀。.cjs 表示 CommonJS 模块文件,.mjs 表示 ES Module 模块文件
  2. 通过 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!

参考链接

相关推荐
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui
尝尝你的优乐美6 小时前
vue3.0中h函数的简单使用
前端·javascript·vue.js
windy1a7 小时前
【C语言】js写一个冒泡顺序
javascript
会发光的猪。7 小时前
如何使用脚手架创建一个若依框架vue3+setup+js+vite的项目详细教程
前端·javascript·vue.js·前端框架