使用 pnpm 建立 Vue 的 Monorepo
最近想整理 GitHub 上面一些 repo,像是活動或課程作業的相關產出,
應該都可以集合成一包大專案?
剛好也想研究 Monorepo,所以稍微爬文一下自己公司用的 Lerna 和其他工具,
發現 pnpm 本身就提供類似的管理模式。
環境建置
建立資料夾後先進行初始化:
pnpm init
新增 pnpm-workspace.yaml
:
packages:
- "apps/*"
把之前的專案都搬到 apps
這個資料夾底下作為 Monorepo 的子專案,
搬完之後需要修改子專案的 package.json
,將 name
加上 @apps/
這個前綴:
{
"name": "@apps/week-1", // 從 "week-1" 改為 "@apps/week-1"
"version": "0.0.0",
"private": true,
"type": "module",
// 略...
}
pnpm 會將這個有這個前綴的資料夾,對應到剛剛在 pnpm-workspace.yaml
的設定,
將其識別為一個工作區(workspace)。
在根目錄執行 pnpm install
來測試子專案 package.json
紀錄的依賴項目能不能正常安裝,
確認子專案有出現 node_modules
之後,就可以接著運行:
pnpm --filter @apps/week-1 dev
這個指令的意思是,用參數 --filter
指向工作區 @apps/week-1
,
並且運行這個工作區 package.json
腳本中的 dev
,
跟 切換到這個目錄底下後執行 npm run dev
是一樣的意思。
如果能正常啟動的話,專案初步的搬遷已經成功了!
腳本
可以將剛剛的指令加到根目錄的腳本,之後就不用再打一長串的指令,
或是手動切換到子專案的資料夾來啟動專案:
{
"scripts": {
"week1:dev": "pnpm --filter @apps/week-1 dev",
"week1:build": "pnpm --filter @apps/week-1 build",
"week1:preview": "pnpm --filter @apps/week-1 preview"
}
}
在根目錄執行剛剛自訂的腳本來啟動子專案:
pnpm week1:dev
共用設定
在根目錄安裝的依賴項目可以作用在全部的工作區,
所以像 husky、commitlint、Prettier、ESLint 等等適用多個專案的套件,
都可以搬到根目錄做安裝與設定,讓子專案開發時可以直接共用。
這次搬的都是 六角學院 2024 Vue 前端新手營
的作業,
所以包含 Vue 本體都可以直接搬到根目錄:
{
"name": "2024-vue-camp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
},
// 把依賴項目搬到根目錄
"dependencies": {
"vue": "^3.4.29"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.14.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"npm-run-all2": "^6.2.0",
"prettier": "^3.2.5",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vue-tsc": "^2.0.21"
},
"keywords": [],
"author": "",
"license": "ISC"
}
新增共用依賴
未來要在根目錄安裝新的套件時,需要帶上 -W
這個參數,
來標註這是要在根目錄安裝的共用依賴項目,例如:
pnpm add axios -W
ESLint
Monorepo 如果是基於微前端的架構,子專案不一定全部都是同一套前端框架,
框架之間也有不同的最佳設定,所以建議子專案的設定可以留著:
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');
module.exports = {
root: false,
extends: ['../../.eslintrc.cjs'],
rules: {
// 各種規則
}
};
將 root
設為 false
就能解除解析範圍,讓 ESLint 可以向上層目錄解析,
extends
填上根目錄的 .eslintrc.cjs
,
這樣就能繼承根目錄的設定,並自行加入、重寫規則。
可以在子專案寫一個不符合規則的寫法,看看會不會收到提示:
// 宣告一個沒有被用到的變數
const testRef = ref();
看到明顯的黃波浪,確認是 ESLint 的嚴厲斥責警告就算成功了:
'testRef' is assigned a value but never used. eslint(@typescript-eslint/no-unused-vars)
TypeScript
透過 Vite 建立的 Vue 3 專案會有 3 個 TypeScript 設定檔:
tsconfig.app.json
tsconfig.node.json
tsconfig.json
,將上面兩個設定檔加入參照(reference)
這些設定檔預設會從官方提供的現成設定 @vue/tsconfig
和 @tsconfig/node20
繼承,
上面有提過微前端架構中可能包含其他不同框架的專案,
因次需要的 TypeScript 設定也不同,會牽涉到建構工具、編碼輸出的問題,
要共用 TypeScript 設定的話,我認為只放入撰寫規則(lint)相關的設定會比較安全。
在根目錄新增共用設定 tsconfig.base.json
:
{
"compilerOptions": {
// 只放入 lint 相關的規則
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
修改子專案的 tsconfig.app.json
和 tsconfig.node.json
的 extends
,
使它們包含根目錄的共用設定:
{
"extends": ["@vue/tsconfig/tsconfig.dom.json", "../../tsconfig.base.json"],
// 以下略
}
在子專案寫一個沒有指定參數型別的函式,測試是否有成功繼承到根目錄的設定:
function greet(name) {
return 'Hello ' + name;
}
這個規則會違反 noImplicitAny
(不允許隱式的 any),所以會收到提示:
Parameter 'name' implicitly has an 'any' type.ts(7006)
不過目前無法知道這個提示會生效的原因,
是基於 @vue/tsconfig
或 @tsconfig/node20
還是根目錄的 tsconfig.base.json
,
而 extends
會照陣列順序來解析,所以順序比較後面的 "../../tsconfig.base.json"
,
如果有同名屬性的規則,應該要覆蓋過去,
因此可以把 noImplicitAny
設為 false
來測試是不是有成功覆蓋。
確認設為 false
後如果沒有紅波浪的提示,TypeScript 的部分就算是設定完成了,
當然測完要記得改回 true
!
共用元件庫
同時經營多個產品線的話,通常會有一套共用的設計系統延伸到各個專案來維持品牌風格。
而不論開發上是純手刻,或是基於其他現成的元件庫做再封裝,
都可以做成一個共用庫,達成更好的開發一致性,以後也更好配合 design token 的改動。
先在根目錄建立資料夾 packages
,並在裡面建立一個 Vue 3 專案,
package.json
中 Vue 相關的依賴項目都可以移除,因為根目錄已經有紀錄,
將 name
改為帶有 @packages/
的前綴,也要重新設定專案進入點:
{
"name": "@packages/shared-ui", // 加入前綴
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/main.ts" // 設定為 main.ts
}
在 pnpm-workspace.yaml
加入剛剛建立的 packages
資料夾路徑:
packages:
- "apps/*"
- "packages/*"
修改完後要在根目錄執行 pnpm install
,讓目前的環境重新識別到 packages
。
隨意新增兩個元件,並在 main.ts
導出:
import SharedButton from './components/SharedButton.vue';
import SharedBadge from './components/SharedBadge.vue';
import type { App } from 'vue';
export { SharedButton, SharedBadge };
export default {
install(app: App) {
app.component('SharedButton', SharedButton);
app.component('SharedBadge', SharedBadge);
}
};
切換到子專案安裝這個共用元件庫,安裝時要加入參數 --workspace
,
來標注這個套件要從本機工作區拉取,而不是從 npm 的伺服器去找:
pnpm add @packages/shared-ui --workspace
在子專案的頁面導入共用元件:
<script setup lang="ts">
import { SharedButton, SharedBadge } from '@packages/shared-ui';
</script>
<template>
<SharedButton>test btn</SharedButton>
<SharedBadge label="test badge" />
</template>
IDE 沒有任何提示,子專案也能順利啟動的話就......還沒結束,
嘗試一下直接修改共用元件的元件原始檔的樣式,
如果熱重載有生效,那就成功啦!
打包
雖然這樣就可以直接部署了,不過為了支援 Tree-Shaking,
共用庫通常還是會進行打包,導出一個整理乾淨的 js 檔,
就像我們平常在使用 npm 抓下來的套件一樣。
首先要在共用庫裡面安裝插件 vite-plugin-dts
,
讓 Vue SFC 可以在打包時自動生成型別定義:
pnpm add -D vite-plugin-dts
新增 vite.config.ts
的打包設定:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
dts({
entryRoot: 'src',
outDir: 'dist',
insertTypesEntry: true,
tsconfigPath: './tsconfig.app.json'
})
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
name: 'shared-ui',
fileName: 'shared-ui',
formats: ['es']
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
},
}
},
}
});
package.json
的進入點 main
要改為打包後的 js 檔 "main": "./dist/shared-ui.js"
,
並加入 exports
,來讓其他子專案引用 時可以識別共用庫打包後導出的檔案放在哪裡:
{
"name": "@packages/shared-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./dist/shared-ui.js",
// 加入以下設定
"exports": {
".": {
"import": "./dist/shared-ui.js",
"types": "./dist/main.d.ts"
}
},
// 將需要導出的內容控制在 dist 資料夾內
"files": [
"dist"
],
// 以下略
}
main
和 exports
都是用來指定 Node.js 解析時的進入點,而 exports
的優先級更高。
裡面的 import
是用來指定 ESM 的進入點,types
指定型別的解析檔位置。
想要指定 CommonJS 的進入點,就可以寫 "require": "./dist/shared-ui.cjs"
,
但是 Vite 的設定檔就必須修改,讓打包時也可以輸出 .cjs
格式。
設定好之後執行 pnpm build
會生成 dist
資料夾,
並且包含 vite.config.ts
中指定要生成的檔名。
重新啟動子專案會發現元件的樣式不見了!
因為還沒打包前,子專案是透過共用庫的進入點 main.ts
直接取出 Vue SFC,
而打包後會變 js 檔,不會自動內聯樣式,所以要修改打包設定,導出 css 檔:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
build: {
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
name: 'shared-ui',
fileName: 'shared-ui',
formats: ['es']
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
},
// 加入 css 檔導出名稱與位置
assetFileNames: 'assets/shared-ui.[ext]'
}
},
// 導出單一 css 檔
cssCodeSplit: false
}
// 以下略
});
並在子專案的進入點導入 :
import { createApp } from 'vue';
import App from './App.vue';
// 加入 css 檔
import '@packages/shared-ui/dist/assets/shared-ui.css';
createApp(App).mount('#app');
但會收到無法識別 css 檔路徑的報錯,需要要調整子專案的 vite.config.ts
:
import { fileURLToPath, URL } from 'node:url';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/2024-vue-camp/week-1/' : '/',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
// 加入這行
'@packages': resolve(__dirname, '../../packages')
}
}
});
這樣就算是成功引用打包後的元件了。
元件打包後生成的 js 檔可以透過 Node.js 自動解析模組,
而 css 檔則是普通的靜態資源,所以從外部導入時必須寫出完整路徑,
或是像上面的示範,在建構工具中寫入解析路徑的規則。
導入打包後的元件就不支援熱重載了,
必須重新打包共用庫,子專案才能看到最新版的元件,
這邊暫不深入探討怎麼從設定面去解決。
部署
前端框架的專案無論如何都會經過打包的階段,
而 Vite 建起來的 Vue 專案已經很好心地安裝好這個套件 npm-run-all2
,
帶入參數 --parallel
就可以同時運行多個腳本。
在根目錄加入整個專案的打包腳本,
但要留意共用庫通常會被其他子專案導入,所以必須先執行完共用庫的 build,
才能執行子專案的 build,否則會找不到相關的依賴:
{
"scripts": {
"shared-ui:build": "pnpm --filter @packages/shared-ui build",
// 先 build 共用庫 shared-ui 再執行其他腳本
"build": "pnpm shared-ui:build && npm-run-all2 --parallel week1:build week2:build week3:build final:build"
// 其他腳本
}
}
執行 pnpm build
後就會依序進行各工作區的 build。
確認沒有報錯後,就可以進行 GitHub Pages 的部署了!
也建議養成好習慣,先在本機打包或是透過 Husky 設定 Git Hook 觸發打包腳本,
確保程式碼推送到 GitHub 之前,是可以正常完成打包的。
部署前要記得調整 vite.config.ts
的生成路由 base url,
加上根目錄的 repo 名稱 /2024-vue-camp/
做前綴:
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/2024-vue-camp/week-1/' : '/',
// 以下略
});
CI/CD
Vite 官網有提供 workflows
的腳本,只要專案在指定分支有收到新的推送,
就會觸發 GitHub Actions 執行對應分支的 workflow,並生成 GitHug Pages。
改寫一下官方的腳本:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# 設置 GitHub Pages 部署所需的權限
permissions:
contents: read
pages: write
id-token: write
# 允許一個並行部署
concurrency:
group: 'pages'
cancel-in-progress: true
jobs:
# 先構建共用元件庫
build-shared-ui:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build shared-ui
run: pnpm shared-ui:build
# 將共用元件庫的構建結果保存為 artifact
- name: Upload shared-ui artifact
uses: actions/upload-artifact@v4
with:
name: shared-ui-dist
path: packages/shared-ui/dist
retention-days: 1
# 為每個應用構建單獨的構建作業
build:
needs: build-shared-ui
runs-on: ubuntu-latest
strategy:
matrix:
app: [week-1, week-2, week-3, final]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
# 下載共用元件庫的構建結果
- name: Download shared-ui artifact
uses: actions/download-artifact@v4
with:
name: shared-ui-dist
path: packages/shared-ui/dist
- name: Build
run: |
cd apps/${{ matrix.app }}
pnpm build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.app }}-dist
path: apps/${{ matrix.app }}/dist
retention-days: 1
# 合併所有構建結果並部署
deploy:
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare deployment structure
run: |
mkdir -p final_dist
cp index.html final_dist/
for app_dir in week-1 week-2 week-3 final; do
mkdir -p final_dist/$app_dir
cp -r artifacts/$app_dir-dist/* final_dist/$app_dir/
done
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'final_dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
改寫的方向主要有:
- 安裝 pnpm
- 設定 pnpm 的暫存位置
- 將 deploy 任務重新拆成 build 和 deploy
- 共用庫與子專案的任務拆開
- 使用
matrix
來指定所有需要打包的子專案 - 使用
actions/download-artifact@v4
和actions/upload-artifact@v4
來傳遞各個任務打包好的靜態檔案
雖然設定檔看起來眼花撩亂,不過大致讀過後就發現,
這些腳本還算是人類能讀懂的英文,語意化的程度是 OK 的,
但在這個時代當然不會自己一行一行寫腳本,你懂的 XD
導航頁面
部署完後可以在這個 repo 的 GitHub Pages 的網址加上 /week-1
來導向到子專案,
但根目錄本身沒有 index.html
,所以輸入這個 repo 的首頁 /
會導向 404,
可以加上 html 檔方便進行導覽:
<!doctype html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>六角學院 2024 Vue 前端新手營</title>
<style>
{/* 樣式略 */}
</style>
</head>
<body>
<h1>六角學院 2024 Vue 前端新手營作業</h1>
<ul>
<li>
<a href="./week-1/">第一週作業</a>
</li>
<li>
<a href="./week-2/">第二週作業</a>
</li>
<li>
<a href="./week-3/">第三週作業</a>
</li>
<li>
<a href="./final/">最終作業</a>
</li>
</ul>
</body>
</html>
最後,也提供我部署好的專案供參考: https://github.com/penspulse326/2024-vue-camp