-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
应用级 Monorepo 优化方案 #9
Comments
感谢可以写个 TL;DR 请使用 Rush.js |
受教 |
业务项目,不涉及到发npm包到仓库,请问是不是完全可以不适用lerna |
是的,但是随着业务发展,这些是很难预料的,目前来看成本低效果好的方案是 pnpm workspaces,后续要发包的话配合 changesets 就好了,能满足绝大部分场景。 |
再请教一下,业务项目的子包之间的互相引用,比如A引用B, 是先B打包,然后A引用B的bundle文件好一些呢?还是A去引用B, A的webpack配置里面 include在node_modules中的未打包的B |
「Package 引用规范」这一节是有描写相关内容的哈,最通用的方式是直接引用打包出来的产物,但是如果对热更新速度有要求,就要考虑使用源码引用了(前提是项目之间无法隔离调试) 如果有帮助的话可以给我 blog 一个 star 哈 |
为啥不直接用lerna |
lerna 无法完美配合 pnpm 使用,不支持 workspace 协议,一些 Monorepo 工具具备的能力都没跟上,比如构建缓存等等能力,同时维护不乐观 lerna/lerna#2703 |
lerna本身有自己的workspace,可以替换掉yarn的。缓存本身可以hoist的,也可以自己定制构建策略,lerna本身只提供一种管理多包工作流。不过hoist之后,可能存在的幽灵依赖和doppelganger,确实rush更优。维护的话,lerna其实已经可以以简单轻量的方式面对大多数场景了。另外,rush的话,微软自己19年就改用lerna了microsoft/just@feab43e#diff-04c6e90faac2675aa89e2176d2eec7d8R19 |
|
仅是内容讨论哈,
|
这是我实践的出来的结论,仅供讨论。 |
还有抄袭事件...我真不知道这事,turborepo 和 lage 的思路基本一模一样这个我是知道的,不过这篇文章也不是来批判哪个工具的,只是为了在工作中获得更好的开发体验而已,如果 lerna 能够解决你们团队的问题,使用它没问题,我们只是选择了我们眼中更优秀更拥抱未来的工具哈。 也感谢讨论,对于 lerna 我的确了解不多,因为他不能和 pnpm 完美结合,包管理器的缺陷目前来看是无法避免的,rush 也只是抱上了 pnpm 的大腿而已 |
eslint 配置是中心化的还是各个项目都有自己的 eslint 配置 |
各个项目有自己的 .eslintrc.js,统一引用内部 eslint 配置包即可 |
请问这套方案可以像lerna一样,检测到变更的包然后发布吗? |
可以的,看这篇文章 #12 |
请问一下,热更新需要怎么配置 |
你指的怎么配置 monorepo 内不构建的源码包吗?类似于 |
你好问一下,热更新的配置是在features 中配置 |
我之前的做法是在 package.json 新增一个自定义 entry,该入口指向源码,构建工具识别一下。不推荐,我认为对外的 package 应该要有独立的调试环境(如 storybook),而不是和某个 app/package 绑定调试,当然实在有需求也有 workaround,比如调研一下通用解决方案:直接把所有 monorepo 的包都重定向到 src,定制一个规则,我觉得也可行 |
lerna已复活,Nrwl接管了lerna,现在已经支持了pnpm,请问博主对lerna6是否有研究? |
没有研究 - - pnpm 足够成熟了,配合 turborepo 已经能够支撑大型项目了,个人感觉没有必要再去额外学习 lerna6 |
时隔两年看到大佬的文章,特来请教几个问题,目前是基于 pnpm(v8.14) 的 workspace:
|
|
好的谢谢回复~ |
rush monorepo使用pnpm8的情况下,需要开启 experiments.json中的 "usePnpmFrozenLockfileForRushInstall": true, 过滤安装才会生效, 可以看这个里的代码 #4344 files: |
前言
笔者目前所在团队是使用 Monorepo 的方式管理所有的业务项目,而随着项目的增多,稳定性以及开发体验受到挑战,诸多问题开始暴露,可以明显感受到现有的 Monorepo 架构已经不足以支撑日渐庞大的业务项目。
现有的 Monorepo 是基于 yarn workspace 实现,通过 link 仓库中的各个 package,达到跨项目复用的目的。package manager 也理所当然的选择了 yarn,虽然依赖了 Lerna,由于发包场景较为稀少,基本没有怎么使用。
可以总结为以下三点:
TL;DR
存在的问题
命令不统一
存在三种命令
新人上手容易造成误解,部分命令之间功能存在重叠。
发布速度慢
如果我们需要发布 app1,则会
Phantom dependencies
由于无法保证幻影依赖的版本正确性,给程序运行带来了不可控的风险。app 依赖了 lib-a,lib-a 依赖了 lib-x,由于依赖提升,我们可以在 app 中直接引用 lib-x,这并不可靠,我们能否引用到 lib-x,以及引用到什么版本的 lib-x 完全取决于 lib-a 的开发者。
NPM doppelgnger
相同版本的 Package 可能安装多份,打包多份。
假设存在以下依赖关系
最终依赖安装可能存在两种结果:
最终本地会安装 3 份 lib-x,打包时也会存在三份实例,如果 lib-x 要求单例,则可能会造成问题。
Yarn duplicate
假设存在以下依赖关系
当 (p)npm 安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。即 lib-a 会复用 app 依赖的 lib-b@1.1.0。
然而,使用 Yarn v1 作为包管理器,lib-a 会单独安装一份 lib-b@1.2.0。
peerDependencies 风险
Yarn 依赖提升,在 peerDependencies 场景下可能导致 BUG。
若 app2 忘记安装 A@2.0.0,那么结构如下
此时 B@2.0.0 会错误引用 A@1.0.0。
Package 引用规范缺失
目前项目内存在三种引用方式:
Package 引用版本不确定性
假设一个 Monorepo 中的 package1 发布至了 npm 仓库,那么 Monorepo 中的 app1 应当如何在 package.json 中编写引用 package1 的版本号?
package1/packag.json
app1/package.json
在处理 Monorepo 中项目的互相引用时,Yarn 会进行以下几步判断:
假设存在以下场景:
1.0.0
版本,此时远端仓库与本地 Monorepo 中代码一致;1.0.0
版本下迭代,无需变更版本号发布;*
或1.0.0
);直到某天,该需求特性需要提供给外部业务方使用。
1.0.0-beta.0
并进行发版;package1@1.0.0
供 app1 使用;package@1.0.0
已经落后 app1 先前使用的本地package@1.0.0
太多;这种不确定性,导致引用此类 package 时会经常犯嘀咕:我到底引用的是本地版本还是远端版本?为什么有时候是本地版本,有时候是远端版本?我想用上 package1 的最新内容还需要时刻保持与 package1 的版本号保持一致 ,那我干嘛用 Monorepo ?
yarn.lock 冲突
(p)npm 支持自动化解决 lockfile 冲突,yarn 需要手动处理,在大型 Monorepo 场景下,几乎每次分支合并都会遇到 yarn.lock 冲突。
yarn
,yarn.lock
会直接失效,全部版本更新到package.json
最新,风险太大,失去lockfile
的意义;Git conflict with binary files
,只能使用master
的提交再重新yarn
,流程繁琐。Automatically resolve conflicts in lockfile · Issue #2036 · pnpm/pnpm
可以发现,现有 Monorepo 管理方式缺陷过多,随着其内项目的不断增加,构建速度会越来越慢,同时程序的健壮性无法得到保证。仅凭开发人员自觉是不可靠的,我们需要一套解决方案。
推荐阅读:node_modules 困境
解决方案
这里选择 rush 以及 pnpm 作为最终解决方案。
为什么使用的是 pnpm + rush ?
而不是 pnpm workspace(+ changesets)?
而不是 npm7 workspace(+ lerna) ?
而不是 yarn workspace(+ lerna)?
monorepo 需要解决的是规模性问题:项目越来越大,依赖安装越来越慢,构建越来越慢,跑测试用例越来越慢。
「按需」就成为了关键词,pnpm 作为包管理器已经非常优秀,甚至可以按需安装依赖,但对于大型 monorepo 需要的能力还是有所欠缺的,所以我们引入了 rush 解决 monorepo 下的工程化问题,至于 yarn 和 npm,还停留在解决包管理器问题的阶段。
所以目标很明确:在 monorepo 规模越来越大的情况下,整个项目的复杂度始终维持在一个稳定的水准。
pnpm
在 npm@3 之前, node_modules 的结构是干净且可预测的,因为 node_modules 中的每个依赖项都有其自己的 node_modules 文件夹,其所有依赖项都在 package.json 中指定。
但是这样带来了两个很严重的问题:
为了解决这两个问题,npm@3 重新思考了 node_modules 的结构,引入了平铺的方案。于是就出现了下面我们所熟悉的结构。
与 npm@3 不同,pnpm 使用另外一种方式解决了 npm@2 所遇到的问题,而非平铺 node_modules。
在由 pnpm 创建的 node_modules 文件夹中,所有 Package 都与自身的依赖项分组在一起(隔离),但是依赖层级却不会过深(软链接到外面真正的地址)。
可以发现,很多与包管理器相关的问题就此迎刃而解。
Rush
rush(x) xxx
一把梭,减少新人上手成本。同时 Rush 除了rush add
以及rushx xxx
等命令需要在指定项目下运行,其他命令均为全局命令,可在项目内任意目录执行,避免了在终端频繁切换项目路径的问题。Rush 中的许多命令支持分析依赖关系,比如
-t
(to) 参数:该命令只会安装 app1 的依赖及其 app1 依赖的 package 的依赖,即按需安装依赖。
该命令会执行 app1 以及 app1 依赖的 package 的构建脚本。
类似的,还有
-f
(from) 参数,可以使命令只作用于当前 package 以及依赖了该 package 的 package。Monorepo 中的项目应当尽量保证依赖版本的一致性,否则很有可能出现重复打包以及其他的问题。
Rush 则提供了许多能力来保证这一点,如
rush check
、rush add -p package-name -m
以及ensureConsistentVersions
。有兴趣的同学可以自行翻阅 Rush 的官方文档,十分详尽,对于一些常见问题也有说明。
Package 引用规范
产物引用
传统引用方式,构建完成后,app 直接引用 package 的构建产物。开发阶段可以通过构建工具提供的能力保证实时构建(如 tsc --watch)
源码引用
package.json 中的
main
字段配置为源文件的入口文件,引用该 package 的 app 需要将该 package 纳入编译流程。alias
适配繁琐;引用规范
main
字段设置为源文件入口并配置 app 项目的 webpack,走后编译形式。Workspace protocol (workspace:)
开启 PNPM workspace 能力从而可以使用 workspace:协议保证引用版本的确定性,使用了该协议引用的 package 只会使用 Monorepo 中的内容。
推荐引用 Monorepo 内的 package 时统一使用该协议,引用本地最新版本内容,保证更改能够及时扩散同步至其他项目,这也是 Monorepo 的优势所在。
若一定要使用远端版本,需要在
rush.json
中配置具体 project (增加cyclicDependencyProjects
配置),详见 rush_json。很幸运的是 PNPM workspace 中
workspace:*
可以匹配 prerelease 版本 👉 Local prerelease version of packages should be linked only if the range is *问题记录
Monorepo Project Dependencies Duplicate
这个问题类似于前面提到的 Yarn duplicate,但并不是 Yarn 独有的。
假设存在以下依赖关系(将 Yarn duplicate 的例子进行改造,放在 Monorepo 场景中)
app1 以及 package1 同属于 Monorepo 内部 project。
在 Rush(pnpm)/Yarn 项目中,会严格按照 Monorepo 内 project 的 package.json 所声明的版本进行安装,即 app1 安装 lib-a@1.1.0,package1 安装 lib-a@1.2.0。
此时对 app1 进行打包,则 lib-a@1.1.0 和 lib-a@1.2.0 都会被打包。
对这个结果你也许会有一些意外,但仔细想想,又很自然。
换一种方式理解,整个 Monorepo 是一个大的虚拟 project,我们所有的 project 都作为这个虚拟 project 的直接依赖存在。
安装依赖时,(p)npm 首先下载直接依赖项,然后再下载间接依赖项,并且在安装到相同模块时,判断已安装的模块版本(直接依赖项)是否符合新模块(间接依赖项)的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。
而 app1 和 package1 的直接依赖关系 lib-a 是该 fake-project 的间接依赖项,无法满足上述判断条件,于是按照对应 package.json 中描述的版本安装。
解决方案:Rush: Preferred versions
Rush 可以通过手动指定
preferredVersions
的方式,避免两个可兼容版本的重复。这里将 Monorepo 中 lib-a 的preferredVersions
指定为 1.2.0,相当于在该虚拟 project 下直接安装了指定的版本的模块,作为直接依赖项。对于 Yarn,由于 Yarn duplicate 的存在,就算在根目录指定安装确定版本的 lib-a 也是无效的。
但是依旧有两种方案可以进行处理:
yarn.lock
;resolutions
字段。过于粗暴,不像preferredVersions
可以允许不兼容版本的存在,不推荐。需要谨记:在 Yarn 下消除重复依赖,也应该一个 Package 一个 Package 的去进行处理,小心使得万年船。
prettier
由于根目录不再存在 node_modules,故需要每个项目安装一个
prettier
作为 devDependency 并编写.prettierrc.js
文件。本着偷懒的原则,根目录新建
.prettierrc.js
(不依赖任何第三方包),全局安装prettier
解决该问题。eslint
先看一个场景,若在项目中使用
eslint-config-react-app
,除了需要安装eslint-config-react-app
,还需要安装一系列 peerDependencies 插件。为什么
eslint-config-react-app
不将这一系列插件作为 dependencies 内置,而是作为 peerDependencies?使用者并不需要关心预设配置内引用了哪些插件。具体讨论可以查看该 issue,里面有相关问题的讨论: Support having plugins as dependencies in shareable config #3458 。
总而言之:和 eslint 插件的具体查找方式有关,如果因为依赖提升失败(多版本冲突),导致需要的插件被装在了非根目录 node_modules 下,就可能产生问题,而用户自行安装 peerDependencies 可以保证不会出现该问题。
当然,我们也发现一些开源的
eslint
预设配置不需要安装 peerDependencies,这些预设利用了 yarn 和 npm 的扁平 node_modules 结构,也就是依赖提升,装的包都被提升至根目录 node_modules,故可以正常运作。即便如此,在基于 Yarn 的 Monorepo 中,依赖一旦复杂起来,就可能出现插件无法被查找到的情况,能够正常运转就像一个有趣的巧合。在 Rush 中,不存在依赖提升(提升也不一定靠谱),装一系列的插件又过于繁琐,则可以通过打补丁的方式绕过。
git hooks
通常会在项目中使用
husky
注册pre-commit
和commit-msg
钩子,用于校验代码风格以及 commit 信息。很明显,在 Rush 项目的结构下,根目录是没有 node_modules 的,无法直接使用
husky
。我们可以借助 rush init-autoinstaller 的能力来达到一样的效果,本节主要参考官方文档 Installing Git hooks 以及 Enabling Prettier 的内容。
在
rush-lint
目录下新增commit-lint.js
以及commitlint.config.js
,内容如下commit-lint.js
commitlint.config.js
注意:此处不需要新增
prettierrc.js
(根目录已存在) 以及eslintrc.js
(各项目已存在)。根目录新增
.lintstagedrc 文件
.lintstagedrc
完成了相关依赖的安装以及配置的编写,我们接下来将相关命令执行注册在
rush
中。修改
common/config/rush/command-line.json
文件中的commands
字段。最后,将
rush commitlint
以及rush lint
两个命令分别与commit-msg
以及pre-commit
钩子进行绑定。common/git-hooks
目录下增加commit-msg
以及pre-commit
脚本。commit-msg
pre-commit
如此,便完成了需求。
避免全局安装 eslint 以及 prettier
经过上一节的处理,在
rush-lint
目录下安装了eslint
以及prettier
后,我们便无需全局安装了,只需要配置一下 VSCode 即可。附录
常用命令
rush update --full 全量更新到符合 package.json 的最新版本
rush update --purge 清空缓存并重新安装 rush purge 清空缓存
rush add 默认安装版本号为 ~ 开头,仅接受补丁更新
rush add 可通过增加 --caret 参数达到与 yarn add 效果一致
rush add 不可一次性安装多个 package,可以先更改 package.json 再统一执行 rush update
rush build -t @monorepo/app1 表示只构建 @monorepo/app1 及其依赖的 package
rush build -T @monorepo/app1 表示只构建 @monorepo/app1 依赖的 package,不包含其本身
rushx xxx 同理。可以直接执行 rushx 查看当前项目所支持的脚本命令。
工作流
参考文章
The text was updated successfully, but these errors were encountered: