mcpField
V
vue-development

vue-development

github.com/travisjneuman开发106137 次安装1 天前更新
代码仓库

关于 vue-development

vue-development 是面向开发者的 Vue 3 Composition API 实用指南,涵盖 TypeScript、常见 Vue 生态工具、Nuxt 3、可复用 composable、状态管理与测试。

主要功能

  1. 01Composition API 与 Script Setup 模式
  2. 02可复用 composable
  3. 03Pinia 状态管理
  4. 04Nuxt 3 路由与 API 路由
  5. 05使用 Vitest 测试组件和 composable
  6. 06常见反模式指南

适用场景

  1. 01启动 Vue 3 应用
  2. 02迁移 Options API 组件
  3. 03构建可复用应用逻辑
  4. 04使用 Vitest 测试 Vue 组件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/travisjneuman/.claude --skill vue-development

安装后在 Claude Code 中输入/vue-development确认已加载。

详细介绍

Vue.js 3 开发

使用 Composition API 构建现代 Vue.js 应用的完整指南。

技术栈概览

工具用途版本
Vue 3核心框架3.4+
TypeScript类型安全5.0+
Vite构建工具5.0+
Pinia状态管理2.1+
Vue Router路由4.2+
Nuxt 3全栈框架3.10+
Vitest测试1.0+

使用 Script Setup 的 Composition API

基本组件结构
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";

// Props with TypeScript
interface Props {
  title: string;
  count?: number;
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
});

// Emits with TypeScript
const emit = defineEmits<{
  update: [value: number];
  submit: [];
}>();

// Reactive state
const localCount = ref(props.count);
const items = ref<string[]>([]);

// Computed
const doubleCount = computed(() => localCount.value * 2);

// Methods
function increment() {
  localCount.value++;
  emit("update", localCount.value);
}

// Lifecycle
onMounted(() => {
  console.log("Component mounted");
});
</script>

<template>
  <div class="component">
    <h1>{{ title }}</h1>
    <p>Count: {{ localCount }} (Double: {{ doubleCount }})</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<style scoped>
.component {
  padding: 1rem;
}
</style>
响应性模式
import { ref, reactive, computed, watch, watchEffect } from "vue";

// Primitives: use ref()
const count = ref(0);
const name = ref("");
count.value++; // Access via .value

// Objects/Arrays: use reactive()
const state = reactive({
  items: [] as Item[],
  loading: false,
  error: null as Error | null,
});
state.items.push(item); // Direct access, no .value

// Computed values
const total = computed(() => state.items.reduce((sum, i) => sum + i.price, 0));

// Watch specific values
watch(count, (newVal, oldVal) => {
  console.log(`Count changed: ${oldVal} -> ${newVal}`);
});

// Watch with options
watch(
  () => state.items,
  (newItems) => saveToStorage(newItems),
  { deep: true, immediate: true },
);

// Automatic dependency tracking
watchEffect(() => {
  console.log(`Count is now: ${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;
    }
  }

  // Auto-refetch when URL changes
  watchEffect(() => {
    fetchData();
  });

  return { data, error, loading, refetch: fetchData };
}

// Usage in component
const { data, error, loading } = useFetch<User[]>("/api/users");

// With reactive 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> {
  // Read initial value
  const stored = localStorage.getItem(key);
  const value = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>;

  // Sync to storage
  watch(
    value,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue));
    },
    { deep: true },
  );

  return value;
}

// ✅ Good: Accept refs, getters, or plain values
// ✅ Good: Return plain object with refs (not reactive)
// ✅ Good: Clean up in onUnmounted
// ✅ Good: Use toValue() for flexible inputs

Pinia 状态管理

定义 Store(Composition 风格)
// 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", () => {
  // State
  const user = ref<User | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  // Getters (computed)
  const isLoggedIn = computed(() => !!user.value);
  const displayName = computed(() => user.value?.name ?? "Guest");

  // Actions
  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("Login failed");

      user.value = await response.json();
    } catch (e) {
      error.value = e instanceof Error ? e.message : "Unknown error";
      throw e;
    } finally {
      loading.value = false;
    }
  }

  function logout() {
    user.value = null;
  }

  return {
    // State
    user,
    loading,
    error,
    // Getters
    isLoggedIn,
    displayName,
    // Actions
    login,
    logout,
  };
});
在组件中使用 Store
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";

const userStore = useUserStore();

// Use storeToRefs for destructuring state/getters
const { user, isLoggedIn, loading } = storeToRefs(userStore);

// Actions can be destructured directly
const { login, logout } = userStore;

async function handleLogin() {
  try {
    await login(email.value, password.value);
    router.push("/dashboard");
  } catch {
    // Error handled in store
  }
}
</script>
带持久化的 Store
// 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");

  // Load from storage on init
  const stored = localStorage.getItem("settings");
  if (stored) {
    const parsed = JSON.parse(stored);
    theme.value = parsed.theme ?? "light";
    locale.value = parsed.locale ?? "en";
  }

  // Persist changes
  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 };
});

组件模式

Provider/Inject 模式
// 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");

// Provider component
export function provideAuth() {
  const user = ref<User | null>(null);

  async function login(email: string, password: string) {
    // Implementation
  }

  function logout() {
    user.value = null;
  }

  const context = { user, login, logout };
  provide(AuthKey, context);
  return context;
}

// Consumer hook
export function useAuth(): AuthContext {
  const context = inject(AuthKey);
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}
智能组件/哑组件

<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>

<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">Loading...</div>
    <div v-else-if="error">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/              # File-based routing
│   ├── index.vue       # /
│   ├── about.vue       # /about
│   └── users/
│       ├── index.vue   # /users
│       └── [id].vue    # /users/:id
├── components/         # Auto-imported components
├── composables/        # Auto-imported composables
├── server/             # API routes (Nitro)
│   ├── api/
│   │   └── users.ts    # /api/users
│   └── middleware/
├── layouts/            # Page layouts
├── middleware/         # Route 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">
// Server-side fetch (SSR)
const { data: users } = await useFetch("/api/users");

// Lazy fetch (client-side)
const { data: posts, pending } = await useLazyFetch("/api/posts");

// With parameters
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("renders user information", () => {
    render(UserCard, {
      props: {
        name: "John Doe",
        email: "[email protected]",
      },
    });

    expect(screen.getByText("John Doe")).toBeInTheDocument();
    expect(screen.getByText("[email protected]")).toBeInTheDocument();
  });

  it("emits click event", async () => {
    const { emitted } = render(UserCard, {
      props: { name: "John", email: "[email protected]" },
    });

    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("fetches data successfully", 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("handles errors", async () => {
    vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));

    const { data, error } = useFetch("/api/test");
    await flushPromises();

    expect(data.value).toBeNull();
    expect(error.value?.message).toBe("Network error");
  });
});

应避免的反模式

反模式问题解决方案
对复杂组件使用 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 组件

评论

支持 Markdown