组合式函数 #

什么是组合式函数? #

组合式函数(Composables)是 Vue 3 中复用状态逻辑的一种方式。在 Pinia 的 Setup Store 中,我们可以充分利用组合式函数来组织和复用代码。

基本用法 #

简单的组合式函数 #

ts
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    double,
    increment,
    decrement,
    reset
  }
}
ts
// stores/counter.ts
import { defineStore } from 'pinia'
import { useCounter } from '@/composables/useCounter'

export const useCounterStore = defineStore('counter', () => {
  const { count, double, increment, decrement, reset } = useCounter(0)
  
  return { count, double, increment, decrement, reset }
})

常用组合式函数 #

异步请求处理 #

ts
// composables/useAsync.ts
import { ref, Ref } from 'vue'

interface AsyncState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (...args: any[]) => Promise<T | null>
}

export function useAsync<T>(
  asyncFn: (...args: any[]) => Promise<T>,
  immediate = false
): AsyncState<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  async function execute(...args: any[]): Promise<T | null> {
    loading.value = true
    error.value = null
    
    try {
      const result = await asyncFn(...args)
      data.value = result
      return result
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
      return null
    } finally {
      loading.value = false
    }
  }
  
  if (immediate) {
    execute()
  }
  
  return { data, loading, error, execute }
}
ts
// stores/product.ts
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useAsync } from '@/composables/useAsync'

interface Product {
  id: number
  name: string
  price: number
}

export const useProductStore = defineStore('product', () => {
  const products = ref<Product[]>([])
  
  // 使用 useAsync 处理异步请求
  const { loading, error, execute: fetchProducts } = useAsync(
    async () => {
      const response = await fetch('/api/products')
      products.value = await response.json()
      return products.value
    }
  )
  
  const productCount = computed(() => products.value.length)
  
  return {
    products,
    loading,
    error,
    productCount,
    fetchProducts
  }
})

分页处理 #

ts
// composables/usePagination.ts
import { ref, computed, watch } from 'vue'

interface PaginationOptions {
  pageSize?: number
  total?: number
}

export function usePagination(options: PaginationOptions = {}) {
  const { pageSize = 10, total = 0 } = options
  
  const currentPage = ref(1)
  const itemsPerPage = ref(pageSize)
  const totalItems = ref(total)
  
  const totalPages = computed(() => 
    Math.ceil(totalItems.value / itemsPerPage.value)
  )
  
  const offset = computed(() => 
    (currentPage.value - 1) * itemsPerPage.value
  )
  
  const hasNextPage = computed(() => currentPage.value < totalPages.value)
  const hasPrevPage = computed(() => currentPage.value > 1)
  
  function nextPage() {
    if (hasNextPage.value) {
      currentPage.value++
    }
  }
  
  function prevPage() {
    if (hasPrevPage.value) {
      currentPage.value--
    }
  }
  
  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }
  
  function setTotal(total: number) {
    totalItems.value = total
  }
  
  return {
    currentPage,
    itemsPerPage,
    totalItems,
    totalPages,
    offset,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    goToPage,
    setTotal
  }
}
ts
// stores/article.ts
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { usePagination } from '@/composables/usePagination'
import { useAsync } from '@/composables/useAsync'

interface Article {
  id: number
  title: string
  content: string
}

export const useArticleStore = defineStore('article', () => {
  const articles = ref<Article[]>([])
  
  const pagination = usePagination({ pageSize: 10 })
  
  const { loading, execute: fetchArticles } = useAsync(async () => {
    const response = await fetch(
      `/api/articles?page=${pagination.currentPage.value}&size=${pagination.itemsPerPage.value}`
    )
    const data = await response.json()
    articles.value = data.items
    pagination.setTotal(data.total)
    return data
  })
  
  // 页码变化时重新获取数据
  watch(pagination.currentPage, () => {
    fetchArticles()
  })
  
  return {
    articles,
    loading,
    ...pagination,
    fetchArticles
  }
})

表单处理 #

ts
// composables/useForm.ts
import { ref, reactive, computed } from 'vue'

interface FormOptions<T> {
  initialValues: T
  validate?: (values: T) => Record<string, string>
  onSubmit: (values: T) => Promise<void> | void
}

export function useForm<T extends Record<string, any>>(options: FormOptions<T>) {
  const { initialValues, validate, onSubmit } = options
  
  const values = reactive({ ...initialValues }) as T
  const errors = reactive<Record<string, string>>({})
  const touched = reactive<Record<string, boolean>>({})
  const isSubmitting = ref(false)
  
  const isValid = computed(() => Object.keys(errors).length === 0)
  const isDirty = computed(() => 
    JSON.stringify(values) !== JSON.stringify(initialValues)
  )
  
  function setFieldValue(field: keyof T, value: any) {
    (values as any)[field] = value
    touched[field as string] = true
    validateField(field)
  }
  
  function validateField(field: keyof T) {
    if (validate) {
      const validationErrors = validate(values)
      if (validationErrors[field as string]) {
        errors[field as string] = validationErrors[field as string]
      } else {
        delete errors[field as string]
      }
    }
  }
  
  function validateAll() {
    if (validate) {
      const validationErrors = validate(values)
      Object.keys(errors).forEach(key => delete errors[key])
      Object.assign(errors, validationErrors)
    }
  }
  
  async function handleSubmit() {
    validateAll()
    
    if (!isValid.value) return
    
    isSubmitting.value = true
    try {
      await onSubmit(values)
    } finally {
      isSubmitting.value = false
    }
  }
  
  function reset() {
    Object.assign(values, initialValues)
    Object.keys(errors).forEach(key => delete errors[key])
    Object.keys(touched).forEach(key => delete touched[key])
  }
  
  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    isDirty,
    setFieldValue,
    validateField,
    validateAll,
    handleSubmit,
    reset
  }
}
ts
// stores/auth.ts
import { defineStore } from 'pinia'
import { useForm } from '@/composables/useForm'

interface LoginForm {
  email: string
  password: string
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref<string | null>(null)
  
  const form = useForm<LoginForm>({
    initialValues: {
      email: '',
      password: ''
    },
    validate: (values) => {
      const errors: Record<string, string> = {}
      if (!values.email) {
        errors.email = 'Email is required'
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
        errors.email = 'Invalid email format'
      }
      if (!values.password) {
        errors.password = 'Password is required'
      } else if (values.password.length < 6) {
        errors.password = 'Password must be at least 6 characters'
      }
      return errors
    },
    onSubmit: async (values) => {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values)
      })
      const data = await response.json()
      user.value = data.user
      token.value = data.token
    }
  })
  
  function logout() {
    user.value = null
    token.value = null
    form.reset()
  }
  
  return {
    user,
    token,
    form,
    logout
  }
})

选择/多选处理 #

ts
// composables/useSelection.ts
import { ref, computed } from 'vue'

export function useSelection<T>(key: keyof T = 'id' as keyof T) {
  const selectedItems = ref<T[]>([])
  const lastSelected = ref<T | null>(null)
  
  const isSelected = computed(() => {
    return (item: T) => {
      return selectedItems.value.some(
        selected => selected[key] === item[key]
      )
    }
  })
  
  const selectedCount = computed(() => selectedItems.value.length)
  const hasSelection = computed(() => selectedCount.value > 0)
  
  function select(item: T) {
    if (!isSelected.value(item)) {
      selectedItems.value.push(item)
      lastSelected.value = item
    }
  }
  
  function deselect(item: T) {
    const index = selectedItems.value.findIndex(
      selected => selected[key] === item[key]
    )
    if (index !== -1) {
      selectedItems.value.splice(index, 1)
    }
  }
  
  function toggle(item: T) {
    if (isSelected.value(item)) {
      deselect(item)
    } else {
      select(item)
    }
  }
  
  function selectAll(items: T[]) {
    selectedItems.value = [...items]
  }
  
  function deselectAll() {
    selectedItems.value = []
  }
  
  function selectRange(items: T[], startItem: T, endItem: T) {
    const startIndex = items.findIndex(
      item => item[key] === startItem[key]
    )
    const endIndex = items.findIndex(
      item => item[key] === endItem[key]
    )
    
    const [from, to] = startIndex < endIndex 
      ? [startIndex, endIndex] 
      : [endIndex, startIndex]
    
    for (let i = from; i <= to; i++) {
      if (!isSelected.value(items[i])) {
        selectedItems.value.push(items[i])
      }
    }
  }
  
  return {
    selectedItems,
    lastSelected,
    isSelected,
    selectedCount,
    hasSelection,
    select,
    deselect,
    toggle,
    selectAll,
    deselectAll,
    selectRange
  }
}
ts
// stores/file.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useSelection } from '@/composables/useSelection'

interface File {
  id: string
  name: string
  size: number
  type: string
}

export const useFileStore = defineStore('file', () => {
  const files = ref<File[]>([])
  const loading = ref(false)
  
  const selection = useSelection<File>('id')
  
  async function fetchFiles() {
    loading.value = true
    try {
      const response = await fetch('/api/files')
      files.value = await response.json()
    } finally {
      loading.value = false
    }
  }
  
  async function deleteSelected() {
    const ids = selection.selectedItems.value.map(f => f.id)
    await fetch('/api/files', {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids })
    })
    files.value = files.value.filter(f => !ids.includes(f.id))
    selection.deselectAll()
  }
  
  return {
    files,
    loading,
    ...selection,
    fetchFiles,
    deleteSelected
  }
})

最佳实践 #

1. 单一职责 #

每个组合式函数只负责一个功能:

ts
// 好的做法
useAsync()      // 处理异步请求
usePagination() // 处理分页
useSelection()  // 处理选择

// 不好的做法
useEverything() // 处理所有事情

2. 命名规范 #

使用 use 前缀命名组合式函数:

ts
// 推荐
export function useCounter() { /* ... */ }
export function useAsync() { /* ... */ }
export function usePagination() { /* ... */ }

// 不推荐
export function counter() { /* ... */ }
export function createAsync() { /* ... */ }

3. 返回响应式引用 #

返回 ref 和 computed,而不是原始值:

ts
// 推荐
export function useCounter() {
  const count = ref(0)
  const double = computed(() => count.value * 2)
  return { count, double }
}

// 不推荐
export function useCounter() {
  let count = 0
  return { count, double: count * 2 }  // 失去响应性
}

4. 接受 ref 或 reactive 作为参数 #

ts
export function useSearch(items: Ref<any[]>, searchKey: string) {
  const query = ref('')
  
  const results = computed(() => {
    if (!query.value) return items.value
    return items.value.filter(item => 
      item[searchKey].toLowerCase().includes(query.value.toLowerCase())
    )
  })
  
  return { query, results }
}

下一步 #

现在你已经掌握了组合式函数的使用,接下来让我们学习 Store 的组合。

最后更新:2026-03-28