本节目标:把
package.json里那个常被复制粘贴的exports字段彻底讲透。读完后你能说出条件键的匹配顺序为何是正确性的核心、types为什么要写在最前面、import/require/node/browser各由谁消费,能读懂ERR_PACKAGE_PATH_NOT_EXPORTED与TS1479的成因,并亲手写出一个让 Node、打包器与 TypeScript 三方都满意的双包导出表。
9.2 条件导出与 bundler 语义
上一节我们确认了「解析模式要与产物执行方式匹配」。但模式只决定用哪套算法,真正决定「这个导入最终落到哪个文件」的,是包作者在 package.json 里写下的 exports 字段。它是模块解析里信息密度最高、也最容易写错的一处配置——因为它同时被 Node、打包器和 TypeScript 三方读取,而三方的条件集合并不一致。
9.2.1 main、module、types 时代的三个问题
在 exports 出现之前,一个包用三个平级字段描述入口:
{
"name": "my-lib",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
这套方案有三个绕不开的问题:
| 问题 | 表现 |
|---|---|
| 无法封装 | 任何内部文件都能被 import "my-lib/dist/internal/secret" 直接引用 |
| 条件不可组合 | 只能表达「ESM 或 CJS」,无法表达「浏览器或 Node」「开发或生产」 |
| 三方解读不一 | module 字段从未被 Node 采纳,只有打包器认,属于事实标准 |
第二个问题的后果最严重:一个库同时要支持 Node 与浏览器时,只能靠 browser 字段或打包器插件硬凑,而 TypeScript 完全不读 browser,于是类型检查与运行时可能指向两个不同的实现。
exports 的设计目标就是一次性解决这三点:用一个可组合的条件树替代平级字段,并顺带完成封装——只要声明了 exports,未列出的子路径一律不可访问。
9.2.2 exports 的两种形态
exports 有两种写法,理解它们的区别是读懂后续一切的前提。
形态一:子路径映射简写。 值直接是字符串,表示「把这个子路径映射到某个文件」:
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./package.json": "./package.json"
}
}
形态二:条件对象。 值是对象,键是条件名,按条件命中不同文件:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
两种形态可以混用,但同一个子路径下不能既写字符串又写条件对象。还有一个常见的语法糖:如果整个包只有一个入口,可以直接把条件对象写在 exports 顶层:
{
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
这与 { ".": { ... } } 等价,但只适用于「没有子路径导出」的包。一旦你要暴露 my-lib/utils,就必须回到显式的 "." 写法。
9.2.3 顺序语义:本节最重要的一条规则
exports 的条件对象不是哈希表,而是有序列表。解析器从上到下逐键比较,第一个命中的条件立即胜出,之后不再继续。
这意味着下面这份配置是错的:
{
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.mjs"
}
}
}
default 是所有环境都命中的兜底条件,它被写在最前面,于是 import 分支永远不可达——ESM 导入方也会拿到 index.js。正确写法是把它挪到最后:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
由此得到两条铁律:
| 铁律 | 原因 |
|---|---|
default 必须写在最后 | 它无条件命中,写前面会短路后续所有分支 |
types 必须写在最前 | TypeScript 会把 types 当普通条件参与顺序匹配 |
第二条值得展开。TypeScript 解析 exports 时,条件集合里既有 import / require(取决于导入方),也有 types。如果顺序写成这样:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
}
}
TypeScript 会先命中 import,拿到 ./dist/index.mjs,然后退回到「隐式查找同名声明」的规则,去找 ./dist/index.d.mts。找到还好,找不到就直接报 TS7016。而把 types 放最前,解析器一步就拿到显式声明的 .d.ts,不依赖隐式推断。
9.2.4 标准条件键清单
条件键并非随便起名,社区有一套约定俗成的集合:
| 条件 | 谁消费 | 含义 |
|---|---|---|
types | TypeScript | 类型声明入口,必须最先匹配 |
import | Node / 打包器 / TS | 由 ESM 的 import 或动态 import() 触发 |
require | Node / 打包器 / TS | 由 CJS 的 require() 触发 |
node | Node / TS(node16 起) | Node 运行时环境 |
browser | 打包器(浏览器目标) | 浏览器环境 |
development / production | 打包器 | 构建模式区分 |
default | 所有消费方 | 兜底,必须最后 |
两个容易混淆的点:
node 与 import 是两个维度。 node 描述「在哪个运行时」,import 描述「用哪种语法导入」。二者可以组合,例如 "node": { "import": "...", "require": "..." } 嵌套表达「在 Node 下按语法再分」。
browser 只有打包器认。 Node 的条件集合里没有 browser,所以一个只提供 browser 分支而不提供 node 分支的包,在 Node 里会掉到 default。这也是为什么浏览器专用包通常还要提供一个 node 或 default 兜底。
9.2.5 typesVersions:给老解析器的后门
exports 的 types 条件只有 node16 / nodenext / bundler 三种模式才读。如果你的包还要被 moduleResolution: "node10" 的工程消费(这类工程在 2024 年之后仍然不少),就需要额外提供 typesVersions:
{
"typesVersions": {
"*": {
"*": ["dist/types/*"]
}
}
}
它的语义是:node10 模式在按目录找类型时,把匹配到的路径重写一遍。这样即使老工程不读 exports,也能找到 .d.ts。同时别忘了保留一个顶层 types 字段作为最基础的回退。
这也是双包发布容易出错的地方:新解析器走 exports,老解析器走 types + typesVersions,两条路径必须指向同一份类型语义,否则会出现「编辑器看到的类型和 CI 看到的类型不一致」的诡异现象。
9.2.6 数组回退
条件键的值除了字符串和对象,还可以是数组:
{
"exports": {
"./feature": [
"./dist/feature.node.mjs",
"./dist/feature.browser.mjs"
]
}
}
数组的语义是「依次尝试,第一个能解析成功的胜出」。注意这里的「成功」仅指文件存在、路径可解析,不涉及任何运行时行为。历史上它常被用来做「先试新路径、失败退回旧路径」的兼容,但今天更推荐用条件键显式表达,因为数组回退会掩盖配置错误——文件路径写错时,解析器会静默跳到下一个候选,你只会看到「行为不对」而看不到报错。
9.2.7 bundler 的条件集合与 customConditions
现在把视角切到打包器一侧。打包器与 Node 读取的字段集并不相同,这是「同一个包在 Node 能跑、打包后崩掉」这类问题的根源。
| 消费方 | 条件集合 | 备注 |
|---|---|---|
| Node(ESM) | node、import、default | 严格按 Node 文档 |
| Node(CJS) | node、require、default | 同上 |
| Vite(浏览器) | module、browser、import、development/production、default | 可用 resolve.conditions 调整 |
| esbuild | browser(platform=browser 时)、import/require、default | mainFields 默认 ["browser","module","main"] |
| TypeScript(bundler) | types、import、default | 不含 node、不含 browser |
最后一行尤其关键:TypeScript 在 bundler 模式下默认不加入 node 条件。如果你写的库用 node 条件区分了实现:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": "./dist/index.node.mjs",
"default": "./dist/index.browser.mjs"
}
}
}
那么前端工程用 bundler 检查时,node 分支不会被命中,类型解析会掉到 default。这通常没问题(类型相同),但如果两个分支的类型确实不同,就会出现类型与运行时不符。
TypeScript 提供了 customConditions 让你把打包器使用的私有条件补进来:
{
"compilerOptions": {
"moduleResolution": "Bundler",
"customConditions": ["development"]
}
}
这样 exports 里写的 "development": "./dist/index.dev.mjs" 也能被类型检查正确解析。建议:库作者每新增一个非标准条件键,就在文档里说明消费者需要配 customConditions;否则这个分支对 TypeScript 是隐形的。
9.2.8 一个完整的双包库配置
把上面所有规则组装成一个可直接复制的配置。目标是:Node 的 ESM 与 CJS 导入方各拿到正确的文件,打包器拿到 ESM,TypeScript 在三种解析模式下都能找到类型。
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"sideEffects": false
}
逐条说明:
| 配置 | 作用 |
|---|---|
"type": "module" | 让 .js 默认按 ESM 解析,.cjs 仍为 CJS |
顶层 main / types | 给 node10 解析器与旧工具兜底 |
exports["."] 的 types 在最前 | 三种解析模式都能一步拿到声明 |
exports["."] 的 default 在最后 | 不遮蔽 import / require |
"./package.json" | 许多工具需要读取,显式暴露 |
files: ["dist"] | 只发布产物,与 exports 的封装意图一致 |
sideEffects: false | 告诉打包器可安全 tree-shaking |
注意 sideEffects: false 是个强承诺:它声明包里任何模块被删掉都不影响行为。如果你的包里有「导入即注册」的副作用模块(例如自动注册的 polyfill、全局补丁),必须改成数组精确列出:
{
"sideEffects": ["./dist/register-polyfill.mjs"]
}
写错的后果是打包后功能静默失效,且极难定位。想了解 tree-shaking 的具体判定过程,可延伸阅读 Vite tree-shaking 深入 。
9.2.9 常见报错与排查
ERR_PACKAGE_PATH_NOT_EXPORTED(Node 运行时)。
导入了一个未在 exports 中声明的子路径。这是封装生效的正常表现,不是 bug。解法是包作者补上该子路径,或消费者改用已暴露的公开入口。
TS2307:Cannot find module 'my-lib/utils'。
TypeScript 找不到该子路径的类型。检查 exports["./utils"] 是否写了 types 条件,以及它是否排在 import / require 之后(顺序错误会导致命中 JS 文件后隐式查找失败)。
TS7016:Could not find a declaration file for module 'my-lib'.
包没有提供类型。若该包确实有类型但解析不到,多半是 types 条件位置错误或当前模式是 node10 而包只写了 exports。
TS1479:The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module...
CJS 文件里 import 了一个只提供 ESM 分支的包。三种解法:改用动态 import()、把当前文件改成 ESM、或要求该包提供 require 分支。
运行时行为与类型不符。
按下面的顺序排查:
| 步骤 | 命令 | 看什么 |
|---|---|---|
| 1 | node --input-type=module -e "import('my-lib')" | Node 实际解析到哪个文件 |
| 2 | npx tsc --traceResolution | grep my-lib | TypeScript 命中了哪个条件 |
| 3 | 打包器 --debug 或 resolve 日志 | 打包器命中了哪个条件 |
| 4 | npm pack --dry-run | 发布产物是否包含声明的文件 |
第 4 步常被忽略却最致命:exports 指向 ./dist/index.mjs,而 files 没把 dist 包含进去,本地开发一切正常,用户安装后全部报 ERR_MODULE_NOT_FOUND。发布前跑一次 npm pack --dry-run,看清单里有没有你声明的每一个文件。
导出表的维护还牵涉到版本兼容:删掉一个子路径、改掉 default 的指向,都属于破坏性变更。相关判断标准可延伸阅读 7.3 semver、发布与类型破坏性变更
;.d.ts 如何按 exports 布局生成,见 7.1 .d.ts 生成与 exports 映射
。边缘运行时的适配差异可延伸阅读 TypeScript 边缘运行时适配
。
小结
本节围绕 exports 建立了一套判断标准。第一,它是有序的条件树,第一个命中的键胜出,因此 default 必须放最后、types 必须放最前,顺序写错会静默地让分支不可达。第二,它同时被 Node、打包器与 TypeScript 三方读取,而三方的条件集合并不一致——TypeScript 在 bundler 模式下不带 node 条件,需要 customConditions 补齐私有条件。第三,exports 一旦声明就完成了封装,未列出的子路径全部不可访问,这既是收益也是维护负担。第四,sideEffects 与 files 这两个看似无关的字段,直接决定打包结果与发布产物是否正确,必须与 exports 一起校验。
到这里,一个模块「从哪来」的问题已经解决:解析模式决定算法,exports 决定落点。但还有一个更隐蔽的问题没有触及——当两个模块互相导入时,加载顺序会让其中一方在初始化阶段拿到一个尚未就绪的值。下一节 9.3 循环依赖与类型-only 导入
就专门处理这类结构性问题,并给出用 import type 从根源切断它的方法。想先看打包器侧的依赖图机制,可延伸阅读 Vite 依赖预构建
与 TypeScript 构建性能优化
。
阅读导航:上一节:9.1 moduleResolution 各模式对照 · 下一节:9.3 循环依赖与类型-only 导入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。