筆者目前所在團隊是使用 Monorepo 的方式管理全部的業務項目,而隨着項目的增多,穩定性以及開發體驗受到挑戰,諸多問題開始暴露,能夠明顯感覺到現有的 Monorepo 架構已經不足以支撐日漸龐大的業務項目。css
現有的 Monorepo 是基於 yarn workspace 實現,經過 link 倉庫中的各個 package,達到跨項目複用的目的。package manager 也理所固然的選擇了 yarn,雖然依賴了 Lerna,因爲發包場景較爲稀少,基本沒有怎麼使用。html
能夠總結爲如下三點:node
存在三種命令react
新人上手容易形成誤解,部分命令之間功能存在重疊。webpack
若是咱們須要發佈 app1,則會git
一個庫使用了不屬於其 dependencies 裏的 Package 稱之爲 Phantom dependencies(幻影依賴、幽靈依賴、隱式依賴),在現有 Monorepo 架構中該問題被放大(依賴提高)。github
因爲沒法保證幻影依賴的版本正確性,給程序運行帶來了不可控的風險。app 依賴了 lib-a,lib-a 依賴了 lib-x,因爲依賴提高,咱們能夠在 app 中直接引用 lib-x,這並不可靠,咱們可否引用到 lib-x,以及引用到什麼版本的 lib-x 徹底取決於 lib-a 的開發者。web
相同版本的 Package 可能安裝多份,打包多份。shell
假設存在如下依賴關係npm
最終依賴安裝可能存在兩種結果:
最終本地會安裝 3 份 lib-x,打包時也會存在三份實例,若是 lib-x 要求單例,則可能會形成問題。
假設存在如下依賴關係
當 (p)npm 安裝到相同模塊時,判斷已安裝的模塊版本是否符合新模塊的版本範圍,若是符合則跳過,不符合則在當前模塊的 node_modules 下安裝該模塊。即 lib-a 會複用 app 依賴的 lib-b@1.1.0。
然而,使用 Yarn v1 做爲包管理器,lib-a 會單獨安裝一份 lib-b@1.2.0。
Yarn 依賴提高,在 peerDependencies 場景下可能致使 BUG。
若 app2 忘記安裝 A@2.0.0,那麼結構以下
--apps
--app1
--app2
--node_modules
--A@1.0.0
--B@2.0.0
複製代碼
目前項目內存在三種引用方式:
假設一個 Monorepo 中的 package1 發佈至了 npm 倉庫,那麼 Monorepo 中的 app1 應當如何在 package.json 中編寫引用 package1 的版本號?
package1/packag.json
{
"name": "package1",
"version": "1.0.0"
}
複製代碼
app1/package.json
{
"name": "app1",
"dependencies": {
"package-1": "?" // 這裏的版本號應該怎麼寫?`*` or `1.0.0`
}
}
複製代碼
在處理 Monorepo 中項目的互相引用時,Yarn 會進行如下幾步判斷:
須要特別注意的是:
*
沒法匹配prerelease 版本
👉 Workspace package with prerelease version and wildcard dep version #6719。
假設存在如下場景:
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 ?
(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 困境
Fast, disk space efficient package packageManager
在 npm@3 以前, node_modules 的結構是乾淨且可預測的,由於 node_modules 中的每一個依賴項都有其本身的 node_modules 文件夾,其全部依賴項都在 package.json 中指定。
node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json
複製代碼
可是這樣帶來了兩個很嚴重的問題:
爲了解決這兩個問題,npm@3 從新思考了 node_modules 的結構,引入了平鋪的方案。因而就出現了下面咱們所熟悉的結構。
node_modules
├─ foo
| ├─ index.js
| └─ package.json
└─ bar
├─ index.js
└─ package.json
複製代碼
與 npm@3 不一樣,pnpm 使用另一種方式解決了 npm@2 所遇到的問題,而非平鋪 node_modules。
在由 pnpm 建立的 node_modules 文件夾中,全部 Package 都與自身的依賴項分組在一塊兒(隔離),可是依賴層級卻不會過深(軟連接到外面真正的地址)。
-> - a symlink (or junction on Windows)
node_modules
├─ foo -> .registry.npmjs.org/foo/1.0.0/node_modules/foo
└─ .registry.npmjs.org
├─ foo/1.0.0/node_modules
| ├─ bar -> ../../bar/2.0.0/node_modules/bar
| └─ foo
| ├─ index.js
| └─ package.json
└─ bar/2.0.0/node_modules
└─ bar
├─ index.js
└─ package.json
複製代碼
能夠發現,不少與包管理器相關的問題就此迎刃而解。
a scalable monorepo manager for the web
rush(x) xxx
一把梭,減小新人上手成本。同時 Rush 除了 rush add
以及 rushx xxx
等命令須要在指定項目下運行,其餘命令均爲全局命令,可在項目內任意目錄執行,避免了在終端頻繁切換項目路徑的問題。
Rush 中的許多命令支持分析依賴關係,好比 -t
(to) 參數:
$ rush install -t @monorepo/app1
複製代碼
該命令只會安裝 app1 的依賴及其 app1 依賴的 package 的依賴,即按需安裝依賴。
$ rush build -t @monorepo/app1
複製代碼
該命令會執行 app1 以及 app1 依賴的 package 的構建腳本。
相似的,還有 -f
(from) 參數,可使命令只做用於當前 package 以及依賴了該 package 的 package。
Monorepo 中的項目應當儘可能保證依賴版本的一致性,不然頗有可能出現重複打包以及其餘的問題。
Rush 則提供了許多能力來保證這一點,如rush check
、rush add -p package-name -m
以及 ensureConsistentVersions
。
有興趣的同窗能夠自行翻閱 Rush 的官方文檔,十分詳盡,對於一些常見問題也有說明。
傳統引用方式,構建完成後,app 直接引用 package 的構建產物。開發階段能夠經過構建工具提供的能力保證明時構建(如 tsc --watch)
package.json 中的 main
字段配置爲源文件的入口文件,引用該 package 的 app 須要將該 package 歸入編譯流程。
alias
適配繁瑣;main
字段設置爲源文件入口並配置 app 項目的 webpack,走後編譯形式。補充:rush build 命令是支持構建產物緩存的,若是 app 拆分粒度夠小,可複用的包足夠多,同時打包鏡像支持構建產物緩存的 set 與 get,就能夠作到增量構建 app。
在 PNPM 和 Yarn 支持 Workspace 能力以前,Rush 就誕生了。 Rush 的方法是將全部軟件包集中安裝在 common / temp 文件夾中,而後 Rush 建立從每一個項目到 common / temp 的符號連接。與 PNPM Workspace 本質上是等效的。
開啓 PNPM workspace 能力從而可使用 workspace:協議保證引用版本的肯定性,使用了該協議引用的 package 只會使用 Monorepo 中的內容。
{
"dependencies": {
"foo": "workspace:*",
"bar": "workspace:~",
"qar": "workspace:^",
"zoo": "workspace:^1.5.0"
}
}
複製代碼
推薦引用 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 *
這個問題相似於前面提到的 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 的直接依賴存在。
{
"name": "fake-project",
"version": "1.0.0",
"dependencies": {
"@fake-project/app1": "1.0.0",
"@fake-project/package1": "1.0.0"
}
}
複製代碼
安裝依賴時,(p)npm 首先下載直接依賴項,而後再下載間接依賴項,而且在安裝到相同模塊時,判斷已安裝的模塊版本(直接依賴項)是否符合新模塊(間接依賴項)的版本範圍,若是符合則跳過,不符合則在當前模塊的 node_modules 下安裝該模塊。
而 app1 和 package1 的直接依賴關係 lib-a 是該 fake-project 的間接依賴項,沒法知足上述判斷條件,因而按照對應 package.json 中描述的版本安裝。
Rush 能夠經過手動指定 preferredVersions
的方式,避免兩個可兼容版本的重複。這裏將 Monorepo 中 lib-a 的 preferredVersions
指定爲 1.2.0,至關於在該虛擬 project 下直接安裝了指定的版本的模塊,做爲直接依賴項。
{
"name": "fake-project",
"version": "1.0.0",
"dependencies": {
"@fake-project/app1": "1.0.0",
"@fake-project/package1": "1.0.0",
"lib-a": "1.1.0"
}
}
複製代碼
對於 Yarn,因爲 Yarn duplicate 的存在,就算在根目錄指定安裝肯定版本的 lib-a 也是無效的。 可是依舊有兩種方案能夠進行處理:
yarn.lock
;resolutions
字段。過於粗暴,不像 preferredVersions
能夠容許不兼容版本的存在,不推薦。須要謹記:在 Yarn 下消除重複依賴,也應該一個 Package 一個 Package 的去進行處理,當心使得萬年船。
因爲根目錄再也不存在 node_modules,故須要每一個項目安裝一個 prettier
做爲 devDependency 並編寫 .prettierrc.js
文件。
本着偷懶的原則,根目錄新建 .prettierrc.js
(不依賴任何第三方包),全局安裝 prettier
解決該問題。
先看一個場景,若在項目中使用 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 中,不存在依賴提高(提高也不必定靠譜),裝一系列的插件又過於繁瑣,則能夠經過打補丁的方式繞過。
一般會在項目中使用 husky
註冊 pre-commit
和 commit-msg
鉤子,用於校驗代碼風格以及 commit 信息。
很明顯,在 Rush 項目的結構下,根目錄是沒有 node_modules 的,沒法直接使用 husky
。
咱們能夠藉助 rush init-autoinstaller 的能力來達到同樣的效果,本節主要參考官方文檔 Installing Git hooks 以及 Enabling Prettier 的內容。
# 初始化一個名爲 rush-lint 的 autoinstaller
$ rush init-autoinstaller --name rush-lint
$ cd common/autoinstallers/rush-lint
# 安裝 lint 所需依賴
$ pnpm i @commitlint/cli @commitlint/config-conventional @microsoft/rush-lib eslint execa prettier lint-staged
# 更新 rush-lint 的 pnpm-lock.yaml
$ rush update-autoinstaller --name rush-lint
複製代碼
在 rush-lint
目錄下新增 commit-lint.js
以及 commitlint.config.js
,內容以下
commit-lint.js
const path = require('path');
const fs = require('fs');
const execa = require('execa');
const gitPath = path.resolve(__dirname, '../../../.git');
const configPath = path.resolve(__dirname, './commitlint.config.js');
const commitlintBinPath = path.resolve(__dirname, './node_modules/.bin/commitlint');
if (!fs.existsSync(gitPath)) {
console.error('no valid .git path');
process.exit(1);
}
main();
async function main() {
try {
await execa('bash', [commitlintBinPath, '--config', configPath, '--cwd', path.dirname(gitPath), '--edit'], {
stdio: 'inherit',
});
} catch (\_e) {
process.exit(1);
}
}
複製代碼
commitlint.config.js
const rushLib = require("@microsoft/rush-lib");
const rushConfiguration = rushLib.RushConfiguration.loadFromDefaultLocation();
const packageNames = [];
const packageDirNames = [];
rushConfiguration.projects.forEach((project) => {
packageNames.push(project.packageName);
const temp = project.projectFolder.split("/");
const dirName = temp[temp.length - 1];
packageDirNames.push(dirName);
});
// 保證 scope 只能爲 all/package name/package dir name
const allScope = ["all", ...packageDirNames, ...packageNames];
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"scope-enum": [2, "always", allScope],
},
};
複製代碼
注意:此處不須要新增 prettierrc.js
(根目錄已存在) 以及 eslintrc.js
(各項目已存在)。
根目錄新增 .lintstagedrc 文件
.lintstagedrc
{
"{apps,packages,features}/**/*.{js,jsx,ts,tsx}": [
"eslint --fix --color",
"prettier --write"
],
"{apps,packages,features}/**/*.{css,less,md}": ["prettier --write"]
}
複製代碼
完成了相關依賴的安裝以及配置的編寫,咱們接下來將相關命令執行註冊在 rush
中。
修改 common/config/rush/command-line.json
文件中的 commands
字段。
{
"commands": [
{
"name": "commitlint",
"commandKind": "global",
"summary": "Used by the commit-msg Git hook. This command invokes commitlint to lint commit message.",
"autoinstallerName": "rush-lint",
"shellCommand": "node common/autoinstallers/rush-lint/commit-lint.js"
},
{
"name": "lint",
"commandKind": "global",
"summary": "Used by the pre-commit Git hook. This command invokes eslint to lint staged changes.",
"autoinstallerName": "rush-lint",
"shellCommand": "lint-staged"
}
]
}
複製代碼
最後,將 rush commitlint
以及 rush lint
兩個命令分別與 commit-msg
以及 pre-commit
鉤子進行綁定。 common/git-hooks
目錄下增長 commit-msg
以及 pre-commit
腳本。
commit-msg
#!/bin/sh
node common/scripts/install-run-rush.js commitlint || exit $? #++
複製代碼
pre-commit
#!/bin/sh
node common/scripts/install-run-rush.js lint || exit $? #++
複製代碼
如此,便完成了需求。
通過上一節的處理,在 rush-lint
目錄下安裝了 eslint
以及 prettier
後,咱們便無需全局安裝了,只須要配置一下 VSCode 便可。
{
// ...
"npm.packageManager": "pnpm",
"eslint.packageManager": "pnpm",
"eslint.nodePath": "common/autoinstallers/rush-lint/node_modules/eslint",
"prettier.prettierPath": "common/autoinstallers/rush-lint/node_modules/prettier"
// ...
}
複製代碼
yarn | rush(x) | detail |
---|---|---|
yarn install | rush install | 安裝依賴 |
yarn upgrade | rush update | rush update 安裝依賴,基於 lock 文件 rush update --full 全量更新到符合 package.json 的最新版本 |
yarn add package-name | rush add -p package-name | yarn add 默認安裝版本號爲 ^ 開頭,可接受小版本更新 rush add 默認安裝版本號爲 ~ 開頭,僅接受補丁更新 rush add 可經過增長 --caret 參數達到與 yarn add 效果一致 rush add 不可一次性安裝多個 package |
yarn add package-name --dev | rush add -p package-name --dev | - |
yarn remove package-name | - | rush 不提供 remove 命令 |
- | rush build | 執行文件存在變動(基於 git)的項目的 build 腳本 rush build -t @monorepo/app1 表示只構建 @monorepo/app1 及其依賴的 package rush build -T @monorepo/app1 表示只構建 @monorepo/app1 依賴的 package,不包含其自己 |
- | rush rebuild | 默認執行全部項目的 build 腳本 |
yarn xxx(自定義腳本) | rushx xxx(自定義腳本) | yarn xxx 執行當前目錄下 package.json 中的 xxx 腳本(npm scripts) rushx xxx 同理。能夠直接執行 rushx 查看當前項目所支持的腳本命令。 |
# 從 git 拉取最新變動
$ git pull
# 更新 NPM 依賴
$ rush update
# 從新打包 @monorepo/app1 依賴的項目(不含包其自己)
$ rush rebuild -T @monorepo/app1
# 進入指定項目目錄
$ cd ./apps/app1
# 啓動項目
$ rushx start # or rushx dev
複製代碼