- name
- vue-patterns
- description
- Vue.js 3 Composition API 模式、组件架构、响应式最佳实践、Pinia 状态管理、Vue Router 导航和 Nuxt SSR 模式。在 Vue、Nuxt、Vite 或 Pinia 项目中激活。
- origin
- ECC
# Vue.js 模式与最佳实践
使用 Composition API(`<script setup>`)进行 Vue.js 3 开发的综合指南,覆盖组件设计、响应式、状态管理、路由、测试和 SSR 模式。Nuxt 专属指引在与原生 Vue 不同处给出。
## 何时激活
在以下情况激活:
- 项目使用 Vue.js(任何版本)、Nuxt、Vite + Vue 或 Pinia。
- 用户问 Vue 组件架构、composables、响应式或状态管理。
- 审查 Vue Single-File Components(`.vue` 文件)。
- 搭建 Vue Router、Pinia stores 或 Vite/Vitest 配置。
- 讨论 Vue 专属性能、安全或 SSR 模式。
---
## 1. 项目结构
### 推荐布局(Feature-First)
```
src/
├── api/ # API client 和 endpoint 定义
├── assets/ # 静态资源(图片、字体、图标)
├── components/ # 共享/复用组件
│ ├── base/ # 基础 UI primitives(Button、Input、Modal)
│ └── features/ # Feature 专属共享组件
├── composables/ # 可复用 Composition API 逻辑
├── layouts/ # 页面布局(可选)
├── pages/ # Route 级页面组件
├── router/ # Vue Router 配置
├── stores/ # Pinia stores
├── types/ # TypeScript 类型定义
├── utils/ # 纯工具函数
└── App.vue # 根组件
```
### 文件命名
| 约定 | 何时使用 |
|-----------|-------------|
| `PascalCase.vue` | 所有组件(由 `vue/multi-word-component-names` 强制) |
| `useCamelCase.ts` | Composables |
| `camelCase.ts` | 工具、API clients、types |
| `kebab-case` 目录 | Route segments、feature 文件夹 |
---
## 2. 组件架构
### Single-File Component 顺序
```vue
<script setup lang="ts">
// 1. Imports(vue → ecosystem → 绝对 → 相对)
// 2. Props & Emits & Slots
// 3. Composables
// 4. 本地状态(ref/reactive)
// 5. Computed 属性
// 6. Methods
// 7. Watchers
// 8. 生命周期 hooks
</script>
<template>
<!-- Template 内容 -->
</template>
<style scoped>
/* Scoped 样式 */
</style>
```
### Presentational vs Container
- **Container 组件**:拥有数据获取、状态和副作用。渲染 presentational 组件。
- **Presentational 组件**:接收 props、emit 事件。无 API 调用、无 store 访问。纯渲染。
### Props 最佳实践
```ts
// 基于类型的 props,带默认值
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});
```
- 始终提供 `type`,并在合适时提供 `required`/`default`。
- Boolean props:`isXxx`、`hasXxx`、`canXxx`。
- 绝不修改 props——改为 emit 事件。
- 对 v-model 绑定,使用 `defineModel()`(Vue 3.4+)或 `modelValue` + `update:modelValue`。
### 事件
```ts
const emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();
```
- 在 templates 中用 kebab-case(`@update:model-value`)。
- 在 script 中用 camelCase(`emit("update:modelValue", val)`)。
---
## 3. Composables(可复用逻辑)
### 结构
```ts
// 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`),绝不返回普通 primitives。
- 通过 `MaybeRef` / `toRef()` / `toValue()` 接受响应式输入。
- 在 `onUnmounted` 或 watcher `onCleanup` 中清理副作用。
- 无模块作用域副作用。
### vs Mixins
Composables 完全取代 Vue 2 mixins:
- **Mixins**:不透明数据流、source-of-truth 冲突、命名冲突。
- **Composables**:显式 imports、清晰返回值、可组合且 tree-shakable。
---
## 4. 状态管理
### 何时用什么
| 模式 | 用例 |
|---------|----------|
| `ref()` / `reactive()` | 本地组件状态 |
| Props + Emits | 父子通信 |
| Provide / Inject | Theme、config、plugin API |
| Pinia store | 全局、共享、复杂状态 |
| Server state composable | 带 cache 的 API 数据(包装 `fetch`/TanStack Query) |
### Pinia Setup Store(首选)
```ts
// 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)。
- 业务级 mutation 优先用 actions,成组更新用 `$patch()`。
- 每个异步 action:处理 loading + success + error。
---
## 5. Vue Router
### Route 定义
```ts
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // 懒加载
props: true, // 把 params 作为 props 传入
meta: { requiresAuth: true },
},
];
```
### Navigation Guards
```ts
router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
```
### 响应式 Route Params
当组件保持挂载但 route params 变化时:
```ts
const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));
```
---
## 6. 模板模式
### 模板语法
```vue
<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>{{ content }}</div>
<!-- v-show 用于频繁切换 -->
<div v-show="isOpen">Toggled content</div>
<!-- v-for 带稳定 key -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- Computed 过滤列表(不是同一元素上的 v-if + v-for) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- 事件处理 -->
<form @submit.prevent="handleSubmit">
<button type="submit">Save</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` over `v-if` | 频繁的可见性切换 |
| `<KeepAlive :max="10">` | 缓存切换的视图 |
| 懒加载 routes | `() => import(...)` 用于非关键 routes |
| `Suspense` | 带 fallback 的异步组件加载 |
---
## 8. 测试
### 技术栈
- **Vitest** 用于单元和组件测试
- **Vue Test Utils** 用于挂载和交互
- **@pinia/testing** 用于 store mocking
- **Playwright** 用于 E2E
### 组件测试模式
```ts
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import UserCard from "./UserCard.vue";
beforeEach(() => { setActivePinia(createPinia()); });
it("renders and emits", 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 专属模式
### Auto-Imports
Nuxt 自动导入 `ref`、`computed`、`watch`、`useFetch`、`useAsyncData` 等。直接使用,无需 import。非 Nuxt 项目始终显式 import。
### useAsyncData / useFetch
```ts
const { data: user, pending, error, refresh } = await useAsyncData(
"user", // 用于 cache 的唯一 key
() => $fetch(`/api/users/${id}`),
);
const { data: posts } = await useFetch("/api/posts", {
query: { page: 1 },
key: "posts-page-1", // 去重请求
});
```
### Server Routes
```ts
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... fetch and return
});
```
### Runtime Config
```ts
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// server-only
apiSecret: "",
// public(暴露给客户端)
public: {
apiBase: "https://api.example.com",
},
},
});
```
---
## 10. Vue 3.5+ 新 API
### Reactive Props Destructure
Vue 3.5 稳定了 reactive props destructure——从 `defineProps()` 解构的变量会自动保持响应式:
```ts
// Vue 3.5+:解构 props 是响应式(不需要 toRefs)
const { count = 0, msg = "hello" } = defineProps<{
count?: number;
msg?: string;
}>();
// 限制:不能直接 watch 解构 prop
watch(() => count, (newVal) => { ... }); // 需要 getter
```
### `useTemplateRef()`
用 `useTemplateRef()` 替代名称匹配的普通 ref 来做 template references:
```ts
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" 匹配 template 中的 ref="input" 属性,不是变量名
```
支持动态 ref IDs:`useTemplateRef(dynamicRefId)`。
### `onWatcherCleanup()`
全局可 import 的 watcher cleanup API(Vue 3.5+)。必须在 watcher callback 内同步调用:
```ts
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
View on GitHub