Node.js 精讲 - npm包如何设置导出文件

408 阅读20分钟

1. 前言

其实标题不是很精确,我们这里主要是讲node_modules目录下面的包如何设置导出文件。只不过现在node_modules下面的包基本都是通过npm 包的形式管理的,所以习惯把它叫做 npm 包。

本篇文章的讲解基于 Node.js v20.10.0 版本。

2. 曾经的问题

我们 npm 包的作者在完成一个包的开发后,一般都会在文档中说明如何引用这个包。我们有时会在package.json中使用main属性来指明默认的入口文件。

举例如下:

// package.json
{
  "name": "c",
  "version": "1.0.0",
  "main": "index.js"
}

导入该包:

require('c');

上面例子中,当运行require('c')时,Node.js 会去node_modules下寻找c包的package.json,然后找到main属性,然后加载index.js

也可以不指定main属性,这样 Node.js 默认会自动寻找index.js。但是这样做肯定是不推荐的。

main属性虽然指定了,但是包的使用者可以绕开main,直接导入包里面的文件,而该文件不一定是包作者期望暴露出去的文件。

我们举个例子,假设c包的目录结构如下:

package.json
index.js
	tools
		getYear.js
		getDate.js

对于c包的作者,他原本期望的是index.js是对外的API,tools下面的工具函数是供其内部使用的工具函数,并没有打算暴露给用户。

但是c包的某些使用者这样来使用该包:

const getYear = require('c/tools/getYear');

c包的getYear函数正好能满足他的需求。

一段时间过后,c包的作者对包做了一个小版本的改动,正好改动了getYear,新的getYear功能和原来已经所有不同,但是包的整体功能和用法都没有变化。

包使用者发现c包有升级版,并且声称和老版本兼容,于是升级了该包。

然后必然的结果就是出问题了,getYear函数不达预期。

像这样的包使用者可能有很多。

以上就是我们老版本的 Node.js 存在的问题。

3. 新特性:exports

Node.js 在 v12.7.0 版(2019年)开始提出了一个新的特性:在package.json中加入exports属性。

该属性可以让包的作者主动声明哪些文件可以暴露出去。如果包使用者在其代码中引入该包的其他文件,Node.js 将会报错。这样包也可以做到封装的效果,只将该暴露的暴露出去。 该功能后来不断完善,到 v14.13.0 版本以后就稳定了。

我们举例说明该功能。还继续用上面c包的例子。

c包的作者在开发该包之初,在package.json中添加了exports属性。 package.json文件如下:

{
  "name": "c",
  "version": "1.0.0",
  "main": "index.js",
  "exports": "./index.js"
}

包的目录结构如下:

package.json
index.js
	tools
		getYear.js
		getDate.js

c包的使用者所用的 Node.js 是 v12.7.0 以后的版本,已经支持了exports。 该使用者看到该包中有一个getYear的方法,想直接导入使用,他这样做:

const getYear = require('c/tools/getYear');

这次 Node.js 报错了, 错误类型:ERR_PACKAGE_PATH_NOT_EXPORTED。 错误消息:Package subpath './tools/getYear' is not defined by "exports"

该错误信息告诉使用者,他导入的路径没有在exports中定义。

使用者不再能直接任意使用包内部的文件,只能使用包作者指定的文件,从而包的封装性得到了保障,以后维护的隐患也减少了。

这个属性对于包的作者还是挺有用的,使用者也需要认识该属性,它就像一个小型文档,告诉包使用者入口文件有哪些。所以,我们还是有必要了解一下这个package.json下的exports属性。

下面咱们来看一下它的具体用法。

4. 最简单的用法

// package.json 文件
{
  "name": "c",
  "exports": "./index.js"
}

这样就声明了当前包的接口文件是./index.js

用户导入包时,可以这样导入:

CommonJS 模块

require('c');

ES 模块

import 'c';

规则: 当exports属性值是一个字符串时,该字符串是被导出文件相对于包文件夹目录的相对路径,必须以./开头。

5. 主入口点的声明方式

所谓主入口点,就是引用包时,直接写包名,不需要写路径,此时引用的就是主入口点的文件。

例如:

require('c');

这样导入的就是包的主入口点指定的文件。

下面这样则代表导入包的子路径:

require('c/tools/getPath');

exports有两种主入口的声明方式,一个是像上面说的:

{
	"name": "c",
	"exports": "./index.js"
}

还有一种:

{
	"name": "c",
	"exports": {
		".": "./index.js",
		"./tools/getPath.js": "./tools/getPath.js"
	}
}

这种方式既可以声明主入口点,又可以声明包的子路径。

第一种方式可以看作是第二种方式只有主入口点时的语法糖。

规则:

  1. exports既可以是字符串,也可以是 JSON 对象。
  2. JSON 对象中,主入口点的 key 部分是"."

6. 子路径的声明方式

6.1 举例

c包的 package.json 文件

{
  "name": "c",
   "exports": {
       "./getPath.js": "./tools/getPath.js"
  }
}

导入c包的方式:

// CommonJS 模块系统
const getPath = require('c/getPath.js');
// ES 模块系统
import getPath from 'c/getPath.js';

可以看到exports其实是个 map 映射,key 部分 是供外界引入包的路径,value 部分指向真正的要暴露出去的文件。

这种方式其实是把包文件真正的路径和外界隔离开了,外界引用该包时使用的子路径并不一定就是真正文件的路径。

6.2 规则

  1. 子路径 map 的 key 必须以./开头。
  2. 子路径 map 的 value 部分如果是字符串,则该字符串是被导出文件相对于包目录的相对路径,必须以./开头。
  3. 导入时将 key 中最前面的.去除,剩下的接在包名后面就可以了。

6.3 需要注意的问题

按照 CommonJS 的习惯,require()一个文件时,往往不会带上文件的后缀,执行环境会自动匹配后缀。

但是exports没有这个功能,不会自动匹配文件后缀,所以包作者最好遵循一个统一规则,key 部分要么都指定后缀,要么就都不指定。这样可以保持一致性。

不过 Node.js 官方倾向于全部使用带后缀的 key,就像我们例子中那样。

目前 Node.js ES 模块系统的import也不支持自动猜测后缀,需要开发人员明确指出文件后缀。

7. 子路径支持通配符

当你有大量文件需要导入时,可以给子路径加通配符,这样就不用一个一个的列出来了。

7.1 举例

c包的package.json文件

{
	"name": "c",
	"exports": {
		"./*.js": "./src/tools/*.js"
	}
}

导入c包的方式:

// CommonJS 模块系统
const getYear = require("c/time/getYear.js");
// ES 模块系统
import getYear from 'c/time/getYear.js'

在该例中,./src/tools目录和其子目录下的所有.js后缀的文件均可导出。 注意,./src/tools下的子目录也在匹配范围内,因为*可以匹配包括文件分隔符/在内的任何字符。

Node.js 会先用time/getYear.js*.js匹配,从而得知*的内容是time/getYear,该内容又用于替换./src/tools/*.js,最后的结果是./src/tools/time/getYear.js,即要导入的是这个文件。

7.2 规则

*可以匹配任何内容,包括/,且不带有任何语义,仅仅是简单的替换。

例如:

// package.json 文件
{
    "name": "c",
    "exports": {
        "./*s": "./src/tools/*s"
	}
}

导入c包的方式:

// CommonJS 模块系统
const getYear = require("c/time/getYear.js");
// ES 模块系统
import getYear from 'c/time/getYear.js'

例子中,虽然exports 的 map 映射没有写出完整的.js后缀,但是依然可以匹配 ./src/tools/getYear.js文件,这时的*time/getYear.j

8. 使用 null 值可以让子路径无效

上面说到通配符可以批量指定导出文件。 但是如果包作者不希望这些文件中的个别文件被导出,可以使用null值来实现。

// package.json 文件
{
    "name": "c",
    "exports": {
        "./*.js": "./src/tools/*.js",
        "./private/*": null
     }
}

例子中,批量导出了./src/tools目录和其子目录下的所有.js后缀的文件,但是排除了./src/tools/private下的文件。

如果包使用者试图导入./tools/private目录下的文件,则会报错。 例如:

import 'c/private/getPath.js'

会报错:Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './private/getPath.js' is not defined by "exports"

9. 优先级问题

当我们定义多个子路径时,如果路径的匹配范围有重合,Node.js 将会优先匹配更具体的那个子路径。

举例如下:

{
    "name": "c",
    "exports": {
        "./tools/*.js": "./src/tools/*.js",
        "./tools/*Day.js": "./src/*Day.js"
     }
}

导入c包的方式:

// CommonJS 模块
const getDay = require('c/tools/getDay.js');
// ES 模块
import getDay from 'c/tools/getDay.js';

例子中,./tools/*Day.js要比./tools/*.js更具体,因此./tools/*Day.js所指定的文件会被导出。在这里具备被导出的文件是./src/getDay.js

10. 有条件的导出

除了子路径匹配方式外,Node.js 还支持一类叫有条件的导出方式,英文名叫Conditional Exports

所谓有条件的导出,就是宿主环境必须满足一定的条件才能匹配该导出项。例如可能需要指定的运行环境、模块系统等。

我们后面就把“有条件的导出”叫做“条件匹配”。

其实子路径也是一种条件,只是 Node.js 官方把它们区分开了,咱们就按照官方的思路来理解就行。

区分开也有合理性,条件匹配和子路径匹配确实有些不一样,子路径 key 部分必须以./开头,而条件匹配不需要也不能以./开头。

Node.js 内置了几种条件匹配:

  1. import
  2. require
  3. node
  4. node-addons
  5. default

下面我们分别讲解其用法:

10.1 import 和 require

我们知道 Node.js 有两种模块系统,一个是 CommonJS 模块系统,另一个是 ES 模块系统。 如果包作者想较好的支持这两种模块系统,那就需要两种导出方式,并把它们放到不同的文件中。CommonJS 模块导入的是 CommonJS 专用的文件,ES 模块导入的是 ES 专用的文件。(如果你想了解 Node.js 如何指定模块系统,可以看这篇文章

importrequire条件匹配可以满足这个需求。

举例如下:

// package.json
{
	"name": "c",
	"exports": {
		"import": "./index-module.js",
		"require": "./index-require.cjs"
	},
}

根据例子中设置,当使用 CommonJS 模块系统导入包时,会导入./index-require.cjs文件。 当使用 ES 模块系统导入包时,会导入./index-module.js文件。

导入c包的方式:

// CommonJS 模块
require('c');

以上导入的是./index-require.cjs文件。

// ES 模块
import 'c';
// 或者
import('c');

以上导入的是./index-module.js文件。

规则:

  1. import key 适用于 ES 模块的import关键字(import()也一样适用)。
  2. require key 适用于 CommonJS 模块的require()方法。

10.2 node

使用该条件匹配的含义是其指定的文件只有在 Node.js 环境下才能被引用。 举例如下:

// package.json
{
	"name": "c",
	"exports": {
    	"node": "./feature-node.js",
    	"default": "./feature.js"
	}
}
// Node.js 中导入该包
import 'c';
// 或者
require('c');

例子中,如果运行环境是 Node.js,则node条件满足,./feature-node.js会被引用。 如果是其他环境,则会匹配default./feature.js会被引用。不过前提是其他环境也遵从default的规则。default的规则在下面讲。

10.3 node-addons

使用该条件匹配的含义是只有在支持插件的 Node.js 环境下,才能匹配其所对应的文件。 由于--no-addons启动参数可以禁用 Node.js 的插件,因此包作者可以通过node-addons来区分当前 Node.js 环境是否支持插件功能,如果支持则匹配node-addons

举例如下:

// package.json 文件
{
  "name": "c",
  "exports": {
    "node-addons": "./build/addon.node",
    "default": "./default.js"
  }
}

main.js文件中引用了c

import('c')

Node.js 命令行执行main.js文件

node main.js

上例中,因为 Node.js 的插件功能没有被关闭,满足了node-addons的条件,./build/addon.node被导入。

但是如果 Node.js 加上启动参数--no-addons

node --no-addons main.js

该启动参数会禁用 Node.js 加载插件的功能,所以不满足node-addons的条件,因此default指定的文件./default.js被导入。

10.4 default

上面的例子已经多次提及default,相信你已经猜个八九不离十了。 default是一个兜底的匹配项。当其他条件都不满足时,就会匹配它所对应的文件。

不过default需要注意的是,一定要把它放到最后,否则它后面的匹配项将永远也不会被匹配到。具体请往下看“条件匹配优先级”的相关内容。

10.5 注意点

需要注意的是,条件匹配项不能和.(主入口点)./(子路径)在同一层级,否则会报错。其实也可以理解,因为它们本身就是冲突的。

Node.js内置的几个条件匹配项就是这些了。

11. 条件匹配可以嵌套

条件匹配还有一个特性,就是可以嵌套,这大大提高了其灵活性。

我们可以把条件匹配理解成 JavaScript 的 if语句。

举例如下:

// package.json
{
	"name": "c",
	"exports": {
		".": "./index.js",
		"./feature.js": {
			"import": "./index-module.js",
			"require": "./index-require.cjs"
		}
	}
} 

这时子路径./feature.js的值不再是个字符串,而是集合了各种条件匹配的JSON对象。 例子的意思是,不同模块系统导入c/feature.js将会根据指定的importrequire导入不同的文件。

我们可以把它比作以下程序伪代码:

// ./feature.js 匹配成功后
if (import === true) {
	return './index-module.js';
} else if(require === true){
	return './index-require.cjs';
}

导入c包的方式:

// CommonJS 模块
require('c/feature.js');

以上将会导入./index-require.cjs

// ES 模块
import 'c/feature.js';

以上将会导入./index-module.js

再看一个例子:

// package.json 文件
{
	"exports": {
		"node": {
			"import": "./feature-node.mjs",
			"require": "./feature-node.cjs"
		},
		"default": "./feature.mjs"
	}
}

例子中,importrequire被嵌入到了node中,其意思是,当运行环境是Node.js 时,ES 模块导入的文件和 CommonJS 模块导入的文件是分开的。

需要注意的是,子路径无法嵌套使用,它只能在exportsJSON对象的最顶层。

12. 开发人员可以自定义条件匹配

通过--conditions启动参数,开发人员可以自定义一个条件匹配:

node --conditions=development main.js

此时的 Node.js 执行环境就具有了development条件。

包作者可以这样来写package.json

// package.json
{
	"name": "c",
    "exports": {
    	"development": "./index-development.js",
    	"default": "./index-default.js"
    }
}

main.js文件:

import 'c';

Node.js 运行main.js文件:

node --conditions=development main.js

此时包中的development条件被满足,./index-development.js文件被导入。

如果不带该自定义条件参数:

node main.js

则包中的development条件不会被满足,会匹配default,导入./index-default.js

13. 条件匹配的优先级问题

运行环境很有可能同时满足多个条件,那这是就涉及到优先级问题了。

优先级很简单,exports JSON 对象中,从前往后,依次匹配,一旦匹配到了就停止往下寻找匹配项。也就是说,前面的优先级比后面的高。自定义条件、default、内置条件都是同等对待的,顺序是决定优先级的唯一因素。

例如我们现在有这样的exports

// package.json 文件
{
	"name": "c",
	"exports": {
		"node-addons": "./index-addons.js",
		"node": "./index-module.js",
	}
}

main.js文件:

import 'c';

运行:

node main.js

上例中,node-addons被匹配,./index-addons.js被导出。 因为当前 Node.js 环境并没有禁用插件。同样都能匹配的情况下,前面的优先级比后面的高。

再看一个default条件匹配的例子:

// package.json
{
	"name": "c",
	"exports": {
		"default": "./index-default.js",
		"node": "./index-module.js",
	}
}

main.js文件:

import 'c';

运行:

node main.js

上例中,default被匹配,./index-default.js被导出。 default可以匹配任何情况,而前面匹配项优先级要高于后面的。 因此一定要把default放到同级的最后,它后面同级的匹配项永远也不会被匹配到。

自定义的条件匹配项也遵从优先级规则:

// package.json
{
	"name": "c",
	"exports": {
		"node": "./index-module.js",
		"development": "./index-development.js"
	}
}

main.js文件:

import 'c';

运行:

node  --conditions=development main.js

虽然 Node.js 启动时自定义了development条件,但是由于node条件在development前面,因此最终node被匹配,./index-module.js被导出。

优先级相关的就是这些了。

14. 常见自定义条件

自定义条件匹配给了开发人员极大的灵活度,我们可以根据自己的场景来自定义自己的条件匹配项。

社区对一些非常常用的自定义条件匹配已经形成了共识:

types:供类型系统导入类型文件使用。应该放到最前面,否则会被其他匹配项优先匹配。 browser:匹配任何浏览器环境。 development:匹配开发环境,使用该条件,包开发者可以为开发环境提供专门的文件。 production:匹配生产环境,使用该条件,包开发者可以为生产环境提供专门的文件。

由于JavaScript运行时环境有很多:Node.js、各大浏览器 等等。

作为开发人员我们肯定是希望这些运行时环境能够有统一的自定义条件,这样可以减少代码对这些不同环境的兼容成本,减少JavaScript开发人员的工作强度。

幸运的是 WinterCG 正在做这个事情。它正在像W3C提这方面的标准,点击这里看草案WinterCG 是一个致力于提高各个JavaScript运行时环境的API互操作性的社区组。

另外如果你想 Node.js 内置新的条件,可以给其提 pull request

Node.js 官方也给出了内置新条件的要求。

  1. 对于所有的实现人员来说,含义必须足够清晰,不能模糊。
  2. 对于为什么需要新条件,需要有明确的理由。
  3. 必须要有足够的现成的实现案例。
  4. 新条件的名称不能和其他已有的条件名称或者已经被广泛使用的条件名称冲突。
  5. 新条件应该可以增加整个生态系统的可协作性或者互操作性,而且没有其他更好的方式。
  6. 新条件名称应该是 Node.js 用户期望在 Node.js 文档中能够找到的内容。type就是一个很好的例子,它不在 WinterCG 的W3C提案中,但是它很适合在 Node.js 文档中提及。

Node.js 官方文档中说,上面的这些要求可能在某个适当的时候放到一个专门的新条件的注册流程中。

15. 包内部可以通过包名自引用

一旦你给你的包在package.json中定义了exports属性,那就多了一个功能,你可以像外界一样,通过你的包名引用exports指定的内容。

示例代码如下:

// package.json
{
	"name": "c",
	"exports": {
		".": "./index.js",
		"./foo.js": "./foo.js"
	}
}

c模块内部某文件:

import { something } from 'c';

或者

const { something } = require('c');

例子中,从./index.js导出了something

可以看到和包外部引用包文件的方式是相同的,CommonJS 和 ES 模块均可。

规则:

  1. 自引用只有在package.json设置了exports属性时才可以使用。
  2. 自引用仅能引用exports属性指定的内容。

16. 包同时支持 CommonJS 和 ES 模块的问题

所谓同时支持 CommonJS 和 ES 模块,就是指该包既支持 CommonJS 模块方式导出,又支持 ES 模块方式导出。

举一个例子:

// package.json 文件
{
	"name": "c",
	"exports": {
		"import": "./BigData.mjs",
		"require": "./BigData.cjs"
	}
}

包的作者针对两种模块系统分别编写代码BigData.mjsBigData.cjscreateBigData.mjs是 ES 模块文件,BigData.cjs是 CommonJS 模块文件。

c包的内部是这样的: c/BigData.mjs

export default function BigData() {
}

c/BigData.cjs

module.exports = function BigData() {
};

这时包的使用者这样用这个包:

import BigData from 'c';
const bigData = new BigData();

以上代码暂时没有问题,但是突然有一天包的使用者接到一个需求,需要把 bigData传给另外一个包d的一个方法setBigData(),该方法只接收BigData类型的对象。

因此他写下如下代码:

import BigData from 'c';
import { setBigData } from 'd';

const bigData = new BigData();
// 报错
setBigData(bigData);

但是意外的是,setBigData竟然报错了,报错信息是:请传入BigData类型的对象

这时包的使用者实在是不知道哪里出了问题。

我们来帮他看一下问题在哪儿。

d包的内部代码是这样的:

const BigData = require('c');
module.exports.setBigData = function(bigData) {
	if(!(bigData instanceof BigData)) {
		throw new Error('请传入BigData类型的对象');
	}
	...
}

错误就来自这个throw。为什么instanceof会返回false呢? 是因为包使用者所引入的那个c包构造函数BigDatad包中引用的c包构造函数BigData根本不是一个函数。

包使用者采用的是 ES 模块的方式引入的BigData,而d包采用的是 CommonJS 模块的方式引入的BigData

根据 Node.js exports条件匹配项importrequire的规则,它们分别引用了BigData.mjs文件和BigData.cjs文件。而这两个文件中分别定义了一个叫BigData的函数。 这两个函数只是名字相同,但是是两个完全不同的函数,因此instanceof返回false

这就是双模块系统带来的问题之一,而且这种问题会比较隐蔽,不好排查。

Node.js 官方针对这类问题提了两点建议。

16.1 第一个建议

包的代码都用 CommonJS 模块系统来写,或者用 ES 模块系统来写,但是最终转成 CommonJS 模块系统。 然后创建一个 ES 模块 wrapper 文件,将 CommonJS 代码包起来,再导出去。

为什么代码要用 CommonJS 模块系统来写代码呢?因为用 CommonJS 模块实现后,ES 模块可以直接引用 CommonJS文件,从而实现只有一个版本的业务逻辑代码,但是反过来,在 Node.js 中 CommonJS模块是无法引用 ES 模块的。

我们还继续沿用上面的例子:

c包的BidData.cjs不变

module.exports = function BigData() {
};

c包的BigData.mjs需要去掉BigData函数定义,修改成这样

import BigData from './BigData.cjs';
export default BigData;

或者更简洁一点:

export { default } from './BigData.cjs';

这样就可以保证只有一个BidData方法了。

ES 模块的只是作为一个壳将 CommonJS代码包裹一下,然后通过package.jsonexports中的条件匹配项导出去。

16.2 第二个建议

尽量写无状态(stateless)的包,也就是说包尽量不存储数据。像 JavaScript 的Math对象就是无状态的,它不存储任何数据,无论你对它操作多少遍,它都不会有变化。

如果你的包必须是有状态的,例如内部维护了一个计数器,那就把状态的部分独立出来,用 CommonJS 模块的形式来写。

这样其他文件不管是 CommonJS 模块还是 ES 模块,都可以引入该状态文件,且根据两个模块系统不会重复加载文件的特性,该状态文件可以保持单例状态。

举例如下: 管理状态的state.cjs文件

//  ./node_modules/c/state.cjs
module.exports = {
	count: 0,
	addCount() {
		this.count++;
	}
}

./node_modules/c/index.cjs文件

const state = require('./state.cjs');
module.exports.state = state;

./node_modules/pkg/index.mjs文件

import state from './state.cjs';
export {
  state,
};

package.json

{
	"name": "c",
	"exports": {
		"import": "./index.mjs",
		"require": "./index.cjs"
	}
}

上面的例子中,所有的状态管理和保存都放到了state.cjs文件中,通过这种方式,state.cjs将始终保持是一个单实例。外界不管用 Commonjs 还是 ES,都会获得同一个state

17. 可以用直接引用路径的方式击穿 exports

虽然exports可以让包达到封装的效果,让包的使用者不可以随意的去引用文件,但是这里也有一个漏洞。

如果用户这样去引用一个包,那exports也拦不住:

require('/path/to/node_modules/pkg/subpath.js');

exports只能拦住通过包名字开头引用文件的方式。不过实际开发中应该很少有人会像上面这样去引用一个包。

如果你临时为了开发调试,这种方式倒不失为一种击穿exports的好方法。

18. main 和 exports可以共存

上面说到老版本 Node.js 使用main属性来指定包的入口文件。 新的exportsmain强大很多。虽然它们的功能上存在一些冲突,但是它们是可以在package.json中同时定义的。 当它们有冲突时,exports的优先级会更高。

不过在不支持exports的老版本 Node.js 中,exports会被忽略。

作为公共的包,如果你需要支持老版本的 Node.js(具体版本号已在上面说明),那最好的方式就是mainexports同时使用。

19. 结束语

到这里关于 npm 包如何设置导出文件的主题就讲完了。