Vue 组合式与模式

让 Agent 以 script setup、composables 与 Pinia 习惯实现逻辑复用,并正确处理 ref、reactive 与模板引用。

本页为 Agent 提供 Vue 3 Composition API 的完整实践参考:composable 设计(useXxx 命名、返回只读 ref + 方法分组)、Pinia store 定义、provide/inject 依赖注入、渲染函数对比模板、TeleportSuspense 的实际用法。

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 }
}
给 Agent 的边界:不要在同一组件混用选项式 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-panelv-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()

返回技能库 更多技能入口