Skip to content

Vue 3 组合式 API 最佳实践

Vue 3 的组合式 API(Composition API)彻底改变了我们编写 Vue 应用的方式。它不仅提供了更好的 TypeScript 支持,还让逻辑复用变得更加简单和直观。本文将分享在实际项目中使用组合式 API 的最佳实践,帮助你写出更简洁、可维护的代码。

目录

组合式 API 基础回顾

为什么选择组合式 API?

组合式 API 相比选项式 API 有以下优势:

  • 更好的逻辑复用:通过组合函数实现逻辑复用
  • 更好的 TypeScript 支持:天然的类型推导
  • 更灵活的代码组织:按功能而非选项组织代码
  • 更小的包体积:更好的 tree-shaking 支持

基本语法对比

vue
<!-- 选项式 API -->
<script>
export default {
  data() {
    return {
      count: 0,
      message: 'Hello'
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

<!-- 组合式 API -->
<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const message = ref('Hello')

const doubleCount = computed(() => count.value * 2)

const increment = () => {
  count.value++
}
</script>

响应式数据管理

ref vs reactive 的选择策略

使用 ref 的场景

js
import { ref } from 'vue'

// 基本类型数据
const count = ref(0)
const message = ref('Hello')
const isLoading = ref(false)

// 需要重新赋值的对象
const user = ref(null)
const fetchUser = async (id) => {
  user.value = await api.getUser(id) // 重新赋值
}

// 数组数据(经常需要重新赋值)
const items = ref([])
const loadItems = async () => {
  items.value = await api.getItems() // 重新赋值
}

使用 reactive 的场景

js
import { reactive } from 'vue'

// 稳定的对象结构
const form = reactive({
  name: '',
  email: '',
  age: 0
})

// 状态对象
const state = reactive({
  loading: false,
  error: null,
  data: []
})

// 配置对象
const config = reactive({
  theme: 'light',
  language: 'zh-CN',
  pageSize: 10
})

最佳实践建议

js
// ✅ 推荐:优先使用 ref
const count = ref(0)
const user = ref({ name: 'John', age: 30 })

// ✅ 推荐:对于复杂的嵌套对象使用 reactive
const state = reactive({
  user: {
    profile: {
      name: '',
      avatar: ''
    },
    preferences: {
      theme: 'light',
      notifications: true
    }
  }
})

// ❌ 避免:混合使用导致混淆
const mixedState = reactive({
  count: ref(0), // 不要在 reactive 中使用 ref
  message: 'Hello'
})

响应式数据的解构

js
import { reactive, toRefs } from 'vue'

// 问题:直接解构会失去响应性
const state = reactive({ count: 0, message: 'Hello' })
const { count, message } = state // ❌ 失去响应性

// 解决方案:使用 toRefs
const { count, message } = toRefs(state) // ✅ 保持响应性

// 或者使用 storeToRefs(Pinia)
import { storeToRefs } from 'pinia'
const store = useUserStore()
const { user, isLoading } = storeToRefs(store)

计算属性最佳实践

复杂计算属性的拆分

js
// ❌ 避免:复杂的计算逻辑
const complexComputed = computed(() => {
  const filteredItems = items.value.filter(item => 
    item.status === 'active' && 
    item.category === selectedCategory.value &&
    item.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  )
  
  const sortedItems = filteredItems.sort((a, b) => {
    if (sortBy.value === 'name') return a.name.localeCompare(b.name)
    if (sortBy.value === 'date') return new Date(b.date) - new Date(a.date)
    return 0
  })
  
  return sortedItems.slice(0, pageSize.value)
})

// ✅ 推荐:拆分为多个计算属性
const filteredItems = computed(() => 
  items.value.filter(item => 
    item.status === 'active' && 
    item.category === selectedCategory.value &&
    item.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  )
)

const sortedItems = computed(() => {
  const items = filteredItems.value
  if (sortBy.value === 'name') {
    return [...items].sort((a, b) => a.name.localeCompare(b.name))
  }
  if (sortBy.value === 'date') {
    return [...items].sort((a, b) => new Date(b.date) - new Date(a.date))
  }
  return items
})

const paginatedItems = computed(() => 
  sortedItems.value.slice(0, pageSize.value)
)

可写计算属性

js
import { computed, ref } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 可写计算属性
const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value) {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

// 在模板中使用
// <input v-model="fullName" />

生命周期钩子的使用

生命周期钩子对比

js
// 选项式 API -> 组合式 API
// beforeCreate -> 不需要(直接在 setup 中编写)
// created -> 不需要(直接在 setup 中编写)
// beforeMount -> onBeforeMount
// mounted -> onMounted
// beforeUpdate -> onBeforeUpdate
// updated -> onUpdated
// beforeUnmount -> onBeforeUnmount
// unmounted -> onUnmounted

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount
} from 'vue'

export default {
  setup() {
    // 相当于 created
    console.log('组件创建')
    
    onBeforeMount(() => {
      console.log('挂载前')
    })
    
    onMounted(() => {
      console.log('挂载后')
    })
    
    onBeforeUpdate(() => {
      console.log('更新前')
    })
    
    onUpdated(() => {
      console.log('更新后')
    })
    
    onBeforeUnmount(() => {
      console.log('卸载前')
    })
    
    onUnmounted(() => {
      console.log('卸载后')
    })
  }
}

生命周期钩子最佳实践

js
import { onMounted, onUnmounted, ref } from 'vue'

// ✅ 推荐:在生命周期钩子中进行资源管理
const useEventListener = (target, event, handler) => {
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

// ✅ 推荐:清理定时器
const useTimer = () => {
  const timer = ref(null)
  
  const startTimer = () => {
    timer.value = setInterval(() => {
      // 定时器逻辑
    }, 1000)
  }
  
  onUnmounted(() => {
    if (timer.value) {
      clearInterval(timer.value)
    }
  })
  
  return { startTimer }
}

逻辑复用与组合函数

创建可复用的组合函数

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

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  const isEven = computed(() => count.value % 2 === 0)
  const isPositive = computed(() => count.value > 0)
  
  return {
    count: readonly(count), // 只读,防止外部直接修改
    increment,
    decrement,
    reset,
    isEven,
    isPositive
  }
}

// 在组件中使用
<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, increment, decrement, reset, isEven } = useCounter(10)
</script>

异步数据获取的组合函数

js
// composables/useAsyncData.js
import { ref, computed } from 'vue'

export function useAsyncData(fetcher) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)
  
  const execute = async (...args) => {
    try {
      loading.value = true
      error.value = null
      data.value = await fetcher(...args)
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }
  
  const retry = () => execute()
  
  const isReady = computed(() => !loading.value && !error.value && data.value !== null)
  
  return {
    data: readonly(data),
    error: readonly(error),
    loading: readonly(loading),
    execute,
    retry,
    isReady
  }
}

// 使用示例
<script setup>
import { useAsyncData } from '@/composables/useAsyncData'
import { onMounted } from 'vue'

const { data: users, loading, error, execute } = useAsyncData(
  (page = 1) => api.getUsers({ page })
)

onMounted(() => execute())
</script>

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <div v-else-if="users">
      <div v-for="user in users" :key="user.id">
        {{ user.name }}
      </div>
    </div>
  </div>
</template>

表单处理的组合函数

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

export function useForm(initialValues = {}, validationRules = {}) {
  const form = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  
  const validate = (field) => {
    const rule = validationRules[field]
    if (!rule) return true
    
    const value = form[field]
    const result = rule(value)
    
    if (result === true) {
      delete errors[field]
      return true
    } else {
      errors[field] = result
      return false
    }
  }
  
  const validateAll = () => {
    let isValid = true
    Object.keys(validationRules).forEach(field => {
      if (!validate(field)) {
        isValid = false
      }
    })
    return isValid
  }
  
  const handleBlur = (field) => {
    touched[field] = true
    validate(field)
  }
  
  const handleInput = (field) => {
    if (touched[field]) {
      validate(field)
    }
  }
  
  const reset = () => {
    Object.keys(form).forEach(key => {
      form[key] = initialValues[key]
    })
    Object.keys(errors).forEach(key => {
      delete errors[key]
    })
    Object.keys(touched).forEach(key => {
      delete touched[key]
    })
  }
  
  const isValid = computed(() => Object.keys(errors).length === 0)
  const isDirty = computed(() => 
    Object.keys(form).some(key => form[key] !== initialValues[key])
  )
  
  return {
    form,
    errors: readonly(errors),
    touched: readonly(touched),
    isValid,
    isDirty,
    validate,
    validateAll,
    handleBlur,
    handleInput,
    reset
  }
}

// 使用示例
<script setup>
import { useForm } from '@/composables/useForm'

const { form, errors, isValid, handleBlur, handleInput, validateAll } = useForm(
  {
    name: '',
    email: '',
    age: 0
  },
  {
    name: (value) => value.length > 0 || '姓名不能为空',
    email: (value) => /\S+@\S+\.\S+/.test(value) || '邮箱格式不正确',
    age: (value) => value >= 18 || '年龄必须大于等于18岁'
  }
)

const handleSubmit = () => {
  if (validateAll()) {
    // 提交表单
    console.log('提交表单:', form)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="form.name"
        @blur="handleBlur('name')"
        @input="handleInput('name')"
        placeholder="姓名"
      />
      <span v-if="errors.name" class="error">{{ errors.name }}</span>
    </div>
    
    <div>
      <input 
        v-model="form.email"
        @blur="handleBlur('email')"
        @input="handleInput('email')"
        placeholder="邮箱"
      />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
    
    <div>
      <input 
        v-model.number="form.age"
        @blur="handleBlur('age')"
        @input="handleInput('age')"
        type="number"
        placeholder="年龄"
      />
      <span v-if="errors.age" class="error">{{ errors.age }}</span>
    </div>
    
    <button type="submit" :disabled="!isValid">提交</button>
  </form>
</template>

状态管理最佳实践

组件内状态管理

js
// 简单状态管理
<script setup>
import { ref, computed } from 'vue'

// 本地状态
const todos = ref([])
const filter = ref('all')

// 计算属性
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(todo => !todo.completed)
    case 'completed':
      return todos.value.filter(todo => todo.completed)
    default:
      return todos.value
  }
})

const completedCount = computed(() => 
  todos.value.filter(todo => todo.completed).length
)

const remainingCount = computed(() => 
  todos.value.length - completedCount.value
)

// 方法
const addTodo = (text) => {
  todos.value.push({
    id: Date.now(),
    text,
    completed: false
  })
}

const toggleTodo = (id) => {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.completed = !todo.completed
  }
}

const removeTodo = (id) => {
  const index = todos.value.findIndex(t => t.id === id)
  if (index > -1) {
    todos.value.splice(index, 1)
  }
}
</script>

与 Pinia 的集成

js
// stores/user.js
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export const useUserStore = defineStore('user', () => {
  // 状态
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  // 计算属性
  const isLoggedIn = computed(() => !!user.value)
  const userName = computed(() => user.value?.name || '游客')
  
  // 方法
  const login = async (credentials) => {
    try {
      loading.value = true
      error.value = null
      const response = await api.login(credentials)
      user.value = response.data
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const logout = () => {
    user.value = null
    error.value = null
  }
  
  const updateProfile = async (profile) => {
    try {
      loading.value = true
      const response = await api.updateProfile(profile)
      user.value = { ...user.value, ...response.data }
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  return {
    // 状态
    user: readonly(user),
    loading: readonly(loading),
    error: readonly(error),
    
    // 计算属性
    isLoggedIn,
    userName,
    
    // 方法
    login,
    logout,
    updateProfile
  }
})

// 在组件中使用
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const { user, loading, isLoggedIn, userName } = storeToRefs(userStore)
const { login, logout } = userStore
</script>

性能优化技巧

避免不必要的响应式

js
import { ref, shallowRef, markRaw } from 'vue'

// ✅ 对于大型不可变数据使用 shallowRef
const largeList = shallowRef([])

// ✅ 对于第三方库实例使用 markRaw
const chart = ref(null)
onMounted(() => {
  chart.value = markRaw(new Chart(canvas, config))
})

// ✅ 对于静态配置使用 readonly
const config = readonly({
  apiUrl: 'https://api.example.com',
  timeout: 5000
})

计算属性缓存优化

js
import { computed, ref } from 'vue'

const items = ref([])
const searchQuery = ref('')

// ❌ 避免:在模板中进行复杂计算
// <div v-for="item in items.filter(i => i.name.includes(searchQuery))" />

// ✅ 推荐:使用计算属性缓存结果
const filteredItems = computed(() => {
  if (!searchQuery.value) return items.value
  
  const query = searchQuery.value.toLowerCase()
  return items.value.filter(item => 
    item.name.toLowerCase().includes(query)
  )
})

异步组件和懒加载

js
import { defineAsyncComponent } from 'vue'

// 异步组件
const AsyncComponent = defineAsyncComponent(() => 
  import('./components/HeavyComponent.vue')
)

// 带加载状态的异步组件
const AsyncComponentWithOptions = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent.vue'),
  loadingComponent: LoadingComponent,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

// 在路由中使用懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')
  }
]

TypeScript 集成

类型定义最佳实践

ts
// types/user.ts
export interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

export interface UserState {
  user: User | null
  loading: boolean
  error: string | null
}

// 组合函数的类型定义
export interface UseUserReturn {
  user: Readonly<Ref<User | null>>
  loading: Readonly<Ref<boolean>>
  error: Readonly<Ref<string | null>>
  login: (credentials: LoginCredentials) => Promise<void>
  logout: () => void
}
vue
<script setup lang="ts">
import { ref, computed, type Ref } from 'vue'
import type { User, UserState } from '@/types/user'

// 类型注解
const user: Ref<User | null> = ref(null)
const loading = ref<boolean>(false)

// 泛型支持
const items = ref<User[]>([])

// 计算属性类型推导
const userName = computed(() => user.value?.name ?? '游客')

// 函数参数类型
const updateUser = (userData: Partial<User>) => {
  if (user.value) {
    user.value = { ...user.value, ...userData }
  }
}

// 事件处理器类型
const handleClick = (event: MouseEvent) => {
  console.log('点击位置:', event.clientX, event.clientY)
}
</script>

<template>
  <div>
    <h1>{{ userName }}</h1>
    <button @click="handleClick">点击我</button>
  </div>
</template>

组合函数的 TypeScript 支持

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

export interface UseApiOptions {
  immediate?: boolean
  onSuccess?: (data: any) => void
  onError?: (error: Error) => void
}

export function useApi<T = any>(
  fetcher: () => Promise<T>,
  options: UseApiOptions = {}
) {
  const data: Ref<T | null> = ref(null)
  const loading = ref(false)
  const error: Ref<Error | null> = ref(null)
  
  const execute = async (): Promise<void> => {
    try {
      loading.value = true
      error.value = null
      
      const result = await fetcher()
      data.value = result
      
      options.onSuccess?.(result)
    } catch (err) {
      const errorObj = err instanceof Error ? err : new Error(String(err))
      error.value = errorObj
      options.onError?.(errorObj)
    } finally {
      loading.value = false
    }
  }
  
  if (options.immediate) {
    execute()
  }
  
  return {
    data: readonly(data),
    loading: readonly(loading),
    error: readonly(error),
    execute
  }
}

// 使用示例
const { data: users, loading, execute } = useApi<User[]>(
  () => api.getUsers(),
  {
    immediate: true,
    onSuccess: (users) => console.log(`加载了 ${users.length} 个用户`),
    onError: (error) => console.error('加载用户失败:', error.message)
  }
)

测试最佳实践

组合函数测试

js
// composables/__tests__/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('应该初始化计数器', () => {
    const { count } = useCounter(5)
    expect(count.value).toBe(5)
  })
  
  it('应该能够增加计数', () => {
    const { count, increment } = useCounter(0)
    increment()
    expect(count.value).toBe(1)
  })
  
  it('应该能够重置计数', () => {
    const { count, increment, reset } = useCounter(0)
    increment()
    increment()
    reset()
    expect(count.value).toBe(0)
  })
  
  it('应该正确计算是否为偶数', () => {
    const { count, increment, isEven } = useCounter(0)
    expect(isEven.value).toBe(true)
    
    increment()
    expect(isEven.value).toBe(false)
    
    increment()
    expect(isEven.value).toBe(true)
  })
})

组件测试

js
// components/__tests__/UserProfile.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import UserProfile from '../UserProfile.vue'

describe('UserProfile', () => {
  it('应该显示用户信息', () => {
    const wrapper = mount(UserProfile, {
      props: {
        user: {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com'
        }
      }
    })
    
    expect(wrapper.text()).toContain('John Doe')
    expect(wrapper.text()).toContain('john@example.com')
  })
  
  it('应该处理编辑事件', async () => {
    const wrapper = mount(UserProfile, {
      props: {
        user: { id: 1, name: 'John Doe', email: 'john@example.com' }
      }
    })
    
    await wrapper.find('[data-test="edit-button"]').trigger('click')
    expect(wrapper.emitted('edit')).toBeTruthy()
  })
})

常见陷阱与解决方案

响应式丢失问题

js
// ❌ 问题:解构导致响应式丢失
const state = reactive({ count: 0, message: 'Hello' })
const { count } = state // 丢失响应式

// ✅ 解决方案:使用 toRefs
const { count } = toRefs(state)

// ❌ 问题:传递 ref.value
const count = ref(0)
someFunction(count.value) // 传递的是值,不是响应式引用

// ✅ 解决方案:传递整个 ref
someFunction(count)

内存泄漏问题

js
// ❌ 问题:未清理事件监听器
onMounted(() => {
  window.addEventListener('resize', handleResize)
})

// ✅ 解决方案:在卸载时清理
onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
})

// ❌ 问题:未清理定时器
const timer = setInterval(() => {
  // 定时任务
}, 1000)

// ✅ 解决方案:使用组合函数管理
const useTimer = (callback, interval) => {
  const timer = ref(null)
  
  const start = () => {
    timer.value = setInterval(callback, interval)
  }
  
  const stop = () => {
    if (timer.value) {
      clearInterval(timer.value)
      timer.value = null
    }
  }
  
  onUnmounted(stop)
  
  return { start, stop }
}

性能问题

js
// ❌ 问题:在模板中使用复杂表达式
<template>
  <div v-for="item in items.filter(i => i.active).sort((a, b) => a.name.localeCompare(b.name))" />
</template>

// ✅ 解决方案:使用计算属性
<script setup>
const activeItems = computed(() => 
  items.value
    .filter(item => item.active)
    .sort((a, b) => a.name.localeCompare(b.name))
)
</script>

<template>
  <div v-for="item in activeItems" />
</template>

总结

Vue 3 的组合式 API 为我们提供了更灵活、更强大的开发方式。通过遵循本文介绍的最佳实践,你可以:

  1. 更好地组织代码:按功能而非选项组织代码,提高可读性
  2. 提升逻辑复用:通过组合函数实现高效的逻辑复用
  3. 增强类型安全:更好的 TypeScript 支持和类型推导
  4. 优化性能:避免不必要的响应式转换和计算
  5. 简化测试:组合函数更容易进行单元测试

记住以下关键原则:

  • 优先使用 ref,复杂嵌套对象使用 reactive
  • 合理拆分计算属性,避免过于复杂的逻辑
  • 创建可复用的组合函数来封装业务逻辑
  • 注意响应式数据的解构和传递
  • 及时清理副作用,避免内存泄漏
  • 充分利用 TypeScript 的类型系统

组合式 API 不仅仅是语法的改变,更是思维方式的转变。它鼓励我们以更函数式、更模块化的方式思考和组织代码,最终写出更简洁、可维护的 Vue 应用。

参考资源

vitepress开发指南