《TypeScript高级编程》7.2 双包(ESM/CJS)与类型解析

同一个包如何同时被 ESM 与 CJS 消费者使用,又让两套类型各自对得上?本节先讲清 type 字段与文件扩展名如何共同决定模块格式,再给出 tsup 双入口构建与 exports 双条件的完整配置,最后剖析双包最危险的副作用——双包风险导致 instanceof 失效与状态分裂,并给出收敛策略。

本节目标:搞清楚「一个包同时服务 ESM 与 CJS 消费者」的完整链路。读完你会知道 type: "module" 到底改变了什么、为什么 .js 与 .mjs 必须配不同的声明文件、exports 的双条件该怎么写才不会让某一类消费者拿到隐式 any,以及双包最危险的副作用「双包风险」是怎么发生的、要不要为此放弃双包。

7.2 双包(ESM/CJS)与类型解析

上一节我们把类型入口铺好了,但只铺了一条。现实是:你的包会被 ESM 项目 import,也会被老 CJS 项目 require。同一个包名,两套模块系统,两份产物,两份声明——这就是「双包」(dual package)。

双包不是 TypeScript 的发明,是 Node 生态的过渡期产物。它的复杂度几乎全部来自一件事:模块格式的判定规则不止一条,而且不同工具判定的依据不同。这一节我们把这些规则一条条钉死。

7.2.1 模块格式由谁决定

Node 判定一个 .js 文件是 ESM 还是 CJS,只看两件事,且有优先级:

判定依据结果优先级
扩展名 .mjs强制 ESM最高
扩展名 .cjs强制 CJS最高
最近的 package.json 的 type: "module".js 视为 ESM中
最近的 package.json 的 type: "commonjs" 或缺失.js 视为 CJS低

关键在于第三、四行:.js 的格式取决于它向上找到的第一个 package.json。这意味着同一个 dist/index.js,在 type: "module" 的包里是 ESM,在没写 type 的包里是 CJS——文件名一模一样,语义完全不同。

这就是双包麻烦的根源:如果你只发一个 dist/index.js,那么包的模块格式是全局开关,不可能同时满足两类消费者。要双包,就必须让产物文件名带上格式信息,或者用两套目录配两份 package.json。

7.2.2 三种发布策略

在动手前先想清楚要不要双包。三条路的代价差异很大:

策略产物兼容性代价
只发 ESMdist/index.js + type: "module"现代工具链全支持,老 CJS 项目不行最低,但会丢掉部分用户
只发 CJSdist/index.cjs 或 .js全兼容(Node 22 起可 require ESM 另说)低,但无法被纯 ESM 环境 tree-shake
双包index.js + index.cjs + 两份声明最好构建、类型、状态三处都要额外处理

判断标准很简单:你的包会被 require() 吗? 如果目标用户里有大量还在 CJS 的框架(老版本 Jest 配置、部分 Electron 主进程、老的 CLI 工具),双包值得。如果用户全是 Vite / Next / 现代 Node,只发 ESM 更省心。

Node 22 之后支持 require(esm),这让「只发 ESM」的可行性提高了一档,但类型层面 TypeScript 仍有约束,不能盲目乐观。

7.2.3 type 字段与目录结构

双包最常见的落地形态是「双目录 + 双 package.json」:

dist/
  esm/
    package.json     { "type": "module" }
    index.js
    index.d.ts
  cjs/
    package.json     { "type": "commonjs" }
    index.cjs
    index.d.cts

注意 dist/esm/package.json 里只有一行 {"type":"module"}——它是个标记文件,用来把该目录下的 .js 钉成 ESM,而不必把所有文件都改名成 .mjs。这是社区通行的做法,好处是产物文件名干净,坏处是发布包里会多出两个只有一行的 package.json,容易被误认为配置泄漏。

另一种形态是「同目录 + 扩展名区分」:

dist/
  index.js        ESM(靠根 package.json 的 type: module)
  index.cjs       CJS(扩展名强制)
  index.d.ts      ESM 声明
  index.d.cts     CJS 声明

两种形态都行,但声明文件的扩展名必须与它描述的 JS 匹配:描述 ESM 的用 .d.ts(且所在 package.json 是 type: module)或 .d.mts,描述 CJS 的用 .d.cts。错配会直接触发上一节的 TS1479。

7.2.4 用 tsup 构建双产物

手写两套 tsc 配置很啰嗦,tsup 一行搞定:

npx tsup src/index.ts --format esm,cjs --dts --clean

它会产出 dist/index.js(ESM)、dist/index.cjs(CJS),以及对应的 dist/index.d.ts、dist/index.d.cts。等价配置写进 tsup.config.ts 更可控:

import { defineConfig } from "tsup";
export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  sourcemap: true,
  clean: true,
  target: "node18",
  outExtension({ format }) {
    return { js: format === "esm" ? ".js" : ".cjs" };
  },
});

dts: true 背后其实是 tsup 起了一个 tsc 进程专门产声明,所以上一节的 declaration 注意事项在这里同样成立:未导出的具名类型出现在公开 API 里,会报 TS4023。

如果你在 monorepo 里用 rollup,等价方案是 rollup-plugin-dts,思路一致:一次构建出多份格式。构建管线的整体组织可延伸阅读 TypeScript 构建性能优化 与 TypeScript 工程化进阶 。

7.2.5 exports 双条件配置

产物有了,接下来把它们接到 exports 上。这是双包的核心配置:

{
  "name": "mylib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"]
}

这份配置有几处必须读懂:

  • types 是嵌套的。外层先按 import / require 分流,内层再各自给 types。这样 ESM 消费者拿到 .d.ts,CJS 消费者拿到 .d.cts,两套声明的模块格式与实际运行时的模块格式一致。
  • 每个 types 都在自己那层排第一。这是上一节的规则在嵌套结构里的延续:只要某层同时有 types 和其他条件,types 就必须在最前。
  • 顶层 main 与 types 保留。它们服务于不读 exports 的老解析器(moduleResolution: "node10")。
  • files: ["dist"] 保证声明产物真的进了发布包。

一个容易忽略的点:exports 里 import 条件的 types 用 .d.ts 而非 .d.mts,前提是包根 package.json 有 type: "module"。如果你没写 type: "module",那么 .d.ts 会被判定为 CJS,ESM 消费者拿到的类型就带错了模块语义。要么写 type: "module" 配 .d.ts,要么用 .d.mts,二选一,别混。

7.2.6 条件匹配是深度优先的首个命中

上一节说「首个命中即停」,在嵌套结构里要说得更精确:解析器自上而下遍历键,遇到第一个能命中的分支就进去,进去之后不再回头。

{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "default": "./dist/index.cjs"
    }
  }
}

这份配置里,ESM 消费者命中 import 分支;CJS 消费者跳过 import,命中 default。看起来对——但 CJS 消费者永远拿不到类型,因为 default 那层没有 types 条件,TypeScript 会去 ./dist/index.cjs 旁边找 index.d.cts,找不到就报 TS7016。

正确的写法是每个分支都自带 types:

{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

注意这里把 default 换成了 require。优先用 require 而不是 default 兜底,因为 default 会被任何条件命中(包括某些打包器的自定义条件),而 require 精确对应 CJS。只有当你确实想要「所有其他情况都走这里」时才用 default,且它必须排最后。

写法ESM 消费者CJS 消费者
只有 import + default拿到类型无类型,TS7016
import + require 各自带 types拿到 .d.ts拿到 .d.cts

子路径导出同理。若你允许 import "mylib/utils",需要显式列出 "./utils",并给它同样的双条件结构。exports 是白名单,没列出的路径不可访问——这是特性,不是 bug。

7.2.7 双包风险:instanceof 失效与状态分裂

双包最危险的后果叫「双包风险」(dual package hazard)。它的机制是:同一个包的两份产物会被同时加载进同一个进程。

设想你的包导出一个类和它的判断函数:

export class Token {
  constructor(public value: string) {}
}
export function isToken(x: unknown): x is Token {
  return x instanceof Token;
}

消费者 A 的代码是 ESM,import { Token, isToken } from "mylib";它依赖的某个 CJS 库内部 require("mylib") 拿到了 CJS 那份。于是进程里出现了两个 Token 类,它们不是同一个构造函数。结果:

const t = new Token("abc");
isToken(t);
// 若 isToken 来自 CJS 副本、t 来自 ESM 副本,结果是 false

instanceof 静默返回 false,没有任何报错。比这更隐蔽的是状态分裂:模块顶层的 Map 缓存、计数器、单例注册表,两份副本各存一份,你在 A 处注册、在 B 处查不到。

收敛手段有三条,按推荐度排序:

手段做法效果
用 Symbol.hasInstance 或品牌字段判型不靠 instanceof,靠 x?.[Symbol.for("mylib.token")]根治
只发 ESM放弃 CJS 用户根治但损失兼容
文档声明「同进程只用一种格式」靠约定治标

Symbol.for() 是全局注册表,两份副本拿到的是同一个 symbol,所以品牌字段法天然跨副本有效:

const TOKEN_BRAND = Symbol.for("mylib.token");
export class Token {
  readonly [TOKEN_BRAND] = true;
  constructor(public value: string) {}
}
export function isToken(x: unknown): x is Token {
  return typeof x === "object" && x !== null && TOKEN_BRAND in x;
}

如果你的包里有全局状态(缓存、连接池、注册表),双包几乎是必然踩坑。这类包强烈建议只发 ESM,或者把状态收敛到消费者显式注入的实例里。相关运行时边界问题可延伸阅读 1.2 类型擦除与运行时边界 。

7.2.8 NodeNext 下的扩展名与自我引用

moduleResolution: "NodeNext" 是发布包的推荐配置,因为它模拟真实 Node 的解析行为——你在本地能跑通,装到用户机器上也能跑通。代价是它很严格:

// 错误:相对导入必须写扩展名
import { helper } from "./helper";
// 正确
import { helper } from "./helper.js";

注意写的是 .js 而不是 .ts。TypeScript 不会把 .js 改写成 .ts 去找源文件,而是按 .js 去找,并允许它对应到 helper.ts。这个约定第一次见很别扭,但它是唯一能让产物在 Node 里直接可执行的方式。

包内自我引用也受 exports 管辖。如果你的包内部模块想用公开入口的路径:

// 包内部 src/internal/foo.ts
import { Token } from "mylib";

这要求 exports 里确实有 ".",且 mylib 是包自己的名字。自我引用在 monorepo 里很实用(避免一堆 ../../../ 相对路径),但它会把内部模块和公开 API 绑死——改 exports 就可能把内部代码弄坏。谨慎使用。

各解析模式(node10 / node16 / nodenext / bundler)的完整对照,以及条件导出在打包器里的语义差异,是第 9 章的主题,可先延伸阅读 9.1 moduleResolution 各模式对照 与 9.2 条件导出与 bundler 语义 。

7.2.9 常见错误与排查

错误一:TS1479: The current file is a CommonJS module whose imports will produce 'require' calls

你在 CJS 上下文里 import 了一个 ESM-only 的包。解法是把该依赖改成动态 import(),或者自己也发双包。注意 require(esm) 只在较新的 Node 上可用,且类型层面仍受限。

错误二:ESM 消费者报 TS7016,CJS 消费者正常

典型症状是 exports 的 import 分支缺 types。按 7.2.5 的结构改成嵌套双条件即可。

错误三:ERR_REQUIRE_ESM

CJS 侧 require 到了 ESM 产物。检查 require 条件是否指向 .cjs 文件,以及 dist/index.cjs 是否真的存在(构建时 format 漏了 cjs)。

错误四:类型显示正常但运行时报 is not a function

声明与产物不同源:types 指向了旧的 .d.ts,而 JS 已经换了一版。npm pack --dry-run 看一眼实际入包的文件时间戳,比读配置快。

错误五:monorepo 里本地链接正常,发布后报模块找不到

本地用 workspace:* 时走的可能是软链与源码,没经过 exports 校验;发布后走真实解析。**在 CI 里加一步「装 tarball 后跑冒烟测试」**能提前暴露这类问题。

7.2.10 双包自检清单

检查项通过标准
type 字段与 ESM 产物所在目录一致,不遗漏
产物扩展名ESM 用 .js/.mjs,CJS 用 .cjs,无歧义
声明扩展名.d.ts 配 ESM,.d.cts 配 CJS,不交叉
exports 结构import / require 各自嵌套 types
types 位置每层条件对象的第一项
顶层 main/types保留,兼容 node10
files包含全部产物目录
全局状态无,或已用 Symbol.for 品牌字段隔离
冒烟测试CI 中基于 npm pack 产物验证双格式

小结

本节把双包拆成三层来看。判定层:Node 靠扩展名和最近的 package.json 的 type 字段决定模块格式,.js 的语义因此是「上下文相关」的,这正是双包复杂的根源。构建与配置层:用 tsup 的 --format esm,cjs --dts 一次产出两套产物与两套声明,再用 exports 的嵌套条件把 import 与 require 分流,每层都让 types 排第一、各自指向格式匹配的声明文件;require 优先于 default 兜底。风险层:双包风险会让同一份代码在一个进程里存在两个副本,instanceof 静默失效、模块级状态分裂;根治手段是品牌字段判型(Symbol.for)或干脆只发 ESM。

到这里,包已经能正确交付了。但还有一个问题没碰:当你升级版本、改动公开类型时,哪些改动是破坏性的? 类型层的破坏性变更比运行时更难察觉——运行时没报错,用户却编译不过。下一节 7.3 semver、发布与类型破坏性变更 就来处理这件事。想先了解 Node 模块系统本身的细节,可延伸阅读 Node.js 模块系统与 ESM 与 TypeScript 模块解析:ESM 与 CJS 。

阅读导航:上一节:7.1 .d.ts 生成与 exports 映射 · 下一节:7.3 semver、发布与类型破坏性变更 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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