使用 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 設定檔:

  1. tsconfig.app.json
  2. tsconfig.node.json
  3. 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.jsontsconfig.node.jsonextends
使它們包含根目錄的共用設定:

{
  "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"]
  // 以下略
}
資訊

mainexports 都是用來指定 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

改寫的方向主要有:

  1. 安裝 pnpm
  2. 設定 pnpm 的暫存位置
  3. 將 deploy 任務重新拆成 build 和 deploy
  4. 共用庫與子專案的任務拆開
  5. 使用 matrix 來指定所有需要打包的子專案
  6. 使用 actions/download-artifact@v4actions/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


參考資料