package.json exports 字段的工程化陷阱:从双包危机到稳定发布契约
标签:JavaScript, 工程化, 最佳实践
一、为什么你的库在 ESM 和 CJS 下会"人格分裂"
当一个库同时发布 ESM 与 CJS 两种产物时,最隐蔽的坑不是语法,而是状态双重实例。假设你的库内部有一个模块级单例:
// lib/registry.js (CJS 产物)
const registry = new Map();
module.exports = { registry };
// lib/registry.js (ESM 产物)
const registry = new Map();
export { registry };
如果 package.json 用 main 指向 CJS、module 指向 ESM,打包器(Webpack/Vite)取 ESM,而用户的 Node 配置取 CJS,那么同一份代码会被加载两次,得到两个互不认识的 Map。这就是著名的 dual package hazard(双包危机):
// ❌ 反例:同一个包,两个世界
import { registry } from 'my-lib/esm/registry.js'; // Map A
const { registry: r2 } = require('my-lib/cjs/registry.js'); // Map B
registry === r2; // false — instanceof 也会失效!
对于依赖 instanceof、模块级缓存的库(如日志器、连接池),这会直接导致"连上了却查不到数据"的诡异 bug。
二、exports 字段如何终结双加载
exports 字段是唯一的可靠分界。它强制声明包的公共入口,并阻止消费者绕过契约直接深 require 内部路径:
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
}
}
关键点在于 条件顺序:import 在前、require 在后,并显式提供 types。Node 会按条件匹配,打包器会按 import 解析,运行时按 require 解析——两者拿到的是源码层面共享状态的同一份逻辑(理想情况下应做成状态共享的包装层)。
三、正反例对照:类型声明跟着出口走
// ❌ 反例:types 目录与产物目录分离,容易对不上版本
{
"main": "./dist/index.js",
"types": "./types/index.d.ts" // 漏改一处就类型漂移
}
// ✅ 正例:声明文件与产物同目录,由构建工具原子生成
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
注意 types 必须放在第一个条件,否则 TypeScript 会报 Cannot find module,因为你没有同时配置 typesVersions。
四、深路径的显式暴露,别让 exports 变成"开口闭口"
很多库为了向后兼容,会省略 exports,让消费者能 require('my-lib/internal')。一旦补上 exports,所有深路径默认被封死:
// ✅ 只暴露需要公开的入口,其余深路径一律 403
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./internal": null // 显式禁用
}
}
这既是安全边界(防止用户依赖不稳定的内部 API),也是重构自由(内部目录随便改,不破坏外部契约)。
五、落地清单
- 只发布一种格式,或让两种格式共享状态:优先纯 ESM(
"type": "module"),CJS 仅作为兼容壳。 exports+types成对出现,types永远排第一。- 用
./package.json白名单保留元数据访问,避免误伤工具链。 - 发布前做双格式冒烟测试:
// smoke.test.mjs
import assert from 'node:assert';
const esm = await import('my-lib');
const cjs = require('my-lib');
assert.strictEqual(esm.version, cjs.version); // 两者必须指向同一份单例
把 module 字段从你的 package.json 里删掉吧——在 exports 已经明确的今天,module 字段只剩历史包袱,只会制造"该信任谁"的分歧。
总结:exports 字段不是语法糖,而是你库的模块边界契约。正确使用它能一次性解决双包危机、类型漂移与深路径滥用三个工程顽疾;忽视它,则会在用户的生产环境中埋下一颗"状态分裂"的定时炸弹。
评论区
登录 后参与评论