近年來 Monorepo 架構被愈來愈多專案所使用,在早前把舊有的 Vue 大型專案使用 Nuxt 3 重構的過程中,我認為 Nuxt 3 是一款非常適合開發大型專案,且極度注重 DX 及對 Monorepo 非常友善的框架,在此整合了一些經驗與大家分享,並會較深入探討細節。
這篇文章適合對 Vue 及 Nuxt 3 已有初步認識的讀者閱讀。
在設計架構時,我將開發體驗 DX 放在首位,並且著眼於實現簡潔明暸、高度可重用和靈活擴展的特性。
.ts
或 .vue
模組及支援 HMR,無需在每次更改後重新編譯.env
文件,同時支持在各個 package 中使用專屬的 .env
面對市場上各式各樣的 Monorepo 工具,如 Turborepo、NX、Bit 等,選擇一個既滿足需求又易於使用的工具至關重要。我最終選擇了 pnpm workspace,它不僅減少了學習新工具的負擔,而且它的設計理念與 Monorepo 的需求完美契合。
Nuxt 3 提供了 Layers 的強大功能,特別適合在 Monorepo 架構中使用。Layers 提供了極高的靈活性。在 Nuxt 3 中,每個 Layer 都可以是一個獨立的 Nuxt 應用,這使得在開發不同 Layer 時可以直接利用 Nuxt 的 Directory Structure。
更有趣的是,你可以讓一個 Nuxt 應用直接繼承(extends)一個或多個 Nuxt Layer。例如,你可能創建了一個 UI
Layer,然後只需在你的 App
Layer 的配置中加入一行 extends
代碼,就可以自由使用 UI
Layer 中的所有組件 (包括 components
, composables
等)。此外,你甚至可以在 App
Layer 中覆寫 UI
Layer 的任何 config。
當你需要像 Nuxt Module 一樣強大的功能(例如使用 hook
或 addComponent
等),你也可以直接在 Layer 中編寫 Nuxt Module,這進一步增強了其靈活性和功能性。
要開始建立一個 Monorepo 環境,首先需要設定 pnpm workspace。以下是基本步驟:
pnpm init
指令或者手動建立 package.json
packages
pnpm-workspace.yaml
並設定放置 packages 的資料夾 packages/*
提示:你可以直接點擊下方互動界面的檔名,來查看每個檔案的內容
packages:
- packages/*
現在建立一個 @nuxt-monorepo/ui
Nuxt Layer package,它將可以被其他不同的 Nuxt package 繼承及使用。
在 packages
資料夾內建立一個名為 ui
的子資料夾,按照以下範例配置基本 Nuxt 專案:
{
"name": "@nuxt-monorepo/ui",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"nuxt": "^3.8.2",
"vue": "^3.3.10",
"vue-router": "^4.2.5"
}
}
.npmrc
內的 shamefully-hoist
設定將會改變 pnpm 在整個 monorepo 執行 install
時的行為。ui/components
內的是所有 ui
的元件,這是來自 Nuxt 的 Directory Structureui/nuxt.config.ts
內的設定,可被其他 Nuxt App / Layer 繼承 (extends)ui/tsconfig.json
是用作 extends .nuxt
的 tsconfig.json
ui/package.json
需要設定 main
以確保 Layer 可以正確被繼承
"main": "./nuxt.config.ts"
你也可以選擇使用 Nuxt Layer 的 Starter Template 來建立 Layer。要注意的是,在本 Monorepo 架構中,Starter Template 中的 typescript.includeWorkspace
設定需被取消,以避免在 Type Check 時產生錯誤。
在成功建立 UI Layer 之後,下一步是建立一個繼承自 UI Nuxt Layer 的 Nuxt App。
packages
目錄下,使用 nuxi
命令來創建一個名為 app
的 Nuxt 專案。cd packages
pnpm dlx nuxi@latest init app
app
的 package 名稱改為 @nuxt-monorepo/app
。- "name": "nuxt-app",
+ "name": "@nuxt-monorepo/app",
app
目錄並執行 pnpm install 以初始化專案。cd app
pnpm i
app
目錄下,新增剛剛建立的 @nuxt-monorepo/ui
作為 devDependencies
。pnpm add -D @nuxt-monorepo/ui
app
的 nuxt.config.ts
中,將 @nuxt-monorepo/ui
添加為 Layer。export default defineNuxtConfig({
devtools: { enabled: true },
extends: ["@nuxt-monorepo/ui"]
})
app.vue
中使用 ui
的 components/TheMessage
。這樣 app
專案應該會像下面這樣:<template>
<div>
<TheMessage />
</div>
</template>
完成這些設定後,你的 app
現在已經繼承了 ui
Layer。通過 Nuxt 的 Auto-imports 功能,你可以隨時使用 ui
中的任何元件。
執行 app
之後,你應該可以看到 ui
的 TheMessage
元件在 app
中成功顯示。
# packages/app
pnpm dev
如果你一次過批量執行 monorepo 中每個 package 的 script (例如 build
),你可以使用 pnpm 的 --recursive (-r)
。
{
"name": "nuxt-monorepo",
"scripts": {
"build": "pnpm -r build"
}
}
當你在 monorepo 的根目錄執行 pnpm build
時,就會自動在 ui
、app
及其他所有包含 build
的 package 中自動執行 build
命令。
除此之外,你也可以利用 --filter (-F)
自定義批量執行命令的 package。
在使用 Monorepo 架構進行開發時,難免會有不少設定需要共用,以下將分享一些常用的設定如何在不同 packages 之間共用。
Nuxt Tailwind 是 Nuxt 官方開發的 Nuxt Module,對於在 Nuxt Layer 中的整合提供了良好的支援。
你只需在 ui
Layer 中進行基本的 Tailwind CSS 設定,app
就可以直接使用這些設定,而且在 app
中加入的 tailwind.config.js
將會與 ui
的配置自動合併。
module.exports = {
theme: {
colors: {
'app': 'red'
}
}
}
完整範例:nuxt-monorepo/tailwindcss
若想在 app
中使用 ui
的 UnoCSS 設定,建議在 ui
中定義一個 UnoCSS preset,然後將 ui
添加到其他 package 的 presets 中。(參考討論 UnoCSS #2430)
import {
defineConfig,
transformerDirectives
} from 'unocss';
import uiPreset from '@nuxt-monorepo/ui/preset';
export default defineConfig({
presets: [uiPreset],
transformers: [transformerDirectives()],
theme: {
colors: {
'app': 'red'
}
}
});
請注意,preset 中不能包含 Transformers,因此需要分別在每個 UnoCSS config 中加入。
完整範例:nuxt-monorepo/unocss
Nuxt 已內置了 Autoprefixer,因此,如果你想在多個 Nuxt App 之間共用 Browserslist 設定,可以將 .browserslistrc
檔案放置在 monorepo 的根目錄。這樣,當在 app
中運行或編譯時,系統會自動尋找並應用上級目錄中的設定。
defaults
not ie > 0
not ie_mob > 0
如果你同時在 package 內和 monorepo 根目錄內都放置了 Browserslist 設定,則會以 package 內的設定為優先。
.env
環境變數在 Nuxt 中,默認情況下會讀取執行項目目錄下的 .env
文件。但是,在 Monorepo 架構中,這可能導致一些問題。
以 app
為例,當在 app
目錄下運行 Nuxt 時,系統僅會讀取 app/.env
,而忽略 ui/.env
及所有上級目錄中的 .env
。
這意味著如果你打算在 monorepo 的根目錄放置一個共用的 .env
文件,則必須進行一些額外的配置。
.env
在 Monorepo 環境中,dotenv-cli 可以在執行命令之前載入 .env
文件到環境變數中。以下是如何在你的項目中使用 dotenv-cli 的步驟:
在 monorepo 的根目錄下安裝 dotenv-cli。
# project root
pnpm i -D -w dotenv-cli
在根目錄中創建一個 .env
文件,並定義所需的環境變數。
echo "NUXT_PUBLIC_FOO=bar" > .env
使用 dotenv-cli 載入根目錄的 .env
文件到環境變數,然後執行 app
的命令。
pnpm dotenv -- pnpm --filter @nuxt-monorepo/app dev
為了方便使用,你可以將部分命令添加到 package.json
的 scripts
。
{
"name": "nuxt-monorepo",
"scripts": {
"packages": "dotenv -- pnpm --filter",
"app": "pnpm packages @nuxt-monorepo/app"
},
"devDependencies": {
"dotenv-cli": "^7.3.0"
}
}
這樣,你可以在 monorepo 的根目錄中,使用更簡短的命令來達到相同的效果:
pnpm app dev
# or
# pnpm packages app dev
# or
# pnpm packages @nuxt-monorepo/app dev
.env
在安裝 dotenv-cli 後,若想忽略根目錄的 .env
,僅使用 app/.env
的話,你只需如常在 app
目錄下執行命令即可,無需修改任何配置。
cd packages/app
# run app dev with app/.env
pnpm dev
--dotenv
的利與弊除了使用 dotenv-cli 之外,Nuxt 提供了 --dotenv
的 flag,允許你在執行 dev
或 build
時指定讀取特定的 .env
文件。
這個解決方案的優點在於你無需額外安裝套件,但缺點則是你需要在每個 script
中加入 --dotenv ../../.env
,這不僅使用了相對路徑,違背了我們的「依賴管理」架構設計目標,而且也缺乏在讀取專案目錄 .env
和 monorepo 根目錄 .env
之間靈活切換的能力,因此我不太推薦在本 monorepo 架構中使用此方案。
在使用本 monorepo 架構及 Nuxt Layer 開發時,有一些技巧需要特別留意,才能符合我們的架構設計目標。
在開發 Web 應用時,我們經常使用 Path Alias 來避免使用較不直觀的相對路徑 (relative path)。但在本 Monorepo 的架構環境中,這個做法需要特別注意。
當在 ui
Layer 中使用 Nuxt 的預設 Root Path Alias(例如 ~/
或 @/
)時,雖然 nuxt dev
或 build
指令在 app
中正常運作,但進行 TypeScript 的類型檢查(Type Check)則會遇到問題。
<script setup lang="ts">
import { message } from '~/constants/message'
// ^ type check error
</script>
<template>
<div>
Component from ui {{ message }}
</div>
</template>
這一問題源於 TypeScript 編譯器 (tsc
) 讀取的是 app
的 tsconfig.json
設定。舉例來說,如果你在 ui
中嘗試 import ~/constants/message
,本意是指向 ui/constants/message.ts
。但在 app
中運行 tsc
時,由於 app
的 Path Alias 設定優先,這會導致錯誤地將路徑解析為 app/constants/message.ts
。
Nuxt 能夠順利完成編譯是因為它自行解析了這些 Path Alias,同時 nuxt build
默認不會執行 Type Check,因此在構建過程中不會出現錯誤。
值得一提的是,VS Code TypeScript 也能自行解析這些路徑,所以在 IDE 中不會出現錯誤提示。
如果你不在意是否能通過 Type Check,那麼可以繼續在 Layer 中使用 Nuxt 的預設 Root Path Alias。
相反地,如果 Type Check 對你的項目很重要,你可以透過 Nuxt 的 alias
config 來定義一個專屬的 Root Path Alias。
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
alias: { '~ui': resolve('./') }
})
在自定 Root Path Alias 為 ~ui
後,在 ui
即可以下列方式使用:
import { message } from '~ui/constants/message'
隨著 monorepo 中的 packages 和 Layer 數量增加,識別不同 packages 之間的元件來源可能會變得困難。
以上面的 ui/TheMessage
為例子,在 app
中使用 <TheMessage>
時,我們無法立即識別出該 component 是來自 ui
還是 app
自己本身。
我們可以利用 Nuxt 的 components prefix
設定,把所有 ui
components 都統一加上自訂的前綴。
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
alias: { '~ui': resolve('./') },
components: [
{ path: '~ui/components', prefix: 'Ui' }
]
})
這樣 ui
的 <TheMessage>
將會自動變成 <UiTheMessage>
,這樣在 app
中使用 ui
的 components 時,便能快速識別該 component 的出處。
<template>
<div>
<UiTheMessage />
</div>
</template>
Nuxt 的 Auto-imports 無法自動載入 Custom Directives,以下提供 2 個解決方法:
.vue
文件中 import;或者<script setup lang="ts">
import { vTest } from '#imports'
</script>
<template>
<div v-test />
</template>
<script>
中使用 ComponentNuxt 的 Auto-imports 只會在 <template>
中自動載入 components,如果你想在 <script>
內引用 component,你需要手動 import。
<script setup lang="ts">
import { UiTheMessage } from '#components'
const theMessage = ref<InstanceType<typeof UiTheMessage> | null>()
</script>
<template>
<div>
<UiTheMessage refs="theMessage" />
</div>
</template>
在使用 Nuxt 把專案重構後,大量減少了 import
的程式碼,標準化的目錄結構讓檔案能更有效地被分類以及使用。
無論你的 Vue 專案是 SSR、SSG 或者是單純的 SPA 網頁,Nuxt 都能有效解決所有需求,其中 Nuxt Layer 更是近乎完美的 Vue Monorepo 解決方案。
本文所提及的 Monorepo 架構並非概念驗証,而是已經實際應用到真實產品。雖然未必符合所有人的需求,但我希望能為 Vue 和 Nuxt 的開發者帶來一點啟發。
如果你還未看完全文就滾動到這邊找專案程式碼,恭喜你找到了!閱讀程式碼時如有不明白的地方,歡迎隨時回來看看文章。
完整程式碼:serkodev/nuxt-monorepo ⭐️