Vue 组合式与模式
让 Agent 以 script setup、composables 与 Pinia 习惯实现逻辑复用,并正确处理 ref、reactive 与模板引用。
本页为 Agent 提供 Vue 3 Composition API 的完整实践参考:composable 设计(useXxx 命名、返回只读 ref + 方法分组)、Pinia store 定义、provide/inject 依赖注入、渲染函数对比模板、Teleport 与 Suspense 的实际用法。
SKILL 写明 defineProps/defineEmits 与 TypeScript 宏的写法按项目 tsconfig 对齐;watch/watchEffect 的选用、副作用清理(onScopeDispose)以及 storeToRefs 解构 Pinia state 的正确方式。
与 Vite/Nuxt 自动导入相关的 SSR onMounted 限制、异步组件加载错误处理需在步骤中点名,确保 Agent 产出的代码直接可运行。
Composable 的设计与实现
useCounter:最小可用 composable,展示 ref、readonly 暴露与方法分组。
// composables/useCounter.ts
import { ref, readonly } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initial }
return {
count: readonly(count), // 防止外部直接赋值
increment,
decrement,
reset,
}
}
// 使用
// <script setup lang="ts">
// const { count, increment } = useCounter(10)
// </script>
useAuth:异步 composable,含加载状态与副作用清理。
// composables/useAuth.ts
import { ref, onScopeDispose } from 'vue'
import type { User } from '@/types'
export function useAuth() {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
let abortCtrl: AbortController | null = null
async function fetchUser() {
abortCtrl = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch('/api/me', { signal: abortCtrl.signal })
if (!res.ok) throw new Error(`${res.status}`)
user.value = await res.json()
} catch (e: unknown) {
if ((e as Error).name !== 'AbortError')
error.value = (e as Error).message
} finally {
loading.value = false
}
}
// 组件卸载时取消进行中的请求
onScopeDispose(() => abortCtrl?.abort())
return { user, loading, error, fetchUser }
}
data 与组合式 ref;迁移时以文件粒度切换,并补全类型与单测。
Teleport 与 Suspense 的实际用法
Teleport:将模态框或 Toast 挂到 body,解决层叠上下文问题。
<!-- components/ConfirmDialog.vue -->
<script setup lang="ts">
defineProps<{ open: boolean; title: string }>()
const emit = defineEmits<{ confirm: []; cancel: [] }>()
</script>
<template>
<Teleport to="body">
<div v-if="open" class="dialog-backdrop" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-modal="true">
<h2>{{ title }}</h2>
<slot />
<div class="dialog-actions">
<button @click="emit('cancel')">取消</button>
<button @click="emit('confirm')">确认</button>
</div>
</div>
</div>
</Teleport>
</template>
Suspense:配合异步组件,统一加载与错误边界。
<!-- views/DashboardView.vue -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: () => h('div', '加载中…'),
errorComponent: () => h('div', '加载失败'),
delay: 200,
timeout: 8000,
})
</script>
<template>
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<Skeleton height="320px" />
</template>
</Suspense>
</template>
作用域插槽把数据与渲染委托给父级时,在 SKILL 中写清插槽 props 的形状;与 v-model 成对的 modelValue / update:modelValue 事件需一并文档化。
Pinia 状态管理与 provide/inject
Pinia store 定义(Setup Store 风格,与 composable 写法一致):
// stores/useUserStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '@/types'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isLoggedIn = computed(() => !!token.value)
const displayName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : '游客'
)
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
token.value = data.token
user.value = data.user
}
function logout() {
user.value = null
token.value = null
}
return { user, token, isLoggedIn, displayName, login, logout }
})
storeToRefs 解构(防止响应式丢失):
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/useUserStore'
const store = useUserStore()
// ✅ 响应式保留:用 storeToRefs 解构 state/computed
const { user, isLoggedIn, displayName } = storeToRefs(store)
// ✅ actions 直接解构(非响应式,不需要 storeToRefs)
const { login, logout } = store
</script>
provide/inject 的类型安全封装(InjectionKey 模式):
// composables/useTheme.ts — provide 端
import { provide, inject, ref, type InjectionKey } from 'vue'
export interface ThemeContext {
theme: Ref<'light' | 'dark'>
toggle: () => void
}
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('theme')
export function provideTheme() {
const theme = ref<'light' | 'dark'>('light')
const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }
provide(THEME_KEY, { theme, toggle })
}
// inject 端(带默认值保证类型安全)
export function useTheme(): ThemeContext {
const ctx = inject(THEME_KEY)
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
return ctx
}
- Pinia action 内不要直接操作 DOM;副作用通过 composable 隔离后注入。
- provide/inject 跨层传「小上下文」(主题、locale、用户权限);大型共享状态用 Pinia。
- 测试:Vue Test Utils
mount+createTestingPinia()覆盖 store 初态。
页内工具:Props / Emits 清单与插槽名格式化
以下脚本仅在本页运行,便于把契约要点写进 SKILL 或对齐模板中的具名插槽写法。
Props / Emits 小清单
勾选已落实项,点击汇总可复制到提示词或文档草稿。
具名插槽名 → 模板 kebab-case
输入 camelCase / PascalCase / 下划线形式,得到单文件组件模板中的具名插槽写法(如 #side-panel、v-slot:side-panel)。
SKILL 大纲
---
name: vue-composition-patterns
description: 用 script setup 与 composable 按 Vue 3 规范实现功能
---
# 规则
- 所有新组件用 <script setup lang="ts">,禁止同文件混用 Options API
- Composable 命名 useXxx;返回 readonly ref + 方法分组;副作用用 onScopeDispose 清理
- Pinia store 用 Setup Store 风格;组件内用 storeToRefs 解构 state/computed
- provide/inject 必须使用 InjectionKey<T>,inject 端提供运行时保护
# 步骤
1. 读现有 composables/ 目录,确认 useXxx 命名与返回值契约
2. Props/Emits 定义:defineProps<T>() + defineEmits<{ evt: [payload] }>()
3. 响应式:优先 ref;watch 指定精确 source;watchEffect 用于 DOM 副作用
4. Pinia:defineStore 在 stores/ 目录;action 内 try/catch 统一错误处理
5. provide/inject:跨 2+ 层时使用;单层 props drilling 仍用 props
6. Teleport:模态/Toast 挂 body;Suspense:异步组件 + errorComponent + timeout
7. 测试:composable 用 withSetup 或 mount;store 用 createTestingPinia()