见闻
monorepo 在前端已经不是一个新奇的提法了,但它确实在变得更加普及。有名的项目如 React、babel、vue 的仓库都已经升级了 monorepo。
用上 monorepo 是 2021 年中旬的事情。那时候团队主要的代码仓库进行了技术改造,升级为了 monorepo 架构。那是我第一次亲身接触到这个改变我很多思路的东西。
如果认可我的想法,使用 espoir 将是对我最大的支持。
npm i -g espoir-cli@latest
GitHub: github.com/AntoineYANG…
轮子?
自从有了 Node.js,前端就觉醒了对于基建的强大兴趣。这个时代有一句特别著名的话——
凡是能用 js 写出来的,最后都会被用 js 写出来
造轮子,其乐无穷。
超脱于业务需求给自己/团队造轮子,更是其乐无穷。
本文也是一个造轮子的故事。
monorepo
先聊一聊为什么要用 monorepo。
monorepo 最明显的特点就是把所有代码都整合到了同一个大的代码仓库。用一个词描述就是一致性。团队里不同的应用项目,现在可以在同一个代码仓库中相互透明。仓库的管理也统一化了,而实际每一个项目的发布等等,则不再与代码仓库呈现出一对一的关系。
试想这样一个场景:项目 A 中使用了一个组件 foo,foo 一直由项目 A 维护。但一段时间后项目 B 需要使用相同的组件。B 应该 CV 大法,还是要求 A 分离出 foo 单独维护?
这个问题被 monorepo 解决了——项目 A、项目 B 和组件 foo,全部放到同一个仓库维护。如此,foo 的维护便可以统一进行,同时 A 和 B 也对其享有了知情权。
即使将来 A 和 B 对 foo 的要求有了一些变化,我们还是可以保留 foo 中共通的逻辑。
依赖管理
代码仓库合为一体了,那依赖管理怎么办?按照传统的 npm 管理工具,基于与 package.json 的一一对应,我们会得到这样的项目结构:
每一个 package 对应一个 node_modules,这看起来没有什么问题。但如果我们注意到这种情况:
同样的依赖被安装了两遍,对于常用的依赖和底层的依赖来说这样的情况会比比皆是。因此,我在 espoir 上装载了依赖管理模块,并把它设计为下图的结构:
既然 node_modules 中会产生大量重复安装的依赖,那把它们安装到同一个地方就解决了。对于每一个项目来说,引用依赖的方式也不会有变化。
版本兼容
依赖的版本兼容是一个重要的问题。假设 X 和 Y 同时依赖了一个包 abc,但是它们对于 abc 的版本要求并不兼容(如 X - abc@^4.2.6,Y - abc@^5.1.0)。如果只采用其中的一个的话很难想象在实际运行中会产生怎样的行为。而如果安装多个版本,怎样做到引入时相互隔离呢?这里我用到了链接的方式。
具体原理非常简单,由于 node 在解析依赖时,会从当前目录开始逐级向上搜索 node_modules。对于 X 和 Y,只需要放置一个 node_modules 文件夹,并添加一个名为 abc 的链接,指向目标版本的文件夹即可。
如果对同一个包的一些依赖并不完全冲突(如 abc@^5.0.2 abc@>=5),则会最小化实际安装的包,并让依赖它们的包共享。
影子依赖
说到版本问题就不得不再提一下大名鼎鼎的影子依赖问题。如果有了解 npm3 的同学,会知道 npm3 为了解决 npm2 时代大量包重复安装(即我们上一节讨论的)的问题,设计为了将所有依赖全部打平,统一放在 node_modules 目录里。
项目 A 依赖了包 X@^1.0.0,而此时安装的 X@1.0.0 依赖了包 abc@2.0.0。由于 abc 也被安装到了 node_modules 目录下,尽管项目 A 从未安装 abc,我们也甚至可以在我们的业务代码里写上:
import abc from 'abc';
abc.doSomeThing();
切记!这是非法访问!!
这看上去是一个很方便的结果,但它可能会带来意想不到的错误。假设某一天,X 的作者把包更新了 X@1.1.0 版本,X 本身是与 ^1.0.0 相兼容的,但是好巧不巧,X 的作者把依赖的 abc 的版本设置更新为了 3.0.0。当我们满怀轻松地更新 X 以后,我们的业务代码就出问题了。
abc.doSomeThing();
^
Uncaught TypeError: abc.doSomeThing is not a function
这就是影子依赖。使用未定义的、其他依赖指定的依赖,它的版本并不受自己控制。这层依赖关系因为第三方的更新而更新甚至删除,是潜藏的风险。
我在 espoir 中是如何解决影子依赖的呢?我使用了一个额外的目录,使用过 pnpm 的同学一定会觉得非常眼熟。
互通有无
让我们回到第一节的例子。现在 A, B, foo 都在同一个仓库下了。但是如果它们之间不能导入的话,monorepo 就没有意义了。我们可以想象在一个 monorepo 中,除了各个独立的项目,还有一些公共空间,能够在所有项目中复用。
但是如何去做呢?如果让 A 和 B 跨过根目录去访问 foo?那样太不合理了。对于 A 和 B 来说,foo 的存在应该是不明确的。A 和 B 的作用域(scope)都只存在于它们的路径之内,访问作用域外的内容是危险的。
这里需要注意两点,一是 A 与 B 必须要描述对 foo 的依赖关系,二是实际引用的 foo 必须位于对于 A 和 B 而言合法的路径上。满足这两个条件的很容易能够想到一个解决方案——dependencies。
简而言之,在 A 和 B 的 package.json 中添加对 foo 的依赖。但是当安装依赖时,不去远程仓库寻找同名的包,而是引用本地的 package,把它链接到 node_modules 中。如图:
面面俱到
当项目(package)和代码仓库不再一一对应,意外着在 monorepo 中需要针对每一个 package 独立地维护一些信息。lerna 是这个领域的专家。
在完成了 monorepo 的基础功能之上,我也在 espoir 中尝试做了一些辅助功能。
书同文
由于仓库是一体的,我们可以共享一些对团队做约束或风格化的配置,如 eslint 和 prettier。这些是在代码的层面。我们还希望团队的代码贡献清晰可查,这就要求团队成员的 commit message 符合规范。这也衍生出了很多工具,如 commitlint。
图注:在 VS Code 中使用 GitLens 插件,可以显示每一行代码的提交历史。
espoir 提供了交互式的命令行,辅助开发人员完成一次提交的描述,并在提交前用这些信息自动生成 changelog。
快捷指令
在 package.json 中定义 scripts 可以帮助我们执行快速地一些常用的操作。但当我们操作不同的项目时,需要在各个工作目录之间来回切换。espoir 提供了全局访问 monorepo 内所有命令的方法。
另一个常见的情景是,当我们想要完成的操作难以用一行命令行写完时,我们会选择编写一个 JS 脚本。在 package.json 的 scripts 中,偶尔会见到这样的内容:
{
"build": "node ./scripts/build.js",
"dev": "node ./scripts/dev.js"
}
为此,在 espoir 中,还额外检测了每个 package 的 /scripts/ /tasks/ 两个目录。可以不用累赘地写到 package.json 中直接使用它们:
模板
对于前端同学来说,写代码的“起手式”可能更多是一句脚手架命令行。
在 monorepo 中创建新的 package 也是常用的功能。espoir 预定义了一些简单的脚手架,可以使用现有的模板创建特定类型的 package。未来,espoir 可能将增加更多的模板,或者借助其他的 npm package 完成新项目的初始化。
结语
写前端以来,自己在前端代码仓库建设上思考过的、大约有价值做出来的东西,通过自己的想法,把它们实现出来。这就是 espoir 的由来。前端宽容的社区对于任何问题都有着不同的实现方式,选择学习其中一种,接受他人的思维,或者选择自己进行尝试,都是被接受的。
espoir 在一段时间内,属于我个人的自用品。今日有机会,把我在此之前的所见所闻,以及在一步一步磨合这个项目的过程中的所思所感,能够在这里分享出来。有同学能通过这篇文章了解到新的知识,或者愿意尝试 espoir,再或者想要和我一样亲自实现这些内容,都是能让我感到欣慰和高兴的。我尝试不去写太多,不过再这个工作中关注点确实也不算少,如果同学能够一路看到文章结尾的这里,向你表示感谢!
作者:宫商Kyusho,GitHub 主页