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)。 - 在脚本中使用 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中清理副作用。 - 没有模块作用域的副作用。
与 Mixins 对比
Composables 完全取代 Vue 2 mixins:
- Mixins:不透明的数据流、真理源冲突、命名冲突。
- Composables:显式导入、清晰的返回值、可组合且可摇树优化。
4. 状态管理
何时使用什么
| 模式 | 使用场景 |
|---|---|
ref() / reactive() | 本地组件状态 |
| Props + Emits | 父子通信 |
| Provide / Inject | 主题、配置、插件 API |
| Pinia store | 全局、共享、复杂状态 |
| 服务端状态 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, // 将 params 作为 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 | 异步组件加载并提供回退内容 |
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", // 用于缓存的唯一 key
() => $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() 替代名称匹配的普通 refs 来获取模板引用:
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>
懒加载 Hydration(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 | 执行顺序不明确 | 使用 computed 过滤后的数组 |
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— 通用代码质量标准