在 5.3 节中,我们讨论了 CommonJS 的 require 和 ES Modules 的静态导入机制。但无论使用哪种模块系统,当 Node.js 遇到一个包(package)的引入,比如 require('lodash') 或 import lodash from 'lodash',都需要知道这个包到底导出了什么文件。这个信息就定义在 package.json 中。随着 Node.js 模块体系的演进,包加载机制也从单一的 main 字段演进到了功能更强大的 exports 字段和条件导出。理解这套机制,对于发布一个优质 npm 包或者优雅地组织 Monorepo 项目都至关重要。
5.4.1 传统主入口:main 字段
在早期 Node.js 版本中,包的入口几乎完全由 package.json 中的 main 字段决定。当执行 require('some-package') 时,Node.js 的模块解析算法会去 node_modules/some-package 目录下寻找 package.json,并读取其中的 main 字段,把它作为包的入口文件。如果没有 main 字段,Node.js 会默认尝试读取目录下的 index.js 或 index.node。
// some-package/package.json
{
"name": "some-package",
"version": "1.0.0",
"main": "lib/index.js"
}
上述配置让 require('some-package') 等价于 require('some-package/lib/index.js')。这种机制简单直接,在很长一段时间内工作得很好。
但 main 有明显的局限性:
- 只能指定一个入口,无法区分 ESM 和 CommonJS,也无法为不同环境(Node.js / 浏览器)提供不同入口。
- 无法对包的内部结构做约束:即使指定了
main,用户仍可以绕过它,通过require('some-package/dist/internal')直接访问包的内部模块,导致不稳定的 API 被依赖。 - 无法声明多入口:希望提供
import from 'lodash/fp'这样的子路径入口时,只能依靠文件目录结构,缺乏正式的规范约束。
于是,Node.js 12.7.0 引入了 exports 字段(并在后续版本中不断扩展),它从设计之初就是为了解决这些问题。
5.4.2 exports 字段:现代化包入口控制
exports 字段可以看作包的“公共 API 声明”。它在 package.json 中指定哪些文件可以被外部引用,并且可以针对不同的模块系统、运行环境给出不同的入口。一旦定义了 exports,用户就只能通过 exports 声明的路径访问包的内容,任何未声明的内部路径都将被阻止(即“封装”特性)。
基本用法——替换 main:
{
"name": "my-package",
"version": "1.0.0",
"exports": {
".": "./lib/index.js"
}
}
这里的 "." 代表包的根入口,"./lib/index.js" 是相对于包根目录的路径。它的效果和 "main": "./lib/index.js" 类似,但加上了封装:现在用户只能通过 require('my-package') 获得 lib/index.js,而 require('my-package/lib/internal') 会直接报 ERR_PACKAGE_PATH_NOT_EXPORTED 错误。这非常有利于库的维护者稳定公开 API,防止内部实现被误用。
多入口声明:
如果包需要提供多个正式的入口点,可以像这样定义:
{
"exports": {
".": "./lib/index.js",
"./utils": "./lib/utils.js",
"./styles/style.css": "./assets/style.css"
}
}
用户可以使用 require('my-package/utils') 和 require('my-package/styles/style.css'),而其他路径仍然不可访问。注意子路径导出时的 key 必须以 "./" 开头,即相对路径风格,但实际使用时只需写 my-package/utils,无需再写 my-package/./utils。
双模块格式支持:
现代包经常需要同时支持 CommonJS(CJS)和 ES Modules(ESM),以便兼容旧项目和新工具链。exports 允许为同一个入口指定不同模块系统的文件:
{
"exports": {
".": {
"import": "./esm/index.mjs",
"require": "./cjs/index.cjs"
}
}
}
当用户使用 import myPackage from 'my-package' 时,Node.js 会加载 ./esm/index.mjs;当使用 const myPackage = require('my-package') 时,则加载 ./cjs/index.cjs。这种明确的区分消除了通过文件后缀名(.mjs / .cjs)或 type 字段推断的歧义,是推荐的双模块打包方案。
5.4.3 条件导出:为运行环境与消费方定制
exports 的强大之处在于它支持 条件导出 —— 根据 Node.js 的环境条件选择最合适的入口。前面用到的 "import" 和 "require" 其实就是条件导出中的两种条件。除此之外,Node.js 还定义了一系列标准条件:
node:仅在 Node.js 环境中匹配。browser:在浏览器打包环境中(如 webpack、rollup)通常会被识别。import:用户使用 ES 模块导入时匹配。require:用户使用 CommonJS 导入时匹配。default:通配条件,当没有其他条件匹配时使用(通常放在最后)。types:TypeScript 的类型声明文件入口。node-addons:用于原生模块(.node 文件)分发。
条件导出可以嵌套,形成一个链式匹配。Node.js 在加载时从上到下检查条件数组,使用第一个与当前运行时环境匹配的条件。
{
"exports": {
".": {
"node": {
"import": "./node-esm/index.mjs",
"require": "./node-cjs/index.cjs"
},
"browser": "./browser/index.js",
"default": "./fallback/index.js"
}
}
}
上述配置的工作方式为:
- 在 Node.js 环境中,如果是
import加载,走./node-esm/index.mjs;如果是require,走./node-cjs/index.cjs。 - 在浏览器打包工具中(通常声明
browser条件),加载./browser/index.js。 - 其他环境或不支持条件的旧版本运行时会回退到
./fallback/index.js。
这种方式让包的作者可以精确控制每个环境下的代码分发,例如浏览器版本可以去掉 Node.js 的 fs 引用,Node 版本可能包含 Buffer 处理等。
TypeScript 类型支持:
对于 TypeScript 项目,条件导出也可以配合 types 条件指定类型声明文件。
{
"exports": {
".": {
"import": "./esm/index.mjs",
"require": "./cjs/index.cjs",
"types": "./types/index.d.ts"
}
}
}
当 TypeScript 解析模块时,会优先使用 types 指向的声明文件,从而获得准确的类型提示。
条件出口的嵌套与简写:
条件导出允许简写形式:如果只需要区分 import 和 require,可以写成:
{
"exports": {
".": {
"import": "./esm/index.mjs",
"require": "./cjs/index.cjs"
}
}
}
这等价于把 "import" 和 "require" 作为顶层条件。此外,如果只提供一个文件而不区分,可以使用字符串简写:
{
"exports": "./lib/index.js"
}
它代表包根入口为 ./lib/index.js,适用于单一模块格式的包。
5.4.4 exports 与 main 的共存与迁移
当一个包同时定义了 main 和 exports,只有 exports 会生效(Node.js 12.7.0+)。这其实是一种特性:如果用上了 exports,它应该成为入口定义的唯一来源,避免混淆。但在支持旧版本 Node.js 的情况下,可以保留 main 作为向后兼容手段:
{
"main": "./cjs/index.cjs",
"exports": {
"import": "./esm/index.mjs",
"require": "./cjs/index.cjs"
}
}
对于不支持 exports 的旧版 Node.js(<12.7.0),会忽略 exports 而回退到 main,这样用户仍然能使用 CommonJS 入口。对于现代 Node.js,则会严格按照 exports 解析。这是一种平滑过渡的方案。
5.4.5 子路径导出的使用限制与注意事项
封装特性固然好,但也带来一些需要注意的细节:
- 路径必须以
"./"开头:exports中的键必须是类似"."或"./sub"的格式,值必须是相对路径(以"./"开头)。不能使用绝对路径或网络路径。 - 不能映射到外部包:
exports只能指向包内的文件,不能将"./lodash"重定向到lodash这个包,这种需求应使用第三方别名工具或 TypeScript 的 paths 映射。 - 导出的是文件还是目录:如果导出的是一个目录,Node.js 会寻找该目录下的
index文件(具体规则取决于模块类型),但建议始终明确到具体文件,避免依赖隐式索引。 - 封装可能导致测试困难:当包测试需要访问内部模块时,需要考虑是否开放内部入口或使用其他方式注入。通常建议对测试工具开放
internal子路径。 - TypeScript 的支持:TypeScript 需要额外配置
types或typesVersions字段来配合条件导出,确保类型检查能跟随导出配置。
5.4.6 实用技巧:用 exports 组织 Monorepo 包
在 pnpm workspace 或 Lerna 管理的 Monorepo 中,exports 字段可以很好地定义每个子包的外部接口。例如,一个工具库包 @repo/utils 可以这样规划:
// packages/utils/package.json
{
"name": "@repo/utils",
"exports": {
".": "./src/index.ts",
"./string": "./src/string/index.ts",
"./date": "./src/date/index.ts"
}
}
开发阶段直接指向 TypeScript 源文件,配合构建工具(如 tsup 或 unbuild)处理成 CommonJS 和 ES Modules 的产物,并在发布版本时通过条件导出切换:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
这使得外部引用 import { formatDate } from '@repo/utils/date' 既类型安全,又按需加载。内部包之间的引用也可以利用这一机制清晰地管理依赖。
5.4.7 小结
包加载机制的演进反映了 Node.js 模块系统的成熟过程:
main是单一入口的简单方案,至今仍作为兜底。exports提供了封装、多入口、条件导出等现代化能力,是当今发布 npm 包的核心标准。- 条件导出让同一个包能够优雅地适配 Node.js、浏览器、打包工具、TypeScript 等不同消费方,大幅提升了包的灵活性和可维护性。
在制定一个包的导出策略时,应该优先使用 exports 而非 main,并且充分利用条件导出为包的使用者提供清晰的、环境适配的最佳入口。这不仅是技术能力的体现,也是现代 npm 生态对包开发者的基本期望。