workspaces - monorepo實戰

歲月是把殺豬刀發表於2022-01-11

前言

npm 自從v7開始,引入了一個十分強大的功能,那就是workspaces。另外,yarn和pnpm也擁有workspaces的能力。不過,從用法上來說,幾乎是一模一樣的。所以,學會了npm workspaces的話,自然而然也就學會了yarn和pnpm的了。

概覽

本文會分四個部分進行介紹:

  1. 什麼是workspaces;
  2. 多包管理;
  3. 多專案管理;
  4. 避坑;
  5. 總結;

什麼是workspaces?

顧名思義,workspaces就是多空間的概念,在npm中可以理解為多包。它的初衷是為了用來進行多包管理的,它可以讓多個npm包在同一個專案中進行開發和管理變得非常方便:

  • 它會將子包中所有的依賴包都提升到根目錄中進行安裝,提升包安裝的速度;
  • 它初始化後會自動將子包之間的依賴進行關聯(軟連結);
  • 因為同一個專案的關係,從而可以讓各個子包共享一些流程,比如:eslint、stylelint、git hooks、publish flow等;

這個設計模式最初來自於Lerna,但Lerna對於多包管理,有著更強的能力,而且最新版的Lerna可以完全相容npm或yarn的workspaces模式。不過因為本文講的是workspaces,所以,對於Lerna有興趣的同學,可以自行去Lerna官網學習。

多包管理

多包管理上面已經說過它相對單包單獨管理的好處。所以,我們通過例項的例子來讓同學們感受一下workspaces為什麼被我吹的這麼牛批。

例子演示

專案地址我掛在github上了,有興趣的同學可以自行檢視原始碼

1. 升級npm到7或最新版

npm i -g npm@latest

2. 建立專案

mkdir demo-workspaces-multi-packages

3. 初始化專案

npm init -y
.
└── package.json

4. 宣告本專案是workspaces模式

package.json新增配置:

"private":"true",
"workspaces": [
  "packages/*"
],

這裡的packages/*表示我們的子包都在packages資料夾下。(對於workspaces的細節和更多用法本文不會一一介紹,文件非常清楚,本文講究實戰)

5. 初始化子包m1

建立子包m1

npm init -w packages/m1 -y
.
├── package.json
└── packages
    └── m1
        └── package.json

建立m1的主檔案index.js

echo "exports.name = 'kitty'" >> packages/m1/index.js
.
├── package.json
└── packages
    └── m1
        ├── index.js
        └── package.json

6. 初始化子包m2

同樣的方式,建立子包m2

npm init -w packages/m2 -y
.
├── package.json
└── packages
    ├── m1
    │   ├── index.js
    │   └── package.json
    └── m2
        └── package.json

建立m2的主檔案index.js

echo "const { name } = require('m1')\nexports.name = name" >> packages/m2/index.js
.
├── package.json
└── packages
    ├── m1
    │   ├── index.js
    │   └── package.json
    └── m2
        ├── index.js
        └── package.json

因為這裡require('m1'),所以需要新增m1依賴到m2package.json中:

npm i -S m1 --workspace=m2

7. 初始化子包demo

為了方便我們看到效果,再建立一個demo資料夾(多包管理推薦搞個demo子包進行整體效果測試):

npm init -w packages/demo -y
echo "const { name } = require('m2')\nconsole.log(name)" >> packages/demo/index.js
.
├── package.json
└── packages
    ├── demo
    │   ├── index.js
    │   └── package.json
    ├── m1
    │   ├── index.js
    │   └── package.json
    └── m2
        ├── index.js
        └── package.json

額外的,這個demo包,我們並不像他進行釋出,為了防止不小心釋出,我們在demopackage.json中新增:

"private":"true",

因為這裡require('m2'),所以需要新增m2依賴到demopackage.json中:

npm i -S m2 --workspace=demo

我們看看這時候專案根目錄的node_modules吧:
<img width="300px" src="https://user-images.githubusercontent.com/17001245/148692468-e94beeca-3205-40f9-9e2b-b4c145a2f4a4.png">
是不是很有意思?全是軟連結,連結的指向就是packages資料夾下的各子包。

OK,搞了半天,我們執行demo看下效果吧:

node packages/demo/index.js
# 輸出:
kitty

通過上面的例子,我們可以看出,workspaces對於本地子包之間的依賴處理的非常巧妙,也讓開發者更加方便,尤其是多人開發的時候。另一個人在拉取完專案以後,只需要執行npm install,即可進行開發,軟連結會自動建立好。

接下來,我們看workspaces專案中如果安裝三方包的情況。

8. 安裝兩個不同版本的包

npm i -S vue@2 --workspace=m1
npm i -S vue@3 --workspace=m2

例子中,我們想看看,因為我們的包都會被提升到根目錄進行安裝,那麼不同版本的vue它會怎麼處理呢?難道只會安裝vue3的包嗎?

結果:
<img width="300px" src="https://user-images.githubusercontent.com/17001245/148693126-3426d7b8-a011-4634-87e4-e1e52e5c798b.png">
這樣,我們就無需擔心版本衝突的問題了,workspaces顯然已經很好地解決了。

重點引數--workspace

workspaces專案中,一個很核心的引數就是--workspace,因為從前文的安裝包到子包的命令可以發現,和傳統的安裝包一樣,都是使用npm i -S 包名或者npm i -D 包名,不同的僅僅是末尾加了--workspace

那是不是對於其它的命令,比如runversionpublish等也是樣的使用方式呢?答案是:Yes!

另外,如果我們子包的package.jsonscprits全都有一個叫test的命令,我們想一次性執行所有子包的這個命令,可以使用npm run test --workspaces即可。
這樣的話,對於我們的Lint校驗或是單測都是非常方便的。

到此,workspaces在多包管理中啟到的作用就基本介紹完了。值得一提的是,多包管理,實際專案中還是推薦使用Lerna,它對於版本依賴自動升級、發包提示、自動生成Log(Change Log / Release Note)、CI等都具有一套十分成熟的流程機制了。

多專案管理

目前的npmworkspaces,個人認為是非常適合用來做多專案的整合(Monorepo)管理的 。

例子演示

專案地址我掛在github上了,有興趣的同學可以自行檢視原始碼

1. 建立專案

mkdir demo-workspaces-multi-project

2. 初始化專案

npm init -y
.
└── package.json

3. 宣告本專案是workspaces模式

package.json新增配置:

"private":"true",
"workspaces": [
  "projects/*"
],

4. 初始化子專案zoo

建立子專案zoo

npm init -w projects/zoo -y
.
├── package.json
└── packages
    └── zoo
        └── package.json

建立模板檔案index.html,主內容為:

<!-- projects/zoo/index.html -->
<body>
  <h1>Welcome to Zoo!</h1>
  <div id="app"></div>
</body>

建立專案入口js檔案index.js,內容為:

console.log('Zoo')

安裝專案構建依賴包:

npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin webpack-merge --workspace=zoo

# projects/zoo/package.json
"private":"true",
"dependencies": {
  "html-webpack-plugin": "^5.5.0",
  "webpack": "^5.65.0",
  "webpack-cli": "^4.9.1",
  "webpack-dev-server": "^4.7.2"
}

建立webpack配置:

// projects/zoo/webpack/base.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')

function resolve(dir) {
  return path.join(__dirname, '../' + dir)
}

exports.config = {
  entry: resolve('src/index.js'),

  plugins: [
    new HtmlWebpackPlugin({
      title: 'Zoo',
      filename: 'index.html',
      template: resolve('src/index.html')
    })
  ],
}

exports.resolve = resolve
// projects/zoo/webpack/dev.config.js
const { config, resolve } = require('./base.config')
const { merge } = require('webpack-merge')

exports.default = merge(config, {
  mode: 'development',

  output: {
    filename: 'bundle.js',
  },
})
// projects/zoo/webpack/prod.config.js
const { config, resolve } = require('./base.config')
const { merge } = require('webpack-merge')

exports.default = merge(config, {
  mode: 'production',

  output: {
    filename: 'bundle.js',
  },
})

zoo下的package.json新增命令:

"scripts": {
  "dev": "webpack-dev-server --config webpack/dev.config.js --open",
  "prod": "webpack --config webpack/prod.config.js"
},

接下來就可以執行了,只需要在專案根目錄使用:

npm run dev --workspace=zoo

即可進行本地開發。

效果:
<img width="200px" src="https://user-images.githubusercontent.com/17001245/148756703-5d1a71e7-ecf3-4d56-947d-2a8ad2f2c4d1.png">

執行prod同理。

5. 初始化子專案shop

建立子專案shop

npm init -w projects/shop -y

其餘步驟同初始化子專案zoo幾乎一模一樣,所以不再贅述。

最後的目錄結構:
<img width="150px" src="https://user-images.githubusercontent.com/17001245/148758564-84a086c0-caf1-4042-b6e7-d0e232787490.png">

共享

對於Monorepo,共享是最重要的一個優勢。所以,我們來做一些共享的事情。

1. 在根目錄建立share資料夾,作為共享資源目錄,並建立共享檔案Fish.js

mkdir share
mkdir share/js
touch share/js/Fish.js
// share/js/Fish.js
class Fish {
  constructor(name, age) {
    this.name = name
    this.age = age
  }

  swim() {
    console.log('swim~')
  }

  print() {
    return '? '
  }
}

module.exports = Fish

2. 子專案中的webpack配置新增alias

子專案zooshop都加上相同的alias即可:

resolve: {
  extensions: ['.js'],
  alias: {
    '$share': resolve('../../share'),
  },
},

子專案zoo的入口檔案改為:

// projects/zoo/src/index.js
const Fish = require('$share/js/Fish')
const fish = new Fish()
document.getElementById('app').textContent = fish.print()

執行zoodev看效果:
<img width="150px" src="https://user-images.githubusercontent.com/17001245/148770309-3fe66464-6b1e-4780-948b-6873ee4cab5e.png">

修改子專案shop的入口檔案後,會出現同樣的效果。

也就是說,share資料夾下的東西,zooshop可以公用了,需要做的僅僅是新增一個webpack的alias而已!?

?思考 —— 我們為什麼使用workspaces做集合專案,用傳統方式不行嗎?

傳統方式:

  1. 各個子專案都集合到一個專案中來。和上文不同的是,package.json只有一份,在根目錄,所有專案中的npm包都安裝到根目錄,在根目錄的package.json中定義開發部署子專案的命令;
  2. 各個子專案都集合到一個專案中來。和上文不同的是,雖然根目錄和各個子包都各自有一份package.json,但基礎的構建工具在根目錄進行安裝,比如上面提到的webpackwebpack-cliwebpack-dev-serverhtml-webpack-pluginwebpack-merge,全都在根目錄進行安裝,和業務相關的npm包都安裝到各自子專案中;
  3. 各個子專案都集合到一個專案中來。和上文不同的是,各個子包都各自有一份package.json,根目錄無package.json

方式1 —— 缺點:

  • 命令混亂;
  • 無法應對子專案之間存在npm包衝突的問題;(比如,A專案想用webpack4,B專案想用webpack5;或者A專案想用Vue2,而B專案想用Vue3)

方式2 —— 缺點:

  • 如果子專案有相同的包,不得不在各個子專案中重複安裝;
  • 同樣無法應對子專案之間存在npm包衝突的問題;(比如,A專案想用webpack4,B專案想用webpack5)
  • 如果某天想把B專案移除,成本很高;

方式3 —— 缺點:

  • 如果子專案有相同的包,不得不在各個子專案中重複安裝;

那使用workspaces就很好的解決了上面的所有問題!

另外,對於已經存在的專案而言,比如我今年所接手的專案,一個是Web的,一個是Wap的,然後發現,因為他們屬於同一個業務,所以有大量的程式碼可以複用,又因為只涉及這兩個專案而已,把公共程式碼做成npm包又有點太殺雞用牛刀,所以,過去一直採用的是複製、貼上的模式。這顯然是非常低效的。另外就是,mock服務也是個字專案單獨一套,但是大多數介面的資料都是可以公用的,只是url字首不同。最離譜的就是幾百個銀行圖示都一模一樣。所以,我打算將它倆合併成一個專案。而workspaces對於我來說,是一個對原專案改動量最小的方案。

怎麼單獨部署?

我們想要在構建機上只部署專案zoo,應該怎麼做?

1. 安裝依賴包

npm install --production --workspace=zoo 

這樣的話,構建機上就只會安裝zoo專案下的依賴包了。

2. 構建

npm run prod --workspace=zoo 

這樣的話,就構建成功了!

避坑

npm的workspaces其實有隱藏的坑,所以我也羅列下。

坑一:npm install 預設模式的坑

npm v7開始,install會預設安裝依賴包中的peerDependencies宣告的包。新專案可能影響不大,但是,如果你是改造現有的專案。因為用了統一管理的方式,所以一般都會把子專案中的lock檔案刪掉,在根目錄用統一的lock管理。然後,當你這麼做了以後,可能坑爹的事情就出現了。
場景:我的子專案中用的是webpack4,然後,我們的構建相關的工具(webpack、babel、postcss、eslint、stylint等)都會封裝到基礎包中。這些包的依賴包中有一個包,在package.json宣告中使用這樣寫:

"peerDependencies": {
  "webpack": "^5.1.0"
},

然後,在根目錄中npm install,然後再跑子專案發現專案跑不起來了。原因就是,專案居然安裝的是webpack5的版本!

解決方案

  • 方案1:在子專案的package.json中顯示宣告用的webpack版本;
  • 方案2:去github和作者商量修復依賴包,如果他的包即相容webpack4也相容webpack5,應該寫成,把宣告改為: "webpack": "^4.0.0 || ^5.0.0"
  • 方案3:npm install --legacy-peer-deps

個人真的覺得這是npm作者腦袋被驢踢了。對於yarn或者pnpm,他們的workspaces都不會用這種預設安裝peerDependencies的模式。
作者原本是想,因為如果npm包的開發者宣告瞭peerDependencies,如果我們使用過程中沒有安裝匹配的版本的包就可能導致專案跑不了,為了方便使用,他就採用了預設安裝的模式。
但是,這種做法會導致那些peerDependencies不符合書寫規範的包,在專案中配合使用出現問題。而且,即使新的包中包作者們開始注意書寫規範,但是無法處理那些已經發布出去的老包,總不可能全都回收,然後一個個版本重新再發布一遍吧!

坑二:小版本包衝突

這其實是個人粗心導致的。

舉個例子:zoo使用命令npm i -S @vue2.2.1引入vue,shop使用命令npm i -S @vue2.2.2引入vue。那麼,專案會有兩個版本的vue嗎?不會。
原因我們可以看zoo專案下的package.json

"dependencies": {
  "html-webpack-plugin": "^5.5.0",
  "vue": "^2.2.1",
  "webpack": "^5.65.0",
  "webpack-cli": "^4.9.1",
  "webpack-dev-server": "^4.7.2",
  "webpack-merge": "^5.8.0"
}

恍然大悟。

解決方案

  • 方案1:其實去掉^即可;
  • 方案2:我們安裝的時候可以使用npm i --save-exact vue@2.2.1 --workspace=zoo

總結

本文,利用了workspaces來做多包管理,以及多專案管理,體現出了workspaces的強大。因為我個人負責的專案一直以來都是使用npm來管理的,所以想要遷移到yarn或者pnpm存在未知的風險,而且,也嘗試過,因為一些老包yarn2和pnpm都跑不起來。對於新的專案,個人也更推薦yarn2或者pnpm進行管理,它們比npm更加強大。

本原文來自於個人github部落格,覺得好的小夥伴可以點個贊哈~
<( ̄▽ ̄)/

文中多包管理和多專案管理的原始碼分別在:

有興趣的同學可以自行下載學習。

相關文章