高级前端必须掌握的 package.json 字段知识:exports & imports

680 阅读7分钟

在 npm 包的 package.json 文件中,exportsmain 都可以用来定义包的入口点,但是 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 字段的限制。

例如,以下例子,该例子的目录结构为:

85.png

为了模仿实际项目中使用 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.jsonexports 字段中,你可以指定包的主要入口点和其他自定义子路径。主要入口点用 . 表示,表示当用户直接引用包时应加载的文件。其他自定义子路径则可以指向包内的特定模块或文件,允许更精细地控制哪些部分可以被外部访问。

例如:

{
  "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"
  }
}

此例子的目录结构为:

88.png

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

89.png

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 中的路径,即外部的模块路径。

exportsimports 字段均支持子路径模式,即支持通配符表达式,匹配一组路径,简化了路径映射的定义。

exportsimports 定义的路径映射均是静态可枚举的,即在构建工具或运行时环境中,通过静态分析(即不执行代码)来确定一个包的所有导出路径或导入路径。