From fe2d6656a34f70b90d25e1aaf34515132283a270 Mon Sep 17 00:00:00 2001 From: Bujidao <3317431882@qq.com> Date: Fri, 12 Jun 2026 17:54:28 +0800 Subject: [PATCH] feat(skills): add vue-patterns skill for Vue.js 3 best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add skills/vue-patterns/SKILL.md covering: - Project structure (feature-first layout, file naming) - Component architecture (SFC order, presentational vs container, props/emits) - Composables (use prefix, MaybeRef/toValue, cleanup, vs mixins) - State management decision tree (local → props → provide/inject → Pinia → server state) - Vue Router patterns (lazy loading, navigation guards, reactive params) - Template patterns (v-if/v-else, v-show, v-for, v-model with defineModel) - Performance techniques (shallowRef, v-memo, v-once, KeepAlive, Suspense) - Testing stack and patterns (Vitest, Vue Test Utils, Pinia testing) - Nuxt-specific patterns (auto-imports, useAsyncData, server routes, runtime config) - Vue 3.5+ new APIs section: reactive props destructure, useTemplateRef, onWatcherCleanup, useId, defer Teleport, lazy hydration - Anti-patterns table with Vue 3.5+ version-specific notes --- skills/vue-patterns/SKILL.md | 471 +++++++++++++++++++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 skills/vue-patterns/SKILL.md diff --git a/skills/vue-patterns/SKILL.md b/skills/vue-patterns/SKILL.md new file mode 100644 index 00000000..1312de03 --- /dev/null +++ b/skills/vue-patterns/SKILL.md @@ -0,0 +1,471 @@ +--- +name: vue-patterns +description: Vue.js 3 Composition API patterns, component architecture, reactivity best practices, Pinia state management, Vue Router navigation, and Nuxt SSR patterns. Activates for Vue, Nuxt, Vite, or Pinia projects. +origin: ECC +--- + +# Vue.js Patterns and Best Practices + +Comprehensive guide for Vue.js 3 development using Composition API (` + + + + +``` + +### Presentational vs Container + +- **Container components**: Own data fetching, state, and side effects. Render presentational components. +- **Presentational components**: Receive props, emit events. No API calls, no store access. Pure rendering. + +### Props Best Practices + +```ts +// Type-based props with defaults +interface Props { + label: string; + variant?: "primary" | "secondary"; + disabled?: boolean; + items: Item[]; +} + +const props = withDefaults(defineProps(), { + variant: "primary", + disabled: false, +}); +``` + +- Always provide `type`, and `required`/`default` where appropriate. +- Boolean props: `isXxx`, `hasXxx`, `canXxx`. +- Never mutate props — emit events instead. +- For v-model binding, use `defineModel()` (Vue 3.4+) or `modelValue` + `update:modelValue`. + +### Events + +```ts +const emit = defineEmits<{ + submit: []; + "update:modelValue": [value: string]; + select: [id: string, index: number]; +}>(); +``` + +- Use kebab-case in templates (`@update:model-value`). +- Use camelCase in script (`emit("update:modelValue", val)`). + +--- + +## 3. Composables (Reusable Logic) + +### Structure + +```ts +// composables/useDebounce.ts +export function useDebounce(value: MaybeRef, delay: number): Ref { + const debounced = ref(toValue(value)) as Ref; + + let timer: ReturnType; + watch( + () => toValue(value), + (newVal) => { + clearTimeout(timer); + timer = setTimeout(() => { debounced.value = newVal; }, delay); + } + ); + + onUnmounted(() => clearTimeout(timer)); + return readonly(debounced); +} +``` + +### Rules + +- Must start with `use` prefix. +- Return reactive values (`ref`, `computed`, `reactive`), never plain primitives. +- Accept reactive inputs via `MaybeRef` / `toRef()` / `toValue()`. +- Clean up side effects in `onUnmounted` or watcher `onCleanup`. +- No module-scope side effects. + +### vs Mixins + +Composables replace Vue 2 mixins entirely: +- **Mixins**: Opaque data flow, source-of-truth collisions, name conflicts. +- **Composables**: Explicit imports, clear return values, composable and tree-shakable. + +--- + +## 4. State Management + +### When to Use What + +| Pattern | Use Case | +|---------|----------| +| `ref()` / `reactive()` | Local component state | +| Props + Emits | Parent-child communication | +| Provide / Inject | Theme, config, plugin API | +| Pinia store | Global, shared, complex state | +| Server state composable | API data with caching (wrap `fetch`/TanStack Query) | + +### Pinia Setup Store (Preferred) + +```ts +// stores/useCartStore.ts +export const useCartStore = defineStore("cart", () => { + const items = ref([]); + 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 }; +}); +``` + +- Use Setup Store syntax (not Options Store). +- Actions: only place to mutate state. +- Every async action: handle loading + success + error. + +--- + +## 5. Vue Router + +### Route Definitions + +```ts +const routes = [ + { + path: "/users/:id", + name: "user-detail", + component: () => import("@/pages/UserDetail.vue"), // lazy + props: true, // pass params as 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 } }; + } +}); +``` + +### Reactive Route Params + +When a component stays mounted but route params change: + +```ts +const route = useRoute(); +const id = computed(() => route.params.id as string); +watch(id, (newId) => fetchItem(newId)); +``` + +--- + +## 6. Template Patterns + +### Template Syntax + +```vue + +
Loading...
+
Error: {{ error }}
+
{{ content }}
+ + +
Toggled content
+ + +
{{ item.name }}
+ + +
{{ item.name }}
+ + +
+ +
+ + + + +``` + +--- + +## 7. Performance + +| Technique | When to Use | +|-----------|-------------| +| `v-memo` | List items that rarely change | +| `v-once` | Content rendered once and static forever | +| `shallowRef()` | Large data structures replaced wholesale | +| `shallowReactive()` | Only top-level properties are reactive | +| `v-show` over `v-if` | Frequent visibility toggles | +| `` | Cache toggled views | +| Lazy routes | `() => import(...)` for non-critical routes | +| `Suspense` | Async component loading with fallback | + +--- + +## 8. Testing + +### Stack + +- **Vitest** for unit and component tests +- **Vue Test Utils** for mounting and interaction +- **@pinia/testing** for store mocking +- **Playwright** for E2E + +### Component Test Pattern + +```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-Specific Patterns + +### Auto-Imports + +Nuxt auto-imports `ref`, `computed`, `watch`, `useFetch`, `useAsyncData`, etc. Use them directly without importing. For non-Nuxt projects, always import explicitly. + +### useAsyncData / useFetch + +```ts +const { data: user, pending, error, refresh } = await useAsyncData( + "user", // unique key for caching + () => $fetch(`/api/users/${id}`), +); + +const { data: posts } = await useFetch("/api/posts", { + query: { page: 1 }, + key: "posts-page-1", // dedupes requests +}); +``` + +### 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 (exposed to client) + public: { + apiBase: "https://api.example.com", + }, + }, +}); +``` + +--- + +## 10. Vue 3.5+ New APIs + +### Reactive Props Destructure + +Vue 3.5 stabilized reactive props destructure — destructured variables from `defineProps()` are automatically reactive: + +```ts +// Vue 3.5+: destructured props are reactive (no need for toRefs) +const { count = 0, msg = "hello" } = defineProps<{ + count?: number; + msg?: string; +}>(); + +// Limitation: cannot watch destructured prop directly +watch(() => count, (newVal) => { ... }); // ✅ getter required +``` + +### `useTemplateRef()` + +Replace name-matched plain refs with `useTemplateRef()` for template references: + +```ts +import { useTemplateRef } from "vue"; +const inputEl = useTemplateRef("input"); +// "input" matches the ref="input" attribute in template, not the variable name +``` + +Supports dynamic ref IDs: `useTemplateRef(dynamicRefId)`. + +### `onWatcherCleanup()` + +Globally importable watcher cleanup API (Vue 3.5+): + +```ts +import { watch, onWatcherCleanup } from "vue"; + +watch(userId, async (newId) => { + const controller = new AbortController(); + onWatcherCleanup(() => controller.abort()); + // ... fetch with signal +}); +``` + +### `useId()` + +SSR-stable unique ID generation for form elements and accessibility: + +```ts +import { useId } from "vue"; +const id = useId(); +``` + +### `defer` Teleport + +`` allows teleporting to targets rendered in the same cycle: + +```vue +Content +
+``` + +### Lazy Hydration (SSR) + +`defineAsyncComponent()` now supports `hydrate` strategy: + +```ts +import { defineAsyncComponent, hydrateOnVisible } from "vue"; +const AsyncComp = defineAsyncComponent({ + loader: () => import("./Comp.vue"), + hydrate: hydrateOnVisible(), +}); +``` + +--- + +## Anti-Patterns + +| Anti-Pattern | Why It's Wrong | The Fix | +|-------------|---------------|---------| +| Destructuring `defineProps()` (Vue < 3.5) | Captures snapshot, loses reactivity | Access via `props.xxx` or use `toRefs()` | +| `watch()` on destructured prop (Vue 3.5+) | Compile-time error — destructured props can't be watched directly | Use getter wrapper: `watch(() => count, ...)` | +| `v-if` + `v-for` on same element | Ambiguous execution order | Use computed filtered array | +| `v-for` key = index | Broken state on reorder | Use stable database IDs | +| Mutating props | Violates one-way data flow | Emit events or use `v-model` | +| `v-html` with user content | XSS vulnerability | Sanitize with DOMPurify | +| Mixins in Vue 3 | Opaque, collision-prone | Replace with composables | +| Module-scope side effects in composable | Shared across instances | Scope in `onMounted` + `onUnmounted` | +| `reactive()` for replaceable state | Replacement breaks reactivity | Use `ref()` instead | +| Watcher without cleanup | Memory leaks, race conditions | Use `onCleanup` or `onWatcherCleanup()` (Vue 3.5+) | +| Options API in new Vue 3 code | Ecosystem move to Composition API | Use `