從理念到實踐:Nuxt Monorepo 開發大型 Vue Web 應用

2023-12-23

近年來 Monorepo 架構被愈來愈多專案所使用,在早前把舊有的 Vue 大型專案使用 Nuxt 3 重構的過程中,我認為 Nuxt 3 是一款非常適合開發大型專案,且極度注重 DX 及對 Monorepo 非常友善的框架,在此整合了一些經驗與大家分享,並會較深入探討細節。

這篇文章適合對 Vue 及 Nuxt 3 已有初步認識的讀者閱讀。

架構設計目標

在設計架構時,我將開發體驗 DX 放在首位,並且著眼於實現簡潔明暸、高度可重用和靈活擴展的特性。

專案架構

  • 支持多應用開發:支持多個 Web 應用的同時開發
  • 共享資源:允許 Web 應用之間共享 UI 庫和其他 package,例如 API 客戶端
  • 依賴管理:Monorepo package 之間通過 package 名稱進行 import,避免使用相對路徑 (relative path)

開發

  • 簡化新 package 的建立:減少建立新的 monorepo package 所需的配置複雜度
  • 獨立性:確保 monorepo 內的每個 package 都可以獨立開發、運行、測試和發佈
  • 避免頻密編譯:支持從 monorepo 的 package 中直接導入共享的 .ts.vue 模組及支援 HMR,無需在每次更改後重新編譯

編譯及發佈

  • 環境配置的共享與獨立性:在 monorepo 的根目錄允許共享 .env 文件,同時支持在各個 package 中使用專屬的 .env
  • 優化編譯:保證 Monorepo 中的 package 相互導入時支持 Tree Shaking
  • 類型檢查:確保通過 Type Check

選擇合適的工具

pnpm workspace

面對市場上各式各樣的 Monorepo 工具,如 Turborepo、NX、Bit 等,選擇一個既滿足需求又易於使用的工具至關重要。我最終選擇了 pnpm workspace,它不僅減少了學習新工具的負擔,而且它的設計理念與 Monorepo 的需求完美契合。

Nuxt Layers

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 一樣強大的功能(例如使用 hookaddComponent 等),你也可以直接在 Layer 中編寫 Nuxt Module,這進一步增強了其靈活性和功能性。


開始構建 Monorepo

建立 pnpm Workspace

要開始建立一個 Monorepo 環境,首先需要設定 pnpm workspace。以下是基本步驟:

  1. 使用 pnpm init 指令或者手動建立 package.json
  2. 建立一個空白的資料夾 packages
  3. 建立 pnpm-workspace.yaml 並設定放置 packages 的資料夾 packages/*

提示:你可以直接點擊下方互動界面的檔名,來查看每個檔案的內容

packages:
  - packages/*

建立 UI Layer

現在建立一個 @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"
  }
}

你也可以選擇使用 Nuxt Layer 的 Starter Template 來建立 Layer。要注意的是,在本 Monorepo 架構中,Starter Template 中的 typescript.includeWorkspace 設定需被取消,以避免在 Type Check 時產生錯誤。

建立 App

在成功建立 UI Layer 之後,下一步是建立一個繼承自 UI Nuxt Layer 的 Nuxt App。

  1. packages 目錄下,使用 nuxi 命令來創建一個名為 app 的 Nuxt 專案。
cd packages
pnpm dlx nuxi@latest init app
  1. 為了保持一致性,將 app 的 package 名稱改為 @nuxt-monorepo/app
packages/app/package.json
- "name": "nuxt-app",
+ "name": "@nuxt-monorepo/app",
  1. 進入 app 目錄並執行 pnpm install 以初始化專案。
cd app
pnpm i

把 UI Layer 加入到 App

  1. app 目錄下,新增剛剛建立的 @nuxt-monorepo/ui 作為 devDependencies
pnpm add -D @nuxt-monorepo/ui
  1. appnuxt.config.ts 中,將 @nuxt-monorepo/ui 添加為 Layer。
packages/app/nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  extends: ["@nuxt-monorepo/ui"]
})
  1. 嘗試在 app.vue 中使用 uicomponents/TheMessage。這樣 app 專案應該會像下面這樣:
<template>
    <div>
        <TheMessage />
    </div>
</template>

完成這些設定後,你的 app 現在已經繼承了 ui Layer。通過 Nuxt 的 Auto-imports 功能,你可以隨時使用 ui 中的任何元件。

執行 App

執行 app 之後,你應該可以看到 uiTheMessage 元件在 app 中成功顯示。

# packages/app
pnpm dev
Component from ui

在所有 package 執行命令

如果你一次過批量執行 monorepo 中每個 package 的 script (例如 build),你可以使用 pnpm 的 --recursive (-r)

package.json
{
  "name": "nuxt-monorepo",
  "scripts": {
    "build": "pnpm -r build"
  }
}

當你在 monorepo 的根目錄執行 pnpm build 時,就會自動在 uiapp 及其他所有包含 build 的 package 中自動執行 build 命令。

除此之外,你也可以利用 --filter (-F) 自定義批量執行命令的 package。


設定共用

在使用 Monorepo 架構進行開發時,難免會有不少設定需要共用,以下將分享一些常用的設定如何在不同 packages 之間共用。


共用 Tailwind CSS 設定

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


共用 UnoCSS 設定

若想在 app 中使用 uiUnoCSS 設定,建議在 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


共用 Autoprefixer 設定

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 文件,則必須進行一些額外的配置。

使用 dotenv-cli 管理 .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.jsonscripts

package.json
{
  "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,允許你在執行 devbuild 時指定讀取特定的 .env 文件。

這個解決方案的優點在於你無需額外安裝套件,但缺點則是你需要在每個 script 中加入 --dotenv ../../.env,這不僅使用了相對路徑,違背了我們的「依賴管理」架構設計目標,而且也缺乏在讀取專案目錄 .env 和 monorepo 根目錄 .env 之間靈活切換的能力,因此我不太推薦在本 monorepo 架構中使用此方案。


開發技巧

在使用本 monorepo 架構及 Nuxt Layer 開發時,有一些技巧需要特別留意,才能符合我們的架構設計目標。


在 Layer 使用 Path Alias

在開發 Web 應用時,我們經常使用 Path Alias 來避免使用較不直觀的相對路徑 (relative path)。但在本 Monorepo 的架構環境中,這個做法需要特別注意。

Nuxt 預設 Root Path Alias 的問題

當在 ui Layer 中使用 Nuxt 的預設 Root Path Alias(例如 ~/@/)時,雖然 nuxt devbuild 指令在 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) 讀取的是 apptsconfig.json 設定。舉例來說,如果你在 ui 中嘗試 import ~/constants/message,本意是指向 ui/constants/message.ts。但在 app 中運行 tsc 時,由於 app 的 Path Alias 設定優先,這會導致錯誤地將路徑解析為 app/constants/message.ts

Nuxt 為何能夠順利編譯?

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。

packages/ui/nuxt.config.ts
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'

目前我也正在著手研究可以同時兼容使用預設 Root Path Alias ~/ 與 Type Check 的方法,解決方向為修改 vue-tsc 或開發 Plugin 讓它能夠正確解析路徑。由於 Volar.js 與 vue-tsc 最近都在大幅度重構中,因此我會等待較隱定後再落實解決方法。同時我為此問題在 Nuxt 建立了 Issue,歡迎關注。


使用 Component Prefix

隨著 monorepo 中的 packages 和 Layer 數量增加,識別不同 packages 之間的元件來源可能會變得困難。

以上面的 ui/TheMessage 為例子,在 app 中使用 <TheMessage> 時,我們無法立即識別出該 component 是來自 ui 還是 app 自己本身。

我們可以利用 Nuxt 的 components prefix 設定,把所有 ui components 都統一加上自訂的前綴。

packages/ui/nuxt.config.ts
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 的出處。

packages/app/app.vue
<template>
    <div>
        <UiTheMessage />
    </div>
</template>

使用 Custom Directives

Nuxt 的 Auto-imports 無法自動載入 Custom Directives,以下提供 2 個解決方法:

  • 使用 Nuxt 官方文件中建議的全局註冊
  • 手動在 .vue 文件中 import;或者
packages/ui/components/TheTestDirective.vue
<script setup lang="ts">
import { vTest } from '#imports'
</script>

<template>
    <div v-test />
</template>

<script> 中使用 Component

Nuxt 的 Auto-imports 只會在 <template> 中自動載入 components,如果你想在 <script> 內引用 component,你需要手動 import

packages/app/app.vue
<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 ⭐️

🎉  感謝您的閱讀,不仿看看我的其他文章,也歡迎在 XGitHub 上交流。