...
Back

有些包必须只存在一份

monorepo 会心平气和地装两份同一个库。对大多数库来说这只是浪费。对特定一类库来说,这是个 bug —— 而且会以完全另一副面孔出现。

有些包必须只存在一份

有些包必须只存在一份 🧩

在一个有多个包的工作区里,其中两个依赖同一个库的略微不同的版本,安装器就会精确地照做:两份副本,嵌套着,都在,没有警告。通常这只让你多付一点产物体积,别无代价。

但对某一类库来说,这不只是浪费。它是坏的,而且坏的方式从不提及这个库。


那一类是什么

当一个包持有其他包会读取的模块级状态时,它就必须是单例。具体来说:

  • UI 框架。 hooks、context 和协调器都住在模块作用域。两份副本意味着由其中一份渲染的组件读不到另一份提供的 context,而 hooks 会往错误的内部状态里派发。
  • 渲染引擎。 场景图和集成层依赖针对类的 instanceof 检查。由副本 A 构造的对象,在副本 B 的类上 instanceof 判定失败 —— 尽管这两个类逐字相同。
  • 状态容器。 两个 store,而你一半的应用订阅了那个永远不更新的。
  • 任何带插件注册表的东西。 插件注册进了其中一份的注册表;核心去读另一份,发现是空的。

共同线索是身份。这些库提供的不只是函数,而是身份会在之后被检查的对象。复制模块就等于复制身份,而每一次比较都悄悄返回假。


报错为什么从不点名真正的问题

这正是它昂贵的原因。症状包括:

  • "hooks 只能在组件内部调用" —— 而它显然就在一个组件内部。
  • 一个明明就是网格的对象,isMesh 检查失败。
  • 正上方提供的 context,正下方读出来是 undefined。
  • 一个注册成功了的插件,不存在。

这里每一条都会把你送去读你自己的代码,因为每一条描述的都是"你的代码做错了某件事"。没有一条会说"这个库装了两份"。恍然大悟发生在几分钟到几小时之后,而且几乎总是来自一个以前踩过的人。

一旦知道了,信号是:那个不可能发生的失败。 当一个不可能失败的检查正在失败时,别再调试那个检查,去数副本数量。


十秒钟内怎么查

# 装了几份?
find . -path '*/node_modules/three/package.json' -not -path '*/three/node_modules/*' \
  | xargs -I{} sh -c 'echo -n "{}: "; grep "\"version\"" {} | head -1'
 
# 同一个问题的包管理器原生版本:
npm ls three
bun pm ls | grep three
pnpm why three

输出多于一行就是答案。面对一个不可能的失败,这应该是你跑的第一条命令,而不是最后一条。


怎么预防

在你的内部包里把共享库声明为 peer 依赖。 工作区里一个渲染 UI 的包,应该把框架声明为 peer,而不是普通依赖。这恰好就是 peer 依赖的含义:我需要它,而且我需要宿主的那一份,不是我自己的一份。

在整个工作区里钉住完全相同的版本。 一个版本字符串,写在一处,到处引用。范围重叠是不够的 —— 两个范围兼容的包,在不同的日子仍然可能解析到不同版本。

加一个会让构建失败的检查。 一个断言"所有工作区包对共享库声明同一版本"的依赖一致性检查,几乎不花成本,却能把这一整类 bug 变成一条信息清晰的构建错误。如果你的 monorepo 工具链自带一个,打开它;如果没有,写个遍历工作区清单、比对版本的小脚本,这个下午花得很值。

把哪些包是单例写下来。 这是最容易被跳过、同时也是让规则活下去的那一步。新贡献者无从推断出"渲染引擎是特殊的"。贡献指南里的一份简短清单 —— 这些必须只存在一份,原因是什么,怎么检查 —— 意味着下一个人能在十秒而不是三小时内认出那个不可能的失败。