《TypeScript高级编程》9.2 条件导出与 bundler 语义

本节深入 package.json 的 exports 字段:先讲它如何取代 main 与 module 实现真正的封装,再拆解子路径映射、条件对象与数组回退三种形态,重点讲清条件键的顺序语义为何决定成败,然后对照 bundler 与 Node 在条件集合上的差异,最后给出一个可直接复制的双包库配置。读完你能自己写出既被 Node 认识、又让 TypeScript 找到类型的导出表。

本节目标:把 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 标准条件键清单

条件键并非随便起名,社区有一套约定俗成的集合:

条件谁消费含义
typesTypeScript类型声明入口,必须最先匹配
importNode / 打包器 / TS由 ESM 的 import 或动态 import() 触发
requireNode / 打包器 / TS由 CJS 的 require() 触发
nodeNode / 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 调整
esbuildbrowser(platform=browser 时)、import/require、defaultmainFields 默认 ["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 分支。

运行时行为与类型不符。

按下面的顺序排查:

步骤命令看什么
1node --input-type=module -e "import('my-lib')"Node 实际解析到哪个文件
2npx tsc --traceResolution | grep my-libTypeScript 命中了哪个条件
3打包器 --debug 或 resolve 日志打包器命中了哪个条件
4npm 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 导入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes