npm Workspaces 入門:用 Vue 3 整理多專案共用程式碼
當手上只有一個 Vue 3 專案,共用程式碼放在 src/utils 或 src/components 很自然。但專案增加到後台、官網、會員中心之後,同一個日期格式化函式、按鈕元件或 ESLint 設定就會開始散落各處。修正一次 bug,還要記得貼到另外兩個 repository,久了很難確認哪一份才是最新版。
npm Workspaces 適合處理的正是這種情況:把多個應用程式與共用套件放在同一個 repository,由根目錄統一安裝依賴與維護 package-lock.json,各專案仍保有自己的 package.json、開發指令與部署流程。
這篇會用兩個 Vue 3 + Vite 應用程式示範:
apps/admin:後台apps/shop:前台商店packages/shared:共用 TypeScript 工具packages/ui:共用 Vue 元件
最後要記住的核心是:workspace 負責管理多個套件與本機連結;真正的共用邊界,仍要由你依照變動原因來切。
npm Workspace 解決了什麼問題
Workspace 是 npm CLI 管理多個本機 package 的機制。根目錄 package.json 用 workspaces 宣告哪些資料夾屬於工作區,執行 npm install 時,npm 會把這些本機 package 連結到 node_modules,因此應用程式可以直接用 package name 匯入,不必另外執行 npm link。
它帶來的改變可以整理成四點:
| 原本的做法 | 改成 workspace 之後 |
|---|---|
| 每個專案各自安裝依賴 | 在根目錄安裝,維護一份 package-lock.json |
| 共用程式碼靠複製 | 抽成有名稱、有版本的本機 package |
| 修改後逐一同步 | 修改共用 package,所有使用者立即讀到同一份來源 |
| 進入各資料夾執行指令 | 從根目錄用 -w 指定 workspace |
Workspace 經常和 monorepo 一起出現,但兩者不是同一件事。monorepo 是把多個專案放在同一個 repository 的管理方式;npm Workspaces 則是其中一套套件與指令管理機制。它不會自動替你決定模組邊界,也不是會依照相依圖平行排程的建置系統。
先決定哪些東西值得共用
剛開始整理時,不要看到重複就全部抽出去。先從「兩個專案真的使用相同規則,而且會一起修改」的內容開始:
- 純 TypeScript 工具:日期、金額、字串與驗證函式
- 不綁特定頁面的 Vue 元件:按鈕、Modal、表單欄位
- 共用型別:API response、domain model、表單資料結構
- 開發設定:ESLint、TypeScript、Prettier 的基礎設定
以下內容先留在各自的 app 通常比較清楚:
- router、頁面與 layout
- 各環境的 API URL
- 綁定單一產品流程的 Pinia store
- 只在一個專案出現的元件
環境變數尤其不適合因為名稱相同就直接共用。Vite 的 VITE_ 變數會進入前端產物,正式站與後台也可能指向不同 API;可先參考 Vite 環境變數的模式、優先順序與安全邊界 再決定設定放哪裡。
建立 Vue 3 workspace 的目錄結構
假設你已經有兩個 Vue 3 + Vite 專案,可以先整理成以下結構:
vue-workspace/├── apps/│ ├── admin/│ │ └── package.json│ └── shop/│ └── package.json├── packages/│ ├── shared/│ │ ├── src/index.ts│ │ └── package.json│ └── ui/│ ├── src/AppButton.vue│ ├── src/index.ts│ └── package.json├── package.json└── package-lock.json1. 在根目錄宣告 workspaces
建立根目錄 package.json:
{ "name": "vue-workspace", "private": true, "workspaces": ["apps/*", "packages/*"], "scripts": { "dev:admin": "npm run dev -w @example/admin", "dev:shop": "npm run dev -w @example/shop", "build": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present" }}private: true 很重要,它可以避免誤把 workspace 根目錄發布到 npm registry。apps/* 與 packages/* 會尋找下一層含有有效 package.json 的資料夾。
2. 保留每個 Vue app 的 package.json
後台的 apps/admin/package.json 可以長這樣:
{ "name": "@example/admin", "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", "typecheck": "vue-tsc --noEmit" }, "dependencies": { "@example/shared": "1.0.0", "@example/ui": "1.0.0", "vue": "^3.5.0" }, "devDependencies": { "@vitejs/plugin-vue": "latest", "vite": "latest", "vue-tsc": "latest" }}apps/shop/package.json 使用另一個唯一名稱 @example/shop,其餘依賴可依專案需求調整。範例用 latest 是為了避免文章把讀者鎖在某個過期工具版本;正式專案安裝完成後,應提交 package-lock.json,CI 使用 npm ci 重現已鎖定的版本。
本機套件使用 1.0.0,是因為它和接下來兩個 package 的 version 相符。不要把 pnpm 常見的 workspace:* 直接貼進 npm 專案;npm Workspaces 會在版本範圍符合本機 package 時建立連結。
把共用工具抽成獨立 package
先處理最單純的共用程式碼。建立 packages/shared/package.json:
{ "name": "@example/shared", "version": "1.0.0", "private": true, "type": "module", "exports": "./src/index.ts"}接著在 packages/shared/src/index.ts 放入共用函式:
export function formatTwd(value: number): string { return new Intl.NumberFormat('zh-TW', { style: 'currency', currency: 'TWD', maximumFractionDigits: 0, }).format(value);}兩個 Vue app 都可以用套件名稱匯入:
import { formatTwd } from '@example/shared';
const totalLabel = formatTwd(1280);這裡讓 exports 直接指向 TypeScript 原始碼,是配合 Vite 開發的簡化做法。如果這個 package 之後要發布到 registry、提供給非 Vite 專案,或需要產生 .d.ts,就應替它加入獨立 build,讓 exports 指向 dist 產物。
把 Vue 元件抽成共用 UI package
建立 packages/ui/package.json:
{ "name": "@example/ui", "version": "1.0.0", "private": true, "type": "module", "exports": "./src/index.ts", "peerDependencies": { "vue": "^3.5.0" }, "devDependencies": { "vue": "^3.5.0" }}共用元件把 Vue 放在 peerDependencies,意思是由使用它的 app 提供 Vue。devDependencies 則讓 UI package 自己執行型別檢查與測試時也有開發依賴。兩邊的版本範圍應保持相容,避免 app 與元件套件各自載入不同 Vue runtime。
新增 packages/ui/src/AppButton.vue:
<script setup lang="ts">defineProps<{ disabled?: boolean;}>();
const emit = defineEmits<{ click: [event: MouseEvent];}>();</script>
<template> <button type="button" :disabled="disabled" class="app-button" @click="emit('click', $event)" > <slot /> </button></template>
<style scoped>.app-button { border: 0; border-radius: 8px; padding: 8px 16px; color: white; background: #2563eb; cursor: pointer;}
.app-button:disabled { cursor: not-allowed; opacity: 0.5;}</style>再從 packages/ui/src/index.ts 匯出:
export { default as AppButton } from './AppButton.vue';app 端就能像一般 npm package 一樣使用:
<script setup lang="ts">import { AppButton } from '@example/ui';import { formatTwd } from '@example/shared';
function submitOrder() { console.log(`送出訂單:${formatTwd(1280)}`);}</script>
<template> <AppButton @click="submitOrder">送出訂單</AppButton></template>如果元件開始包含跨層狀態、provide/inject 或複雜事件流,可搭配 Vue 3 <script setup> 元件溝通整理 重新判斷 API,不要為了共用而把產品流程塞進單一萬用元件。
安裝依賴與執行指定專案
回到 workspace 根目錄,只需要安裝一次:
npm installnpm 會在根目錄產生 package-lock.json,並把 @example/shared、@example/ui 連結到本機 workspace。可以用下列指令檢查解析結果:
npm ls --workspaces --depth=0啟動單一 Vue app:
npm run dev -w @example/admin也可以使用資料夾路徑:
npm run dev -w apps/admin只替後台安裝 axios:
npm install axios -w @example/admin只替 UI package 安裝測試工具:
npm install -D vitest -w @example/ui在所有有 typecheck script 的 workspace 執行檢查:
npm run typecheck --workspaces --if-present--if-present 會略過沒有這個 script 的 package。這很適合統一跑短任務,但不適合同時啟動兩個長時間執行的 Vite dev server;最簡單的方式是開兩個終端機分別跑 dev:admin 與 dev:shop,需要單一指令並行時再引入專門的並行工具。
從多個既有 Vue 專案遷移的順序
如果現在每個 app 都是獨立 repository,不建議一次搬完所有程式碼。可以依照以下順序降低排錯範圍:
- 建立新的 workspace 根目錄與
apps/*。 - 先搬入兩個 app,但暫時不抽共用程式碼。
- 刪除 app 內原本的
node_modules與 lockfile,回根目錄執行npm install。 - 分別執行兩個 app 的 dev、build 與測試,確認搬家本身沒有改變行為。
- 先抽純函式與型別到
packages/shared。 - 再抽沒有產品流程的 Vue 元件到
packages/ui。 - 每抽一批就搜尋舊 import、跑 typecheck 與 build,不要累積到最後一次處理。
可以用這些指令做基本驗證:
npm cinpm run typecheck --workspaces --if-presentnpm run build --workspaces --if-presentnpm ls --workspaces --depth=0CI 應從 repository 根目錄執行 npm ci。如果 admin 與 shop 分開部署,後續 job 再以 -w 指定各自的 build script,並把對應的 app、共用 package、根目錄 package.json 與 package-lock.json 納入變更判斷。
Workspace 裡只部署單一 Vue 系統
改成 npm Workspaces 後,部署 admin 並不代表只把 apps/admin 交給 CI。admin 的依賴還包含根目錄 lockfile、packages/shared 與 packages/ui,所以安裝階段仍要看得到整個 repository。
部署單一系統時,可以把設定拆成四個值:
| 設定 | admin 的值 | 原因 |
|---|---|---|
| Working directory | repository 根目錄 | npm ci 要讀根目錄的 workspace 與 lockfile |
| Install command | npm ci | 安裝整個 workspace 的鎖定依賴與本機連結 |
| Build command | npm run build -w @example/admin | 只執行 admin 的 build script |
| Output directory | apps/admin/dist | Vite 預設把產物放在該 app 的 dist |
不要直接把 Root Directory 設成 apps/admin如果部署平台會因此只 checkout 或只上傳
apps/admin,建置時就拿不到根目錄package-lock.json與packages/*。平台應保留完整 repository 作為 build context,再用 build command 和 output directory 指定單一 app。
GitHub Actions:只建置 admin
新增 .github/workflows/deploy-admin.yml:
name: Build admin
on: push: branches: [main] paths: - 'apps/admin/**' - 'packages/**' - 'package.json' - 'package-lock.json' - '.github/workflows/deploy-admin.yml' workflow_dispatch:
permissions: contents: read
jobs: build-admin: runs-on: ubuntu-latest environment: admin-production env: VITE_API_BASE_URL: ${{ vars.ADMIN_API_BASE_URL }}
steps: - name: Checkout repository uses: actions/checkout@v7
- name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: npm cache-dependency-path: package-lock.json
- name: Install workspace dependencies run: npm ci
- name: Type check admin run: npm run typecheck -w @example/admin
- name: Build admin run: npm run build -w @example/admin
- name: Upload admin artifact uses: actions/upload-artifact@v7 with: name: admin-dist path: apps/admin/dist if-no-files-found: error這個 workflow 的安裝範圍是整個 workspace,執行範圍則只有 admin。setup-node 快取的是 npm 的下載快取,不是直接保存 node_modules;真正安裝仍由 npm ci 與已提交的 package-lock.json 決定。
最後的 artifact 可以交給另一個 deploy job、部署 CLI 或主機同步流程。若平台本身已連接 GitHub 並負責部署,就不一定需要 upload-artifact,直接把前面表格的 working directory、install、build 和 output directory 填進平台設定即可。
shop 要有自己的部署入口
shop 可以使用另一份 workflow,把下列項目換掉:
觸發路徑:apps/shop/**、packages/**、根目錄 manifestsBuild command:npm run build -w @example/shopOutput directory:apps/shop/dist部署環境:shop-production兩個 app 分開設定,可以讓 admin 變更只走 admin 部署;但只要 packages/**、根目錄 package.json 或 package-lock.json 改變,兩邊都應重新建置,因為共同依賴可能影響兩份產物。
如果 packages 數量增加,可以把 packages/** 收斂成該 app 真正依賴的 package 路徑。不過這份清單要包含間接依賴:假設 admin 使用 packages/ui,而 UI 又依賴 packages/shared,兩個路徑都要列入。
建置時才注入 Vue 環境變數
Vite 會在 build 時把 VITE_ 變數寫進前端產物,因此 admin 與 shop 應使用不同 deployment environment 或平台變數。上面的 ADMIN_API_BASE_URL 是 GitHub environment variable,再映射成 Vite 讀取的 VITE_API_BASE_URL。
真正的 API secret、資料庫密碼與私密 token 不能因為放在 GitHub Secrets 就安全地交給 VITE_。只要進入靜態前端 build,瀏覽器使用者就能讀到;需要保密的值必須留在後端或 server-side function。
部署前檢查的不是只有 admin 資料夾
單一 app 的 CI/CD 至少要監控這些輸入:
apps/admin/**packages/shared/**packages/ui/**package.jsonpackage-lock.json.github/workflows/deploy-admin.ymlGitHub Actions 的 paths filter 會在符合路徑時觸發 workflow。若這個 workflow 同時被設為 pull request 的 required check,要注意路徑過濾造成 workflow 未執行時,相關 check 可能維持 Pending;這種情況可以讓 PR 驗證 workflow 固定執行,再把路徑過濾只放在 push 後的部署 workflow。
最常遇到的五個問題
1. package name 重複或沒有 scope
每個 workspace 都要有唯一的 name。使用 @example/admin 這類專案 scope,比 admin、shared 更不容易和 registry 上的套件撞名,也能從 import 看出它屬於哪個團隊。
2. 本機 package 的版本範圍對不上
app 寫了 "@example/ui": "^2.0.0",但本機 UI package 仍是 1.0.0 時,npm 不能把它當成滿足範圍的本機版本。先檢查兩邊 version 與 dependency range,再判斷是否真的要升 major。
3. 程式能跑,但 package.json 沒宣告依賴
npm 可能把依賴提升到根目錄,讓未宣告的 import 暫時能被解析。不要把「node_modules 找得到」當成依賴已正確設定;哪個 workspace 直接 import 套件,就在哪個 workspace 的 dependencies 或 devDependencies 明確宣告。
4. 共用 UI 出現兩份 Vue
UI package 若把 Vue 當成一般 runtime dependency,又和 app 使用不相容版本,可能得到多份 Vue runtime。共用元件庫應以 peerDependencies 表達由 app 提供 Vue,並用 npm ls vue --all 檢查實際依賴樹。
5. npm run build --workspaces 不懂相依圖
--workspaces 可以逐一執行 script,但不等於會分析 @example/ui 必須先於 @example/admin 建置。本文讓 Vite 直接讀共用 package 的原始碼,因此沒有獨立產物順序;若未來每個 package 都輸出 dist,就要明確安排 scripts,或導入能理解任務相依關係的建置工具。
什麼情況先不要合成 monorepo
npm Workspaces 很適合同一個團隊維護、需要一起修改與測試的 Vue 專案。若多個專案的權限、發布週期、技術棧與維護團隊完全不同,硬放進同一個 repository 反而會讓 CI、版本與權限變得更難管理。
你也不需要等所有共用內容都規劃完成才開始。先把兩個 app 放進 workspace,抽出一個純函式 package,確認本機開發與部署流程走通,再決定 UI、型別與設定是否值得繼續拆分。這比先設計十個空 package 更容易看出真正的共用邊界。
實測環境為 Node.js 24.13.0、npm 11.6.2、Vue 3.5.41(2026-08-14)。最小驗證包含兩個 app、兩個本機 package、單一 lockfile,以及 workspace symlink 解析。
常見問題
Q: npm Workspaces 和 pnpm workspace 有什麼差別?
A: 兩者都能管理 monorepo 裡的多個 package,但設定檔、lockfile、安裝策略與部分依賴語法不同。npm 在根目錄 package.json 使用 workspaces;pnpm 通常使用 pnpm-workspace.yaml,也提供 workspace: protocol。不要只替換指令名稱就共用設定,遷移時要連 lockfile 與 dependency spec 一起檢查。
Q: 共用 Vue 元件一定要發布到 npm 嗎?
A: 不需要。只要 app 和 UI package 位於同一個 workspace,npm install 就能建立本機連結,開發時可直接 import。只有其他 repository 也要使用、需要獨立版本或公開散布時,才需要建立 build、版本與發布流程。
Q: 每個 Vue app 還需要自己的 package.json 嗎?
A: 需要。根目錄負責宣告 workspaces 與統一操作,每個 app 的 package.json 仍要記錄自己的名稱、scripts、dependencies 與 devDependencies。這個邊界讓你能單獨啟動、建置或部署某一個 app。
Q: npm Workspaces 可以讓兩個 Vue 專案同時啟動嗎?
A: Workspace 能替指定 package 執行 script,但 npm 的 --workspaces 不是專門的長時間程序並行器。開發時可先用兩個終端機分別執行;確定需要單一指令後,再加入並行工具並處理 log、退出碼與關閉行為。
Q: 只部署 admin,可以在 apps/admin 裡執行 npm ci 嗎?
A: 不建議。這個架構只有根目錄 package-lock.json,而且 admin 依賴 packages/shared 與 packages/ui。CI 應在 repository 根目錄執行 npm ci,再用 npm run build -w @example/admin 限定建置目標,最後部署 apps/admin/dist。
參考資料:
npm Docs:package.json 的 workspaces 欄位
回報錯字、失效連結,或告訴我你想看的延伸主題。