前端开发··2 阅读·预计 7 分钟

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.jsonmain 指向 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),也是重构自由(内部目录随便改,不破坏外部契约)。

五、落地清单

  1. 只发布一种格式,或让两种格式共享状态:优先纯 ESM("type": "module"),CJS 仅作为兼容壳。
  2. exports + types 成对出现types 永远排第一。
  3. ./package.json 白名单保留元数据访问,避免误伤工具链。
  4. 发布前做双格式冒烟测试
// 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 字段不是语法糖,而是你库的模块边界契约。正确使用它能一次性解决双包危机、类型漂移与深路径滥用三个工程顽疾;忽视它,则会在用户的生产环境中埋下一颗"状态分裂"的定时炸弹。

0 评论

评论区

登录 后参与评论