今天在读 qwen-audio-agent 代码时,发现它的导出很奇特,既没有typescript,也没有构建, 而是源码发布,于是询问AI这样的利弊之处,学到了:
- 模块缓存与复用的原理
- 使用JSDOC
- 如何保证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.jsbarrel 把所有模块都加载进来。 - 有利于按需加载和 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',必须使用列出的子路径。