目录
[想象一下你正在开发一个 npm 包......](#想象一下你正在开发一个 npm 包……)
[针对 Node.js 和浏览器](#针对 Node.js 和浏览器)
想象一下你正在开发一个 npm 包......
您希望提供多个入口点,但同时限制对内部文件的访问 。您需要同时支持 CJS 和 ESM,包含类型定义,甚至可能还要确保浏览器兼容性。您如何管理所有这些需求?
在早期版本的 Node.js 中,包使用main
字段 in package.json
来定义单个入口点**。**这种方法虽然简单,但存在局限性:它只允许一个入口点,并且包中的所有文件都可访问,无法保护内部文件。随着生态系统的发展(尤其是 ESM 的兴起和对多格式包的需求),这种方法很快就显得力不从心。
术语
-
ECMAScript 模块(ESM) :JavaScript 使用原生
import
&export
语法的标准化模块格式。 -
CommonJS (CJS) :Node.js 的遗留模块格式,用于
require()
导入和module.exports
导出。 -
包入口点 :访问包的入口路径(例如
pkg-a
或pkg-a/file
)。 -
包子路径 :包名称后面的路径(例如,
/this/is/subpath.js
在pkg-a/this/is/subpath.js
)。
什么是exports
领域?
该字段在Node.js v12.7.0(2019 年 7 月)中引入,通过两个核心功能满足了这些需求:exports package.json
-
子路径导出 :包可以定义多个入口点,只允许公开特定文件,同时阻止对包内部的访问。
-
条件导出:包可以切换入口点,以针对不同的环境(例如,Node.js 与浏览器)和模块类型(例如,CJS 与 ESM)解析不同的文件。
从那时起,exports
它得到了主要 JavaScript 工具和构建系统的广泛支持,例如 TypeScript、Deno、Vite、Webpack、esbuild 等。

exports好处
保护内部文件
以前,用户可以导入软件包中的任何文件,甚至是内部文件。这导致软件包维护者难以更新或重构软件包,因为他们无法判断用户是否依赖这些内部文件。现在exports,维护者可以明确定义哪些文件可以访问,从而建立清晰的公共 API,并防止意外导入内部文件。这有助于维护者管理更新,而不会给用户带来损坏的风险。
您可以使用子路径模式 ( ) 使软件包中的所有文件均可访问*
。此模式会捕获子路径(包括嵌套路径)中的任何字符串,并将其映射到目标文件路径。使用此设置,用户可以通过引用路径来导入软件包中的任何文件。
*
匹配一切该
*
字符的行为与 glob 语法不同。它会捕获嵌套路径,并可能暴露比您预期更多的文件**。**
{
"name": "pkg-a",
"exports": {
"./*": "./*" // 公开所有文件,包括嵌套路径
}
}
尽可能避免暴露所有文件
允许用户导入任何文件意味着即使对界面进行微小的更改(包括您不希望用户访问的文件(例如,捆绑块))也会成为重大更改,并且需要进行重大的 semver 更新。
import foo from 'pkg-a' // 🚫 已阻止(入口点未定义)
import { name } from 'pkg-a/package.json' // ✅
多格式包
如今,软件包经常面临支持多种环境的挑战------Node.js、浏览器、ESM、CJS 和 TypeScript 定义。exports中的字段package.json允许您为每个环境和模块格式指定不同的文件。这确保了兼容性,并通过仅包含与每个目标相关的内容来优化导入。
为了让您的包同时支持 ESM 和 CommonJS 使用者,您可以根据包的导入方式指定需要加载的文件。这样,Node.js 和 TypeScript 就能在各自的上下文中解析正确的代码 ( import
vs require
) 和合适的类型定义 ( .d.mts
vs .d.cts
)。
{
"name": "pkg-a",
"exports": {
"require": {
"types": "./dist/index.d.cts", // Types for require('pkg-a')
"default": "./dist/index.cjs" // Code for require('pkg-a')
},
"import": {
"types": "./dist/index.d.mts", // Types for import 'pkg-a'
"default": "./dist/index.mjs" // Code for import 'pkg-a'
}
}
}
将其与子路径导出相结合,您可以为每个入口点导出不同的类型,同时仍然支持 ESM 和 CommonJS 消费者:
{
"name": "pkg-a",
"exports": {
".": {
"require": {
"types": "./dist/index.d.cts", // Types for require('pkg-a')
"default": "./dist/index.cjs" // Code for require('pkg-a')
},
"import": {
"types": "./dist/index.d.mts", // Types for import 'pkg-a'
"default": "./dist/index.mjs" // Code for import 'pkg-a'
}
},
"./feature": {
"require": {
"types": "./dist/feature.d.cts", // Types for require('pkg-a/feature')
"default": "./dist/feature.cjs" // Code for require('pkg-a/feature')
},
"import": {
"types": "./dist/feature.d.mts", // Types for import 'pkg-a/feature'
"default": "./dist/feature.mjs" // Code for import 'pkg-a/feature'
}
}
}
}
常问问题
-
我是否需要为
require
和提供单独的类型文件import
?是的。TypeScript 使用文件的扩展名
.d.ts
来推断其描述的模块格式。一个.d.cts
文件代表一个 CommonJS.cjs
文件,一个.d.mts
文件代表一个 ESM.mjs
文件。如果将两个文件放在同一个.d.ts
文件里,TypeScript 会错误地解释模块格式,并可能导致代码在运行时失败。请参阅 Andrew Branch (TypeScript 核心团队) 在类型错误吗?中解释这种不匹配→ 🎭 伪装成 CJS。
-
每个条件块内的键的顺序重要吗?
是的。该
types
字段必须放在前面default
,TypeScript 才能正确识别。如果放在后面,TypeScript 会忽略它。 -
消费者需要哪些 TypeScript 设置?
它们必须在其 中设置
moduleResolution
为Node16
、NodeNext
或。这些模式启用条件导出解析和正确的模块格式检测。Bundler,``tsconfig.json
要了解更多信息,请参阅TypeScript 文档。其中深入介绍了配置exports
TypeScript 的其他方法(例如,跨 TypeScript 版本导出不同类型)。
将子路径映射到dist目录
JavaScript 项目经常将目录中的代码编译src到 中dist,从而产生类似 的导入import foo from 'pkg-a/dist/util'。包作者可能不希望dist在导入路径中包含 ,以获得更简单的 API,但将文件输出到包根目录需要复杂的发布步骤,这可能会污染开发环境。
通过该exports字段,包子路径可以直接映射到dist目录内部,从而允许消费者使用更清洁的导入,而import foo from 'pkg-a/util'无需为维护者提供复杂的发布脚本。
该exports字段的 subpaths 对象允许您将任意子路径定义为映射到包中文件路径的键。这使您可以使用更简单、更短的子路径来公开深度嵌套的路径。
{
"name": "pkg-a",
"exports": {
"./deep-file": "./dist/deep/deep/file.js", // 直接映射到文件
"./*": "./dist/*" // 在根级别公开 dist 中的所有内容
}
}
import foo from 'pkg-a' // 🚫 已阻止(入口点未定义)
import bar from 'pkg-a/deep-file' // ✅ - 解析为 dist/deep/deep/file.js
import baz from 'pkg-a/file.js' // ✅ - 解析为 dist/file.js
子路径导出
子路径导出允许您定义包的入口点并将它们映射到包内的文件路径。
要定义多个入口点,exports
可以将该字段设置为子路径对象 ,其中每个键都以 开头.
。.
键表示主包入口,子路径以 开头./
。键可以映射到包内的文件路径,也可以映射到条件对象(我们稍后会讨论)。

单一入口点
该字段最简单的用法exports
是指向包入口文件的字符串。虽然它与 字段类似main
,但有一个显著的区别:一旦使用exports
,它就会将您的包黑框起来。这意味着除非明确指定,否则默认情况下任何子路径(甚至package.json
)都无法访问。
{
"name": "pkg-a",
"exports": "./index.js" // Package entry point
}
import foo from 'pkg-a' // ✅ Resolves to pkg-a/index.js
import { name } from 'pkg-a/package.json' // 🚫 Blocked
多个入口点
要定义多个入口点,请设置exports
为一个子路径对象 ------该对象的每个键都以 开头.
,值是包内某个文件的相对路径。如上所述,.
键表示主包入口,子路径以 开头./
。
{
"name": "pkg-a",
"exports": {
".": "./index.js", // Package entry point
"./package.json": "./package.json" // Allow importing pkg-a/package.json
}
}
import foo from 'pkg-a' // ✅
import { name } from 'pkg-a/package.json' // ✅
公开软件包文件的子集
要仅公开特定目录,请将子路径模式放置在该子目录中。此方法允许使用者仅从指定目录导入文件。此外,您还可以通过将子路径映射到 来阻止对子路径的访问null
。
{
"name": "pkg-a",
"exports": {
"./dist/*": "./dist/*", // Only expose the dist directory
"./dist/internal/*": null // Blocks access to dist/internal
}
}
import foo from 'pkg-a' // 🚫 Blocked (entry point not defined)
import bar from 'pkg-a/dist/file.js' // ✅
import baz from 'pkg-a/dist/dir/file.js' // ✅
import qux from 'pkg-a/dist/internal/file.js' // 🚫 Blocked
import quux from 'pkg-a/dist/internal/dir/file.js' // 🚫 Blocked
有条件出口
条件导出是一个非常强大的功能。它使你的包能够根据使用者提供的条件动态加载不同的文件。利用此功能,你可以针对各种环境优化你的包。
举个简单的例子,假设你希望你的包入口点在两个不同的文件之间切换。为此,请在你的 字段中设置一个条件导出对象 :exports package.json
{
"name": "pkg-a",
"exports": {
// Ordered by priority
"condition-a": "./file-a.js",
"condition-b": "./file-b.js"
}
}
导入此包时,加载的文件取决于运行时提供的条件:
import foo from 'pkg-a' // ❓ 根据提供的条件可以是file-a.js或file-b.js
设置使用条件
node
现在你已经为你的包设置了条件和入口点,那么如何在用户端切换它们呢?这取决于谁在解析导入。
-
如果您使用的是 Node.js,则可以使用标志指定条件。例如,这将加载,因为我们指定了:--conditions, -C
file-a.js ``condition-a
$ node --conditions=condition-a ./load-pkg-a.js
-
如果您使用捆绑器,则可以在配置中传入条件。例如,使用 Vite 时,您可以传入条件(下面列出了支持条件的工具的文档)。resolve.conditions
-
如果没有提供条件,则将无法解决并引发错误,因为没有
default
定义条件。
vite

包的情景模式
// package.json
"exports": {
".": {
"custom": "./index.custom.js",
"import": "./index.mjs",
"require": "./index.cjs"
}
}
配置
// vite.config.ts
resolve: {
conditions: ['custom'],
},
默认条件
每个运行时/解析器通常设置自己的默认条件(这些不是按顺序排列的):
- Node.js:
node
,,,import ``require
default
- Vite:
import
,,,,,,或require
default ``module``browser ``production ``development
- esbuild:
import
,,,,,,require ``default
browser ``node
module
句法
与子路径对象相反,条件导出对象 是exports字段内的任何对象,其键并非全部以 开头.。
条件(对象键)按优先级排序,并解析为第一个匹配的条目。(这可能感觉不直观,因为 JavaScript 中的对象在技术上是无序的。)对象也可以嵌套,以指定解析文件所需的条件组合。
条件键的顺序很重要
由于解析器具有默认条件,并且返回其匹配的第一个条件,因此应始终首先指定您的自定义条件(例如,无法达到require、import、以下的任何内容)。default
"exports": {
"import": "./prod.mjs",
"require": "./prod.cjs",
// 这将永远不会匹配,因为它低于默认条件
"this-will-never-match": "./dev.ts"
}
}
针对 Node.js 和浏览器
该exports字段可以定义一个适应 Node.js 或浏览器环境的入口点。在 Node.js 运行时中,用于解析的默认条件包括node、default和导入类型(import对于 ESM 为 ,require对于 CJS 为 )。
条件的优先级取决于包的条件导出对象中的关键顺序。
nord
{
"name": "pkg-a",
"exports": {
"node": "./dist/for-node.js", // Resolved by Node.js
"default": "./dist/for-browsers.js" // Resolved by other environments
}
}
该default
条件适用于非 Node 环境。或者,您可以使用browser
Vite、Webpack 和 Parcel 等 Web 应用打包工具能够识别的条件。