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 为我们提供了更灵活、更强大的开发方式。通过遵循本文介绍的最佳实践,你可以:
- 更好地组织代码:按功能而非选项组织代码,提高可读性
- 提升逻辑复用:通过组合函数实现高效的逻辑复用
- 增强类型安全:更好的 TypeScript 支持和类型推导
- 优化性能:避免不必要的响应式转换和计算
- 简化测试:组合函数更容易进行单元测试
记住以下关键原则:
- 优先使用
ref
,复杂嵌套对象使用reactive
- 合理拆分计算属性,避免过于复杂的逻辑
- 创建可复用的组合函数来封装业务逻辑
- 注意响应式数据的解构和传递
- 及时清理副作用,避免内存泄漏
- 充分利用 TypeScript 的类型系统
组合式 API 不仅仅是语法的改变,更是思维方式的转变。它鼓励我们以更函数式、更模块化的方式思考和组织代码,最终写出更简洁、可维护的 Vue 应用。