名称: vue-development 描述: Vue.js 3 开发,使用Composition API、TypeScript、Pinia状态管理和Nuxt 3全栈。适用于构建Vue应用、实现响应式模式、创建composables或与Vue生态系统一起工作。
Vue.js 3 开发
构建现代Vue.js应用的全面指南,使用Composition API。
堆栈概述
| 工具 | 目的 | 版本 |
|---|---|---|
| Vue 3 | 核心框架 | 3.4+ |
| TypeScript | 类型安全 | 5.0+ |
| Vite | 构建工具 | 5.0+ |
| Pinia | 状态管理 | 2.1+ |
| Vue Router | 路由 | 4.2+ |
| Nuxt 3 | 全栈框架 | 3.10+ |
| Vitest | 测试 | 1.0+ |
Composition API与脚本设置
基本组件结构
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
// 使用TypeScript定义Props
interface Props {
title: string;
count?: number;
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
});
// 使用TypeScript定义Emits
const emit = defineEmits<{
update: [value: number];
submit: [];
}>();
// 响应式状态
const localCount = ref(props.count);
const items = ref<string[]>([]);
// 计算属性
const doubleCount = computed(() => localCount.value * 2);
// 方法
function increment() {
localCount.value++;
emit("update", localCount.value);
}
// 生命周期
onMounted(() => {
console.log("组件已挂载");
});
</script>
<template>
<div class="component">
<h1>{{ title }}</h1>
<p>计数: {{ localCount }} (双倍: {{ doubleCount }})</p>
<button @click="increment">增加</button>
</div>
</template>
<style scoped>
.component {
padding: 1rem;
}
</style>
响应式模式
import { ref, reactive, computed, watch, watchEffect } from "vue";
// 原始值: 使用ref()
const count = ref(0);
const name = ref("");
count.value++; // 通过.value访问
// 对象/数组: 使用reactive()
const state = reactive({
items: [] as Item[],
loading: false,
error: null as Error | null,
});
state.items.push(item); // 直接访问,无.value
// 计算值
const total = computed(() => state.items.reduce((sum, i) => sum + i.price, 0));
// 观察特定值
watch(count, (newVal, oldVal) => {
console.log(`计数改变: ${oldVal} -> ${newVal}`);
});
// 观察带选项
watch(
() => state.items,
(newItems) => saveToStorage(newItems),
{ deep: true, immediate: true },
);
// 自动依赖跟踪
watchEffect(() => {
console.log(`当前计数: ${count.value}`);
});
Composables(可重用逻辑)
命名与结构
src/
└── composables/
├── useFetch.ts
├── useLocalStorage.ts
├── useDebounce.ts
└── useAuth.ts
约定: 始终以use前缀(例如,useFetch,useAuth)
创建Composables
// composables/useFetch.ts
import { ref, toValue, watchEffect, type MaybeRefOrGetter } from "vue";
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
async function fetchData() {
loading.value = true;
error.value = null;
try {
const response = await fetch(toValue(url));
if (!response.ok) throw new Error(`HTTP ${response.status}`);
data.value = await response.json();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
loading.value = false;
}
}
// 当URL变化时自动重新获取
watchEffect(() => {
fetchData();
});
return { data, error, loading, refetch: fetchData };
}
// 在组件中使用
const { data, error, loading } = useFetch<User[]>("/api/users");
// 使用响应式URL
const userId = ref(1);
const { data: user } = useFetch(() => `/api/users/${userId.value}`);
Composable最佳实践
// composables/useLocalStorage.ts
import { ref, watch, type Ref } from "vue";
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
// 读取初始值
const stored = localStorage.getItem(key);
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>;
// 同步到存储
watch(
value,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
},
{ deep: true },
);
return value;
}
// ✅ 好: 接受refs、getters或普通值
// ✅ 好: 返回带有refs的普通对象(非响应式)
// ✅ 好: 在onUnmounted中清理
// ✅ 好: 使用toValue()进行灵活输入
Pinia状态管理
存储定义(组合风格)
// stores/user.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export interface User {
id: number;
name: string;
email: string;
}
export const useUserStore = defineStore("user", () => {
// 状态
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// 计算属性
const isLoggedIn = computed(() => !!user.value);
const displayName = computed(() => user.value?.name ?? "访客");
// 操作
async function login(email: string, password: string) {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("登录失败");
user.value = await response.json();
} catch (e) {
error.value = e instanceof Error ? e.message : "未知错误";
throw e;
} finally {
loading.value = false;
}
}
function logout() {
user.value = null;
}
return {
// 状态
user,
loading,
error,
// 计算属性
isLoggedIn,
displayName,
// 操作
login,
logout,
};
});
在组件中使用存储
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
// 使用storeToRefs解构状态/计算属性
const { user, isLoggedIn, loading } = storeToRefs(userStore);
// 操作可直接解构
const { login, logout } = userStore;
async function handleLogin() {
try {
await login(email.value, password.value);
router.push("/dashboard");
} catch {
// 存储中处理错误
}
}
</script>
带持久化的存储
// stores/settings.ts
import { defineStore } from "pinia";
import { ref, watch } from "vue";
export const useSettingsStore = defineStore("settings", () => {
const theme = ref<"light" | "dark">("light");
const locale = ref("en");
// 初始化时从存储加载
const stored = localStorage.getItem("settings");
if (stored) {
const parsed = JSON.parse(stored);
theme.value = parsed.theme ?? "light";
locale.value = parsed.locale ?? "en";
}
// 持久化更改
watch([theme, locale], () => {
localStorage.setItem(
"settings",
JSON.stringify({
theme: theme.value,
locale: locale.value,
}),
);
});
function toggleTheme() {
theme.value = theme.value === "light" ? "dark" : "light";
}
return { theme, locale, toggleTheme };
});
组件模式
提供者/注入模式
// context/auth.ts
import { provide, inject, type InjectionKey } from "vue";
interface AuthContext {
user: Ref<User | null>;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthKey: InjectionKey<AuthContext> = Symbol("auth");
// 提供者组件
export function provideAuth() {
const user = ref<User | null>(null);
async function login(email: string, password: string) {
// 实现
}
function logout() {
user.value = null;
}
const context = { user, login, logout };
provide(AuthKey, context);
return context;
}
// 消费者钩子
export function useAuth(): AuthContext {
const context = inject(AuthKey);
if (!context) {
throw new Error("useAuth必须在AuthProvider内使用");
}
return context;
}
智能/哑组件
<!-- components/UserCard.vue (哑 - 展示) -->
<script setup lang="ts">
interface Props {
name: string;
email: string;
avatar?: string;
}
defineProps<Props>();
defineEmits<{ click: [] }>();
</script>
<template>
<div class="user-card" @click="$emit('click')">
<img :src="avatar ?? '/default-avatar.png'" :alt="name" />
<h3>{{ name }}</h3>
<p>{{ email }}</p>
</div>
</template>
<!-- views/UserList.vue (智能 - 容器) -->
<script setup lang="ts">
import { useFetch } from "@/composables/useFetch";
import UserCard from "@/components/UserCard.vue";
const { data: users, loading, error } = useFetch<User[]>("/api/users");
function handleUserClick(user: User) {
router.push(`/users/${user.id}`);
}
</script>
<template>
<div class="user-list">
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<template v-else>
<UserCard
v-for="user in users"
:key="user.id"
:name="user.name"
:email="user.email"
:avatar="user.avatar"
@click="handleUserClick(user)"
/>
</template>
</div>
</template>
Nuxt 3(全栈)
项目结构
nuxt-app/
├── pages/ # 文件基础路由
│ ├── index.vue # /
│ ├── about.vue # /about
│ └── users/
│ ├── index.vue # /users
│ └── [id].vue # /users/:id
├── components/ # 自动导入组件
├── composables/ # 自动导入composables
├── server/ # API路由(Nitro)
│ ├── api/
│ │ └── users.ts # /api/users
│ └── middleware/
├── layouts/ # 页面布局
├── middleware/ # 路由中间件
└── nuxt.config.ts
API路由
// server/api/users.ts
export default defineEventHandler(async (event) => {
const method = getMethod(event);
if (method === "GET") {
return await prisma.user.findMany();
}
if (method === "POST") {
const body = await readBody(event);
return await prisma.user.create({ data: body });
}
});
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
return await prisma.user.findUnique({ where: { id: Number(id) } });
});
数据获取
<script setup lang="ts">
// 服务器端获取(SSR)
const { data: users } = await useFetch("/api/users");
// 延迟获取(客户端)
const { data: posts, pending } = await useLazyFetch("/api/posts");
// 带参数
const route = useRoute();
const { data: user } = await useFetch(`/api/users/${route.params.id}`);
</script>
使用Vitest测试
设置
// vitest.config.ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
},
});
// tests/setup.ts
import { vi } from "vitest";
import "@testing-library/jest-dom/vitest";
组件测试
// tests/components/UserCard.test.ts
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/vue";
import UserCard from "@/components/UserCard.vue";
describe("UserCard", () => {
it("渲染用户信息", () => {
render(UserCard, {
props: {
name: "John Doe",
email: "john@example.com",
},
});
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
});
it("触发点击事件", async () => {
const { emitted } = render(UserCard, {
props: { name: "John", email: "john@test.com" },
});
await fireEvent.click(screen.getByRole("article"));
expect(emitted().click).toHaveLength(1);
});
});
Composable测试
// tests/composables/useFetch.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useFetch } from "@/composables/useFetch";
import { flushPromises } from "@vue/test-utils";
describe("useFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("成功获取数据", async () => {
const mockData = [{ id: 1, name: "Test" }];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData),
} as Response);
const { data, loading, error } = useFetch("/api/test");
expect(loading.value).toBe(true);
await flushPromises();
expect(loading.value).toBe(false);
expect(data.value).toEqual(mockData);
expect(error.value).toBeNull();
});
it("处理错误", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("网络错误"));
const { data, error } = useFetch("/api/test");
await flushPromises();
expect(data.value).toBeNull();
expect(error.value?.message).toBe("网络错误");
});
});
避免的反模式
| 反模式 | 问题 | 解决方案 |
|---|---|---|
| 对复杂组件使用Options API | 代码组织差 | 使用Composition API |
| 直接修改props | 破坏单向数据流 | 向父组件发出事件 |
使用$refs进行状态管理 |
不响应式 | 使用ref()和props |
| 深度嵌套的观察者 | 性能问题 | 使用computed()代替 |
| 使用mixins | 名称冲突、来源不清晰 | 使用composables |
不使用storeToRefs() |
解构时失去响应性 | 始终在Pinia中使用 |
相关资源
何时使用此技能
- 构建新的Vue.js 3应用
- 从Vue 2 Options API迁移到Composition API
- 实现Pinia状态管理
- 创建可重用的composables
- 使用Nuxt 3进行全栈开发
- 使用Vitest测试Vue组件