Vue.js 模式與最佳實務
使用 Composition API(<script setup>)進行 Vue.js 3 開發的全面指南,涵蓋元件設計、響應式、狀態管理、路由、測試以及 SSR 模式。若與標準 Vue 有所差異,也會提供 Nuxt 專用的指引。
何時啟用
在以下情況啟用此技能:
- 專案使用了 Vue.js (任何版本)、Nuxt、Vite + Vue 或 Pinia。
- 使用者詢問關於 Vue 元件架構、Composables、響應式或狀態管理的問題。
- 審查 Vue 單一檔案元件 (
.vue檔案)。 - 設定 Vue Router、Pinia stores 或 Vite/Vitest 配置。
- 討論 Vue 相關的效能、安全性或 SSR 模式。
1. 專案結構
推薦佈局(功能優先)
src/
├── api/ # API 客戶端與端點定義
├── assets/ # 靜態資源 (圖片、字體、圖示)
├── components/ # 共享/可複用元件
│ ├── base/ # 基礎 UI 原語 (Button, Input, Modal)
│ └── features/ # 功能特定共享元件
├── composables/ # 可複用的 Composition API 邏輯
├── layouts/ # 頁面佈局 (可選)
├── pages/ # 路由層級頁面元件
├── router/ # Vue Router 配置
├── stores/ # Pinia stores
├── types/ # TypeScript 型別定義
├── utils/ # 純工具函式
└── App.vue # 根元件
檔案命名
| 慣例 | 使用時機 |
|---|---|
PascalCase.vue | 所有元件 (由 vue/multi-word-component-names 強制) |
useCamelCase.ts | Composables |
camelCase.ts | 工具函式、API 客戶端、型別 |
kebab-case 目錄 | 路由區段、功能資料夾 |
2. 元件架構
單一檔案元件順序
<script setup lang="ts">
// 1. 匯入 (vue → 生態系 → 絕對路徑 → 相對路徑)
// 2. Props & Emits & Slots
// 3. Composables
// 4. 局部狀態 (ref/reactive)
// 5. 計算屬性
// 6. 方法
// 7. 監聽器
// 8. 生命週期鉤子
</script>
<template>
<!-- 模板內容 -->
</template>
<style scoped>
/* 作用域樣式 */
</style>
展示型 vs 容器型
- 容器元件:負責資料獲取、狀態與副作用。渲染展示型元件。
- 展示型元件:接收 props,觸發事件。不做 API 呼叫,不存取 store。純渲染。
Props 最佳實務
// 基於型別的 props 搭配預設值
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});
- 永遠提供
type,以及適當的required/default。 - 布林 props:
isXxx、hasXxx、canXxx。 - 永不直接修改 props — 改為觸發事件。
- 對於 v-model 綁定,使用
defineModel()(Vue 3.4+) 或modelValue+update:modelValue。
事件
const emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();
- 在模板中使用 kebab-case (
@update:model-value)。 - 在 script 中使用 camelCase (
emit("update:modelValue", val))。
3. Composables (可複用邏輯)
結構
// composables/useDebounce.ts
export function useDebounce<T>(value: MaybeRef<T>, delay: number): Ref<T> {
const debounced = ref(toValue(value)) as Ref<T>;
let timer: ReturnType<typeof setTimeout>;
watch(
() => toValue(value),
(newVal) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = newVal; }, delay);
}
);
onUnmounted(() => clearTimeout(timer));
return readonly(debounced);
}
規則
- 必須以
use前綴開頭。 - 回傳響應式值 (
ref、computed、reactive),永遠不回傳純原始型別。 - 透過
MaybeRef/toRef()/toValue()接受響應式輸入。 - 在
onUnmounted或監聽器的onCleanup中清理副作用。 - 避免模組作用域的副作用。
vs Mixins
Composables 完全取代了 Vue 2 的 mixins:
- Mixins:資料流不透明、資料來源衝突、名稱衝突。
- Composables:顯式匯入、清晰的回傳值、可組合且可 tree-shaking。
4. 狀態管理
何時使用何種模式
| 模式 | 使用場景 |
|---|---|
ref() / reactive() | 局部元件狀態 |
| Props + Emits | 父子元件通訊 |
| Provide / Inject | 主題、配置、外掛 API |
| Pinia store | 全域、共享、複雜狀態 |
| Server state composable | 帶快取的 API 資料 (包裝 fetch/TanStack Query) |
Pinia Setup Store (首選)
// stores/useCartStore.ts
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const isLoading = ref(false);
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.quantity, 0)
);
async function addItem(productId: string) {
isLoading.value = true;
try {
const item = await fetchProduct(productId);
const existing = items.value.find(i => i.id === item.id);
if (existing) existing.quantity++;
else items.value.push({ ...item, quantity: 1 });
} finally {
isLoading.value = false;
}
}
return { items, isLoading, totalPrice, itemCount, addItem };
});
- 使用 Setup Store 語法 (非 Options Store)。
- 對於商業邏輯層級的資料變更,偏好使用 actions;對於批量更新,使用
$patch()。 - 每個非同步 action:處理 loading + success + error。
5. Vue Router
路由定義
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // 懶加載
props: true, // 將參數作為 props 傳遞
meta: { requiresAuth: true },
},
];
導航守衛
router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
響應式路由參數
當元件保持掛載但路由參數改變時:
const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));
6. 模板模式
模板語法
<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">載入中...</div>
<div v-else-if="error">錯誤:{{ error }}</div>
<div v-else>{{ content }}</div>
<!-- v-show 用於頻繁切換 -->
<div v-show="isOpen">切換的內容</div>
<!-- v-for 搭配穩定 key -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- 使用計算後的過濾列表 (避免在同一個元素上同時使用 v-if + v-for) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- 事件處理 -->
<form @submit.prevent="handleSubmit">
<button type="submit">儲存</button>
</form>
<!-- v-model -->
<input v-model="name" />
<CustomInput v-model="value" v-model:title="title" />
7. 效能
| 技術 | 使用時機 |
|---|---|
v-memo | 很少變動的列表項目 |
v-once | 只渲染一次且永遠靜態的內容 |
shallowRef() | 整個取代的大型資料結構 |
shallowReactive() | 僅頂層屬性需要響應式 |
用 v-show 替代 v-if | 頻繁的可見度切換 |
<KeepAlive :max="10"> | 快取切換的視圖 |
| 惰性路由 | 非關鍵路由使用 () => import(...) |
Suspense | 帶有 fallback 的非同步元件載入 |
8. 測試
技術棧
- Vitest 用於單元測試與元件測試
- Vue Test Utils 用於掛載與互動
- @pinia/testing 用於 store 模擬
- Playwright 用於端對端測試
元件測試模式
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import UserCard from "./UserCard.vue";
beforeEach(() => { setActivePinia(createPinia()); });
it("渲染並觸發事件", async () => {
const wrapper = mount(UserCard, {
props: { user: { id: "1", name: "Alice" } },
});
expect(wrapper.text()).toContain("Alice");
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("select")![0]).toEqual(["1"]);
});
9. Nuxt 特定模式
自動匯入
Nuxt 會自動匯入 ref、computed、watch、useFetch、useAsyncData 等。直接使用它們,無需手動匯入。對於非 Nuxt 專案,務必顯式匯入。
useAsyncData / useFetch
const { data: user, pending, error, refresh } = await useAsyncData(
"user", // 用於快取的唯一鍵
() => $fetch(`/api/users/${id}`),
);
const { data: posts } = await useFetch("/api/posts", {
query: { page: 1 },
key: "posts-page-1", // 去重請求
});
伺服器路由
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... 獲取並回傳
});
執行時期配置
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 僅伺服器端
apiSecret: "",
// 公開 (暴露給客戶端)
public: {
apiBase: "https://api.example.com",
},
},
});
10. Vue 3.5+ 新 API
響應式 Props 解構
Vue 3.5 穩定了響應式 Props 解構 — 從 defineProps() 解構的變數會自動變成響應式:
// Vue 3.5+: 解構的 props 是響應式的 (不需要使用 toRefs)
const { count = 0, msg = "hello" } = defineProps<{
count?: number;
msg?: string;
}>();
// 限制:無法直接監聽解構的 prop
watch(() => count, (newVal) => { ... }); // 正確 必須傳入 getter
useTemplateRef()
使用 useTemplateRef() 取代基於名稱匹配的普通 ref,用於模板引用:
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" 匹配模板中的 ref="input" 屬性,而非變數名稱
支援動態 ref ID:useTemplateRef(dynamicRefId)。
onWatcherCleanup()
全域可匯入的監聽器清理 API (Vue 3.5+)。必須在監聽器回呼內部同步呼叫:
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... 使用 signal 進行 fetch
});
useId()
SSR 穩定的唯一 ID 產生器,用於表單元素和無障礙功能:
import { useId } from "vue";
const id = useId();
defer Teleport
<Teleport defer> 允許傳送到在同一渲染週期中產生的目標:
<Teleport defer to="#container">內容</Teleport>
<div id="container"></div>
惰性水合 (SSR)
defineAsyncComponent() 現在支援 hydrate 策略:
import { defineAsyncComponent, hydrateOnVisible } from "vue";
const AsyncComp = defineAsyncComponent({
loader: () => import("./Comp.vue"),
hydrate: hydrateOnVisible(),
});
反模式
| 反模式 | 為何錯誤 | 修正方法 |
|---|---|---|
解構 defineProps() (Vue < 3.5) | 捕獲快照,失去響應性 | 透過 props.xxx 存取或使用 toRefs() |
watch() 解構後的 prop (Vue 3.5+) | 編譯時錯誤 — 無法直接監聽解構後的 props | 使用 getter 包裝:watch(() => count, ...) |
在同一元素上使用 v-if + v-for | 執行順序不明確 | 使用計算後的過濾陣列 |
v-for 的 key 使用索引 | 重新排序時狀態錯亂 | 使用穩定的資料庫 ID |
| 修改 props | 違反單向資料流 | 觸發事件或使用 v-model |
使用帶有用戶內容的 v-html | XSS 漏洞 | 使用 DOMPurify 進行清理 |
| 在 Vue 3 中使用 Mixins | 不透明、易衝突 | 替換為 Composables |
| Composable 中的模組作用域副作用 | 跨實例共享 | 在 onMounted + onUnmounted 中限定作用域 |
對可替換的狀態使用 reactive() | 替換會破壞響應性 | 改用 ref() |
| 未清理的監聽器 | 記憶體洩漏、競爭條件 | 使用 onCleanup 或 onWatcherCleanup() (Vue 3.5+) |
| 在新的 Vue 3 程式碼中使用 Options API | 生態系已轉向 Composition API | 使用 <script setup> |
| 使用普通 ref 做為模板引用 | 不支援動態 ref,名稱匹配脆弱 | 使用 useTemplateRef() (Vue 3.5+) |
相關技能
accessibility— ARIA、語意化 HTML、焦點管理frontend-patterns— 跨框架前端架構typescript— 應用於 Vue 專案的 TypeScript 最佳實務coding-standards— 通用程式碼品質標準