ESM 源码发布 + 显式子路径导出

10 阅读4分钟

今天在读 qwen-audio-agent 代码时,发现它的导出很奇特,既没有typescript,也没有构建, 而是源码发布,于是询问AI这样的利弊之处,学到了:

  1. 模块缓存与复用的原理
  2. 使用JSDOC
  3. 如何保证API的稳定性
 {"exports": {
    "./package.json": "./package.json",
    "./gateway-events": "./shared/protocol/gateway-events.mjs",
    "./gateway-client-protocol": "./shared/protocol/gateway-client-protocol.mjs",
    "./gateway-client-sdk": "./shared/gateway/client-sdk.mjs",
    "./gateway-client-profiles": "./shared/gateway/client-profiles.mjs",
    "./gateway-access-client": "./shared/gateway/access-client.mjs",
    "./gateway-remote-access": "./shared/gateway/remote-access.mjs",
     ......
}

优势 :

  • 对 Node 客户端零转译:Node 直接执行 .mjs,不需要等待库作者生成 dist,也不会出现 src 和 dist 不一致。
  • 调试体验好:堆栈直接指向真实源码,不需要 source map。
  • 入口清晰:每个子路径就是一组相对独立的 API,避免一个巨大的 index.js barrel 把所有模块都加载进来。
  • 有利于按需加载和 tree-shaking:客户端只导入自己需要的模块,不必把整个 Gateway、Desktop、WebUI 都拉进依赖图。
  • 封装性比“任意深层导入”好:exports 只放行白名单,内部目录重构不会立刻影响所有用户。
  • 适合当前这种“应用 + 扩展点/SDK”的形态:gateway-client-protocol、gateway-client-sdk、gateway-events 等本身就是稳定的集成边界。

代价和风险

  • 公开 API 面很大:package.json:30 开始的 exports 有 40 多个入口。每一个被导出的名字都会变成 semver 契约;删除、重命名或改变导出变量都可能构成 breaking change。
  • 没有类型声明:当前没有 types 或 .d.ts,TypeScript 用户只能得到 any 或手写声明,接入体验不如带类型声明的包。
  • 对运行时版本有要求:源码不做转译,package.json:248 要求 Node ^22.22.2 等版本;老 Node、老浏览器或不处理 node_modules 的构建工具可能直接失败。
  • 安装体积偏大:files 中包含 server/src/、shared/、web/dist/、桌面代码、文档和示例。即使用户只需要一个协议常量,npm 仍会安装整个应用包;这是比“模块重复”更现实的冗余。
  • 源码即公开实现:用户可以看到并依赖文件里的所有导出,而不只是你希望公开的那几个。长期看容易形成“隐性 API”。
  • 没有根入口:exports 没有 ".",所以 import 'qwen-audio-agent' 会失败。这是有意的“反 barrel”设计,但对新用户不够直观,需要在 README 里写清楚。
  • 打包期校验仍有边界:仓库已有 scripts/verify-package.mjs:49 检查 exports 直接目标是否存在于 npm 包中,但它不会递归解析所有相对依赖。以后新增传递依赖时,仍建议补一个干净安装后的导入冒烟测试。

不同的引入会不会有冗余?

结论:同一个物理文件不会因为“exports 导入 + 内部相对导入”自动变成两份,也不会重复求值。

Node ESM 的模块缓存 key 是解析后的文件 URL,不是 import 字符串。这就是 模块缓存复用。比如:

用户写了2个引用变量:

// gateway-client-protocol.mjs` 内部相对依赖了:GatewayClientEvent
import { GatewayClientProtocolEvent } from 'qwen-audio-agent/gateway-client-protocol'

// 直接依赖了GatewayClientEvent
import { GatewayClientEvent } from 'qwen-audio-agent/gateway-events'

两次最终都会解析到:

.../shared/protocol/gateway-events.mjs

因此Nodejs只会加载、求值一次,拿到的是同一个模块命名空间和同一份模块状态,在当前 Node 22 环境下验证过是同个模块。

会真正产生两份实例的典型情况是:

  • 同一个包被安装了两份,或者存在两个版本,例如嵌套 node_modules。
  • exports 的条件导出把不同环境指向不同的物理文件。
  • 包内本来就有 src/ 和 dist/ 两份相同代码。
  • 符号链接解析方式不同,例如 --preserve-symlinks。
  • 两个独立的构建产物分别打包了同一模块;这是产物之间的重复,不是同一个运行时依赖图里的重复。
  • 使用不同 query/hash 导入同一文件,例如 foo.mjs?raw,Node 会视为不同 URL。

bundler 也一样:Rollup、Webpack、Vite、esbuild 通常按解析后的文件路径去重,可能把模块合并进同一个 chunk,但不会让同一模块执行两次。re-export 也只会多一个很小的 facade,不会复制底层实现。

针对这个仓库的判断

这个模式是合理的,因为 qwen-audio-agent 更像“可扩展的运行时/应用”,而不是一个普通的小型工具库;它确实需要把协议、Client SDK、Gateway Process、Desktop 扩展点分别暴露给宿主。

但如果你希望外部开发者把其中一部分当作独立库使用,建议逐步拆分:

  • 把 gateway-client-protocol、gateway-events、realtime-events 这类纯协议内容拆成更小的 @qwen-audio-agent/protocol 包。
  • 给公开入口补 .d.ts 或至少完整的 JSDoc。
  • 在 CI 中增加“干净临时项目安装 tarball 后逐个 import() 所有 exports 入口”的测试。
  • 用 publint 检查 exports 配置。
  • 如果确认模块无副作用,再考虑 "sideEffects": false;不要未经审计直接加。
  • 在文档中明确:不支持 import 'qwen-audio-agent',必须使用列出的子路径。