在 npm 包的 package.json
文件中,exports
和 main
都可以用来定义包的入口点,但是 exports
字段是 main
字段的现代的替代方案。
主入口点导出
main
用于定义包的主要入口点,exports
字段功能更加强大,允许定义多个入口点,支持在不同环境之间进行条件入口解析,并且除了 exports
中定义的入口点之外,阻止其他任何入口点。
如果是开发新的 npm 包,建议使用 package.json 的 exports
字段定义主入口点导出:
{
"exports": "./index.js"
}
当定义了 exports
字段时,包的所有子路径都会被封闭起来,导入者无法再访问未被 exports
导出的模块。例如,require('pkg/subpath.js')
会抛出 ERR_PACKAGE_PATH_NOT_EXPORTED
错误。
这种导出的封装为工具以及在处理包的 semver(语义化版本)升级时提供了更可靠的包接口保证。然而,这并不是一种强封装,因为直接 require
包的任何绝对子路径(例如 require('/path/to/node_modules/pkg/subpath.js')
)仍然会加载 subpath.js
。
更可靠的包接口保证:通过明确指定哪些模块可以被外部访问,开发者可以更好地控制包的公共 API,从而减少意外暴露内部实现细节的风险。
不是强封装:尽管有这些控制,但如果用户知道确切的文件路径,他们仍然可以直接通过绝对路径来加载包内的任意文件,这绕过了
exports
字段的限制。
例如,以下例子,该例子的目录结构为:
为了模仿实际项目中使用 npm 包的场景,就创建了 node_modules
文件夹,node_modules/common/package.json
的内容如下,使用 exports
导出了 node_modules/common/index.js
导出的所有 API:
{
"name": "common",
"exports": {
".": "./index.js"
}
}
node_modules/common/index.js
文件内容如下:
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = {
add: add,
subtract: subtract,
};
app.js
文件的内容如下:
const util = require("common")
// 通过具体真实的相对路径引入 package.json ,能绕过 exports 字段的限制,
// 正常引入 package.json
const pkg = require('./node_modules/common/package.json')
const total = util.add(1, 2)
console.log('res ', {
pkg,
total
})
目前所有受支持的 Node.js 版本以及现代构建工具都支持 exports
字段。对于使用旧版本 Node.js 或相关构建工具的项目,可以通过同时包含 exports
和指向同一模块的 main
字段来实现兼容性:
{
"main": "./index.js",
"exports": "./index.js"
}
子路径导出
在 package.json
的 exports
字段中,你可以指定包的主要入口点和其他自定义子路径。主要入口点用 .
表示,表示当用户直接引用包时应加载的文件。其他自定义子路径则可以指向包内的特定模块或文件,允许更精细地控制哪些部分可以被外部访问。
例如:
{
"exports": {
".": "./index.js",
"./submodule.js": "./src/submodule.js"
}
}
只有 exports
中定义的子路径才能被使用者导入:
import submodule from 'es-module-package/submodule.js';
// Loads ./node_modules/es-module-package/src/submodule.js
其他子路径则会报错:
import submodule from 'es-module-package/private-module.js';
// Throws ERR_PACKAGE_PATH_NOT_EXPORTED
导出语法糖
如果 .
导出是唯一的导出,那么 exports
字段为这种情况提供了简化的语法糖,即 exports
字段的值直接指定默认导出的内容。
{
"exports": {
".": "./index.js"
}
}
可以写成:
{
"exports": "./index.js"
}
子路径导入
在复杂的项目中,为了引用位于不同目录层级的模块,开发者通常需要使用多个 ../
来回溯目录层级,这不仅书写繁琐而且容易出错。通过在 package.json
文件中配置 imports
字段,开发者可以定义一组路径别名(即路径映射),使得整个应用程序可以通过这些预定义的路径来简化模块的导入,而不需要每次都写出冗长的相对路径。
例如这个例子,此例子在 package.json 中使用 imports
字段定义了一组路径别名:
{
"imports": {
"#utils/calc": "./src/utils/calc.js"
}
}
此例子的目录结构为:
src/utils/calc.js
代码如下:
function add(a, b) {
return a + b;
}
module.exports = {
add: add,
};
在 src/dir/dir/dir/some.js
文件中通过 package.json 中 imports
字段定义的路径别名引入 calc
模块:
const calc = require("#utils/calc");
const total = calc.add(1, 2);
console.log("res ", {
total,
});
然后在命令行终端运行 src/dir/dir/dir/some.js
文件,正常地输出了 3
:
imports
字段中的条目必须总是以 #
开头,以确保它们与外部包的模块标识符区分开来。
imports
字段中定义的路径别名(即路径映射)是私有的,仅在包内部使用。
类似于 exports
字段,imports
字段也支持根据环境(如 CommonJS 或 ES 模块系统)的不同来指定不同的模块导入路径。
与 exports
字段不同的是,imports
字段支持将外部包直接作为导入路径,而不仅仅局限于本地模块。
例如下面的 package.json 配置:
{
"imports": {
"#lodash": {
"require": "lodash",
"import": "lodash-es"
}
},
"dependencies": {
"lodash-es": "^4.17.21",
"lodash": "^4.17.21"
}
}
通过这个的配置,我们可以在代码中这样引入 lodash
// index.cjs
const { omit } = require("#lodash");
// index.mjs
import { omit } from "#lodash";
现在,每个环境将加载它各自的 lodash
。
子路径模式
如果一个 npm 包只有少量的导出或导入项,则建议直接在 package.json
文件中详细列出每一个具体的子路径,以确保清晰性和可读性。
然后,如果一个 npm 包拥有大量导出或导入项,如果逐一列出这些路径,会导致 package.json
文件臃肿并引发维护问题。
对于这种情况,可以改用子路径模式:
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/*.js": "./src/features/*.js"
},
"imports": {
"#internal/*.js": "./src/internal/*.js"
}
}
在上面的路径映射中,*
表示一个通配符,用于匹配任意有效的路径片段。
而且,在实际解析路径映射时,只是简单地将匹配到的部分替换成指定的目标路径。即使替换的值中包含路径分隔符 /
,这些分隔符也会被正确处理并保留:
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
这是一种直接的静态匹配与替换,对文件扩展名没有任何特殊处理。在路径映射的两端都包含 *.js
,将公开的包导出仅限于 JS 文件。
exports
字段中定义的属性是静态可枚举的,在构建工具或运行时环境中,可以通过静态分析(即不执行代码)来确定一个包的所有导出路径。这对于优化打包、tree-shaking、类型检查和编辑器支持等非常有用。
即使使用了导出模式(patterns),仍然可以保持这种静态可枚举性。
具体来说,当定义了一个导出模式时,右侧的目标模式(target pattern)理解为一个 glob (通配符)表达式,他会与包内的文件列表进行匹配,可以静态地确定哪些路径符合这个模式,并成为有效的导出路径。
exports
字段支持模式,允许使用通配符(例如**
通配符)来指定包内哪些文件应该被暴露给外部。你可以将文件匹配模式(例如 glob 模式)写在exports
中,Node.js 会根据这些模式来决定导出哪些文件。
不过要注意,node_modules
中的路径禁止在 exports
中使用,确保导出的文件只限于当前包内部。
有时候 npm 包内可能会有一些私有的子文件夹,开发者不希望它们暴露给外部使用。这种情况下,可以在 exports
字段中,将私有子文件夹的路径值设置为 null
来实现:
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/*.js": "./src/features/*.js",
"./features/private-internal/*": null
}
}
import featureInternal from 'es-module-package/features/private-internal/m.js';
// Throws: ERR_PACKAGE_PATH_NOT_EXPORTED
import featureX from 'es-module-package/features/x.js';
// Loads ./node_modules/es-module-package/src/features/x.js
总结
package.json 中 exports
字段是 main
字段的替代方案,可以定义多个入口点,除了指定包的主要入口点,还可以定义其他自定义子路径,支持在不同环境之间进行条件入口解析。
而 imports
字段可以定义一组路径别名(即路径映射),简化包内模块路径的导入。与 exports
字段不同的是,imports
字段支持将外部包直接作为导入路径,而不仅仅局限于本地模块。而 exports
中禁止使用 node_modules
中的路径,即外部的模块路径。
exports
和 imports
字段均支持子路径模式,即支持通配符表达式,匹配一组路径,简化了路径映射的定义。
exports
和 imports
定义的路径映射均是静态可枚举的,即在构建工具或运行时环境中,通过静态分析(即不执行代码)来确定一个包的所有导出路径或导入路径。