Monorepo 的這些坑,咱們幫你踩過了!

前言

筆者目前所在團隊是使用 Monorepo 的方式管理全部的業務項目,而隨着項目的增多,穩定性以及開發體驗受到挑戰,諸多問題開始暴露,能夠明顯感覺到現有的 Monorepo 架構已經不足以支撐日漸龐大的業務項目。css

現有的 Monorepo 是基於 yarn workspace 實現,經過 link 倉庫中的各個 package,達到跨項目複用的目的。package manager 也理所固然的選擇了 yarn,雖然依賴了 Lerna,因爲發包場景較爲稀少,基本沒有怎麼使用。html

能夠總結爲如下三點:node

  • 經過 yarn workspace link 倉庫中的 package
  • 使用 yarn 做爲 package manager 管理項目中的依賴
  • 經過 lerna 在應用 app 構建前按照依賴關係構建其依賴的 packages

存在的問題

命令不統一

存在三種命令react

  1. yarn
  2. yarn workspace
  3. lerna

新人上手容易形成誤解,部分命令之間功能存在重疊。webpack

發佈速度慢

monorepo1

若是咱們須要發佈 app1,則會git

  1. 全量安裝依賴,app一、app二、app3 以及 package1 至 package6 的依賴都會被安裝;
  2. package 所有被構建,而非僅有 app1 依賴的 package1 與 package2 被構建。

Phantom dependencies

一個庫使用了不屬於其 dependencies 裏的 Package 稱之爲 Phantom dependencies(幻影依賴、幽靈依賴、隱式依賴),在現有 Monorepo 架構中該問題被放大(依賴提高)。github

monorepo-2

因爲沒法保證幻影依賴的版本正確性,給程序運行帶來了不可控的風險。app 依賴了 lib-a,lib-a 依賴了 lib-x,因爲依賴提高,咱們能夠在 app 中直接引用 lib-x,這並不可靠,咱們可否引用到 lib-x,以及引用到什麼版本的 lib-x 徹底取決於 lib-a 的開發者。web

NPM doppelgnger

相同版本的 Package 可能安裝多份,打包多份。shell

假設存在如下依賴關係npm

monorepo-3

最終依賴安裝可能存在兩種結果:

  1. lib-x@^1 * 1 份,lib-x@^2 * 2 份
  2. lib-x@^2 * 1 份,lib-x@^1 * 2 份

最終本地會安裝 3 份 lib-x,打包時也會存在三份實例,若是 lib-x 要求單例,則可能會形成問題。

Yarn duplicate

Yarn duplicate 及解決方案

假設存在如下依賴關係

monorepo-4

當 (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。

  1. app1 依賴 A@1.0.0
  2. app2 依賴 B@2.0.0
  3. B@2.0.0A@2.0.0 做爲 peerDependency,故 app2 也應該安裝 A@2.0.0

若 app2 忘記安裝 A@2.0.0,那麼結構以下

--apps
    --app1
    --app2
--node_modules
    --A@1.0.0
    --B@2.0.0
複製代碼

此時 B@2.0.0 會錯誤引用 A@1.0.0

Package 引用規範缺失

目前項目內存在三種引用方式:

  1. 源碼引用:使用包名引用。須要配置宿主項目的構建腳本,將該 Package 歸入構建流程。相似於直接發佈一個 TypeScript 源碼包,引用該包的項目須要作必定的適配。
  2. 源碼引用:使用文件路徑引用。能夠理解「宿主在自身 src 以外的源文件」,即宿主項目源代碼的一部分,而非 Package。宿主須要提供該全部依賴,在 Yarn 依賴提高的前提下達到了跨項目複用,但存在較大風險。
  3. 產物引用。打包完成,直接經過包名引用產物。

Package 引用版本不肯定性

假設一個 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 會進行如下幾步判斷:

  1. 判斷當前 Monorepo 中,是否存在匹配 app1 所需版本的 package1;
  2. 若存在,執行 link 操做,app1 直接使用本地 package1;
  3. 若不存在,從遠端 npm 倉庫拉取符合版本的 package1 供 app1 使用。

須要特別注意的是:* 沒法匹配 prerelease 版本 👉 Workspace package with prerelease version and wildcard dep version #6719

假設存在如下場景:

  1. package1 此前已經發布了 1.0.0 版本,此時遠端倉庫與本地 Monorepo 中代碼一致;
  2. 產品同窗提了一個只服務於 Monorepo 內部應用的需求;
  3. package1 在 1.0.0 版本下迭代,無需變動版本號發佈;
  4. Yarn 判斷 Monorepo 中的 package1 版本知足了 app1 所需版本(*1.0.0);
  5. app1 順利使用上 package1 的最新特性。

直到某天,該需求特性須要提供給外部業務方使用。

  1. pacakge1 將版本改成 1.0.0-beta.0 並進行發版;
  2. Yarn 判斷當前 Monorepo 中的 package1 版本不知足 app1 所需版本;
  3. 從遠端拉取 package1@1.0.0 供 app1 使用;
  4. 遠端 package@1.0.0 已經落後 app1 先前使用的本地 package@1.0.0 太多;
  5. 準備事故通報以及覆盤。

這種不肯定性,致使引用此類 package 時會常常犯嘀咕:我到底引用的是本地版本仍是遠端版本?爲何有時候是本地版本,有時候是遠端版本?我想用上 package1 的最新內容還須要時刻保持與 package1 的版本號保持一致 ,那我幹嗎用 Monorepo ?

yarn.lock 衝突

(p)npm 支持自動化解決 lockfile 衝突,yarn 須要手動處理,在大型 Monorepo 場景下,幾乎每次分支合併都會遇到 yarn.lock 衝突。

  • 不解決衝突無腦 yarnyarn.lock 會直接失效,所有版本更新到 package.json 最新,風險太大,失去 lockfile 的意義;
  • 人工解決衝突每每會出現 Git conflict with binary files,只能使用 master 的提交再從新 yarn,流程繁瑣。

Automatically resolve conflicts in lockfile · Issue #2036 · pnpm/pnpm

能夠發現,現有 Monorepo 管理方式缺陷過多,隨着其內項目的不斷增長,構建速度會愈來愈慢,同時程序的健壯性沒法獲得保證。僅憑開發人員自覺是不可靠的,咱們須要一套解決方案。

推薦閱讀:node_modules 困境

解決方案

pnpm

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
複製代碼

可是這樣帶來了兩個很嚴重的問題:

  1. 依賴層級過深在 Windows 下會出現問題;
  2. 同一 Package 做爲其餘多個不一樣 Package 的依賴項時,會被拷貝不少次。

爲了解決這兩個問題,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
複製代碼
  1. 基於非扁平化的 node_modules 目錄結構,解決 Phantom dependencies。Package 只可觸達自身依賴。
  2. 經過軟鏈複用相同版本的 Package,避免重複打包(相同版本),解決 NPM doppelgnger(順帶解決磁盤佔用)。

能夠發現,不少與包管理器相關的問題就此迎刃而解。

Rush

a scalable monorepo manager for the web

  1. 命令統一。

rush(x) xxx 一把梭,減小新人上手成本。同時 Rush 除了 rush add 以及 rushx xxx 等命令須要在指定項目下運行,其餘命令均爲全局命令,可在項目內任意目錄執行,避免了在終端頻繁切換項目路徑的問題。

monorepo-5

  1. 強大的依賴分析能力。

Rush 中的許多命令支持分析依賴關係,好比 -t(to) 參數:

$ rush install -t @monorepo/app1
複製代碼

該命令只會安裝 app1 的依賴及其 app1 依賴的 package 的依賴,即按需安裝依賴。

$ rush build -t @monorepo/app1
複製代碼

該命令會執行 app1 以及 app1 依賴的 package 的構建腳本。

相似的,還有 -f(from) 參數,可使命令只做用於當前 package 以及依賴了該 package 的 package。

  1. 保證依賴版本一致性

Monorepo 中的項目應當儘可能保證依賴版本的一致性,不然頗有可能出現重複打包以及其餘的問題。

Rush 則提供了許多能力來保證這一點,如rush checkrush add -p package-name -m 以及 ensureConsistentVersions

有興趣的同窗能夠自行翻閱 Rush 的官方文檔,十分詳盡,對於一些常見問題也有說明。

Package 引用規範

monorepo-12

產物引用

傳統引用方式,構建完成後,app 直接引用 package 的構建產物。開發階段能夠經過構建工具提供的能力保證明時構建(如 tsc --watch)

  • 優勢:規範,對 app 友好。
  • 缺點:隨着模塊增多,package 熱更新速度可能變得難以忍受。

源碼引用

package.json 中的 main 字段配置爲源文件的入口文件,引用該 package 的 app 須要將該 package 歸入編譯流程。

  • 優勢:藉助 app 的熱更新能力,自身沒有生成構建產物的過程,熱更新速度快
  • 缺點:須要 app 進行適配, alias 適配繁瑣;

引用規範

  1. 對於項目內部使用的 packages ,稱爲 features,不該當向外發佈,直接將 main 字段設置爲源文件入口並配置 app 項目的 webpack,走後編譯形式。
  2. 對於須要對外發布的 packages,不該該也不容許引用 features,必需要有構建過程,若是須要使用源碼開發增長熱更新速度,能夠新增一個自定義的入口字段,app 的 webpack 配置中優先識別該入口字段便可。

補充:rush build 命令是支持構建產物緩存的,若是 app 拆分粒度夠小,可複用的包足夠多,同時打包鏡像支持構建產物緩存的 set 與 get,就能夠作到增量構建 app。

Workspace protocol (workspace:)

在 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 *

問題記錄

Monorepo Project Dependencies Duplicate

這個問題相似於前面提到的 Yarn duplicate,但並非 Yarn 獨有的。

假設存在如下依賴關係(將 Yarn duplicate 的例子進行改造,放在 Monorepo 場景中)

app1 以及 package1 同屬於 Monorepo 內部 project。

monorepo-8

在 Rush(pnpm)/Yarn 項目中,會嚴格按照 Monorepo 內 project 的 package.json 所聲明的版本進行安裝,即 app1 安裝 lib-a@1.1.0,package1 安裝 lib-a@1.2.0

此時對 app1 進行打包,則 lib-a@1.1.0lib-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: Preferred versions

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 也是無效的。 可是依舊有兩種方案能夠進行處理:

  1. 經過 yarn-deduplicate 針對性的修改 yarn.lock
  2. 使用resolutions 字段。過於粗暴,不像 preferredVersions 能夠容許不兼容版本的存在,不推薦。

須要謹記:在 Yarn 下消除重複依賴,也應該一個 Package 一個 Package 的去進行處理,當心使得萬年船。

  1. 對於存在反作用的公共庫,版本最好保持統一;
  2. 對於其餘的體積小(或支持按需加載)、無反作用的公共庫,重複打包在必定程度上能夠接受的。

prettier

因爲根目錄再也不存在 node_modules,故須要每一個項目安裝一個 prettier 做爲 devDependency 並編寫 .prettierrc.js 文件。

本着偷懶的原則,根目錄新建 .prettierrc.js(不依賴任何第三方包),全局安裝 prettier 解決該問題。

eslint

先看一個場景,若在項目中使用 eslint-config-react-app,除了須要安裝 eslint-config-react-app,還須要安裝一系列 peerDependencies 插件。

monorepo-10

monorepo-11

爲何 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-commitcommit-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 $? #++
複製代碼

如此,便完成了需求。

避免全局安裝 eslint 以及 prettier

通過上一節的處理,在 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
複製代碼

參考文章

相關文章
相關標籤/搜索