深入理解 Vue 3 Composition API
Composition API 设计理念、组合式函数最佳实践、与 React Hooks 对比
什么是 Composition API?
定义:基于函数的 API,用于在组件中组织和复用逻辑代码。
涉及场景:
- 逻辑复用:提取可复用的组合式函数(Composables)
- 代码组织:按功能而非选项组织代码
- 类型推导:更好的 TypeScript 支持
- 大型组件:将复杂组件拆分为多个逻辑单元
作用:
- 解决 Mixins 的命名冲突和来源不清问题
- 提供更灵活的代码组织方式
- 更好的 TypeScript 类型推导
- 支持 Tree-shaking,减小打包体积
一、核心 API
1.1 setup 函数
setup 是 Composition API 的入口,在组件创建前执行:
<script>
import { ref, onMounted } from 'vue'
export default {
setup(props, context) {
// props - 响应式的 props
// context - { attrs, slots, emit, expose }
const count = ref(0)
function increment() {
count.value++
}
onMounted(() => {
console.log('mounted')
})
// 返回的内容暴露给模板
return {
count,
increment
}
}
}
</script>执行时机:
- 在
beforeCreate之前执行 - 此时组件实例尚未创建,无法访问
this - props 已解析,可以访问
context 对象:
interface SetupContext {
attrs: Record<string, any> // 非 props 的属性
slots: Slots // 插槽
emit: (event: string, ...args: any[]) => void // 触发事件
expose: (exposed?: Record<string, any>) => void // 暴露公共属性
}1.2 <script setup>
<script setup> 是 setup 函数的语法糖,提供更简洁的写法:
<script setup>
import { ref, onMounted } from 'vue'
// 顶层绑定自动暴露给模板
const count = ref(0)
function increment() {
count.value++
}
onMounted(() => {
console.log('mounted')
})
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>优势:
- 更少的样板代码:无需 return
- 更好的性能:编译时优化
- 更好的 IDE 支持:类型推导更准确
- 自动注册组件:导入的组件直接可用
编译后:
export default {
setup(__props, { expose }) {
const count = ref(0)
function increment() {
count.value++
}
onMounted(() => {
console.log('mounted')
})
const __returned__ = { count, increment }
expose(__returned__)
return __returned__
}
}1.3 defineProps 和 defineEmits
编译器宏,用于定义 props 和 emits:
<script setup lang="ts">
// 类型定义方式(推荐)
interface Props {
msg: string
count?: number
}
const props = defineProps<Props>()
// 运行时声明方式
const props = defineProps({
msg: { type: String, required: true },
count: { type: Number, default: 0 }
})
// 定义事件
const emit = defineEmits<{
update: [value: number]
delete: []
}>()
// 使用
emit('update', props.count + 1)
</script>Vue 3.5 新特性 - 响应式 Props 解构:
<script setup lang="ts">
// 解构 props,支持默认值
const { msg = 'hello', count = 0 } = defineProps<{
msg?: string
count?: number
}>()
// 解构的变量是响应式的
watchEffect(() => {
console.log(msg, count) // 自动追踪
})
// 传递给 composable 需要包装为 getter
useMyComposable(() => count)
</script>1.4 defineExpose
暴露组件内部方法/属性给父组件:
<!-- 子组件 -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
// 只暴露指定的属性/方法
defineExpose({
count,
increment
})
</script>
<!-- 父组件 -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const childRef = ref()
function handleClick() {
childRef.value.increment() // 调用子组件方法
console.log(childRef.value.count) // 访问子组件数据
}
</script>
<template>
<Child ref="childRef" />
<button @click="handleClick">Increment Child</button>
</template>1.5 defineModel(Vue 3.4+)
简化 v-model 实现:
<!-- 子组件 -->
<script setup>
// 自动声明 modelValue prop 和 update:modelValue emit
const model = defineModel<string>()
// 直接修改会自动触发 emit
function updateModel() {
model.value = 'new value'
}
// 多个 v-model
const title = defineModel<string>('title')
const content = defineModel<string>('content')
// 带选项
const model = defineModel<string>({ required: true })
// 带修饰符
const [model, modifiers] = defineModel<string>({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>
<template>
<input v-model="model" />
</template>
<!-- 父组件 -->
<template>
<Child v-model="text" v-model:title="title" v-model.capitalize="name" />
</template>二、生命周期钩子
2.1 Composition API 生命周期
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated,
onDeactivated
} from 'vue'
export default {
setup() {
// setup 本身相当于 beforeCreate 和 created
console.log('setup')
onBeforeMount(() => {
console.log('beforeMount')
})
onMounted(() => {
console.log('mounted')
// 可以访问 DOM
})
onBeforeUpdate(() => {
console.log('beforeUpdate')
})
onUpdated(() => {
console.log('updated')
})
onBeforeUnmount(() => {
console.log('beforeUnmount')
})
onUnmounted(() => {
console.log('unmounted')
// 清理副作用
})
onErrorCaptured((err, instance, info) => {
console.log('errorCaptured', err)
return false // 阻止错误继续传播
})
// keep-alive 专用
onActivated(() => {
console.log('activated')
})
onDeactivated(() => {
console.log('deactivated')
})
}
}2.2 与 Options API 对比
| Options API | Composition API |
|---|---|
| beforeCreate | setup() |
| created | setup() |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeUnmount | onBeforeUnmount |
| unmounted | onUnmounted |
| errorCaptured | onErrorCaptured |
| activated | onActivated |
| deactivated | onDeactivated |
2.3 生命周期钩子特性
可以多次调用:
onMounted(() => {
console.log('mounted 1')
})
onMounted(() => {
console.log('mounted 2')
})
// 两个都会执行只能在 setup 或 <script setup> 中同步调用:
// ✅ 正确
setup() {
onMounted(() => {
console.log('mounted')
})
}
// ❌ 错误 - 异步调用
setup() {
setTimeout(() => {
onMounted(() => {
console.log('mounted')
})
}, 100)
}三、组合式函数(Composables)
3.1 什么是组合式函数?
组合式函数是利用 Composition API 封装可复用逻辑的函数。
命名规范:
- 以
use开头(如useMouse、useFetch) - 使用驼峰命名
基本结构:
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
// 使用
import { useMouse } from './composables/useMouse'
const { x, y } = useMouse()3.2 最佳实践
1. 返回 ref 对象,便于解构:
// ✅ 推荐 - 返回 ref
export function useCounter() {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, double, increment }
}
// ❌ 不推荐 - 返回 reactive
export function useCounter() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
return state // 解构会失去响应式
}2. 参数规范化 - 支持多种类型:
import { ref, unref, type Ref, type MaybeRef } from 'vue'
export function useCounter(initialValue: MaybeRef<number> = 0) {
// 使用 unref 或 toValue 规范化参数
const count = ref(unref(initialValue))
return { count }
}
// 使用
useCounter(10) // 普通值
useCounter(ref(10)) // ref
useCounter(() => 10) // getter (需要 toValue)3. 副作用清理:
import { onUnmounted } from 'vue'
export function useEventListener(
target: EventTarget,
event: string,
handler: EventListener
) {
target.addEventListener(event, handler)
// 组件卸载时自动清理
onUnmounted(() => {
target.removeEventListener(event, handler)
})
}4. 使用 onWatcherCleanup(Vue 3.5+):
import { watch, onWatcherCleanup } from 'vue'
export function useFetch(url: Ref<string>) {
const data = ref(null)
const error = ref(null)
watch(url, async (newUrl) => {
const controller = new AbortController()
// 注册清理函数
onWatcherCleanup(() => {
controller.abort()
})
try {
const res = await fetch(newUrl, { signal: controller.signal })
data.value = await res.json()
} catch (e) {
error.value = e
}
}, { immediate: true })
return { data, error }
}3.3 常见组合式函数示例
useFetch - 数据请求:
import { ref, unref, type MaybeRef } from 'vue'
export function useFetch<T>(url: MaybeRef<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await fetch(unref(url))
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}
// 使用
const { data, error, loading, refetch } = useFetch<User>('/api/user')useLocalStorage - 本地存储:
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const data = ref<T>(defaultValue)
// 从 localStorage 读取
const stored = localStorage.getItem(key)
if (stored) {
try {
data.value = JSON.parse(stored)
} catch (e) {
console.error(e)
}
}
// 监听变化,同步到 localStorage
watch(
data,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
return data
}
// 使用
const user = useLocalStorage('user', { name: '', age: 0 })useDebounce - 防抖:
import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(value: Ref<T>, delay: number = 300) {
const debouncedValue = ref(value.value) as Ref<T>
let timer: ReturnType<typeof setTimeout> | null = null
watch(value, (newValue) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
// 使用
const searchText = ref('')
const debouncedSearch = useDebounce(searchText, 500)
watch(debouncedSearch, (value) => {
// 执行搜索
})useToggle - 布尔值切换:
import { ref } from 'vue'
export function useToggle(initialValue: boolean = false) {
const value = ref(initialValue)
function toggle() {
value.value = !value.value
}
function setTrue() {
value.value = true
}
function setFalse() {
value.value = false
}
return { value, toggle, setTrue, setFalse }
}
// 使用
const { value: isOpen, toggle, setTrue, setFalse } = useToggle()四、与 React Hooks 对比
4.1 核心区别
| 特性 | Vue Composition API | React Hooks |
|---|---|---|
| 执行时机 | setup 只执行一次 | 每次渲染都执行 |
| 依赖追踪 | 自动追踪 | 手动声明依赖数组 |
| 调用位置 | 可以在条件/循环中 | 必须在顶层 |
| 性能 | 更好(不重复执行) | 需要优化(useMemo、useCallback) |
| 闭包陷阱 | 无 | 有 |
4.2 代码对比
Vue Composition API:
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
watch(count, (newVal) => {
console.log(newVal)
})
function increment() {
count.value++
}
</script>React Hooks:
import { useState, useMemo, useEffect } from 'react'
function Counter() {
const [count, setCount] = useState(0)
// 需要 useMemo 缓存
const double = useMemo(() => count * 2, [count])
// 需要手动声明依赖
useEffect(() => {
console.log(count)
}, [count])
// 需要 useCallback 避免重复创建
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
return <button onClick={increment}>{count}</button>
}4.3 优势对比
Vue Composition API 优势:
- setup 只执行一次,性能更好
- 自动依赖追踪,无需手动声明依赖数组
- 无闭包陷阱,始终访问最新值
- 更灵活,可以在条件/循环中调用
React Hooks 优势:
- 更简单的心智模型(纯函数)
- 更强的约束(规则更严格)
- 生态更丰富
五、高级模式
5.1 组合式函数组合
组合式函数可以相互调用:
// useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}
// useDoubleCounter.ts
export function useDoubleCounter(initial = 0) {
const { count, increment, decrement } = useCounter(initial)
const double = computed(() => count.value * 2)
return { count, double, increment, decrement }
}5.2 依赖注入模式
使用 provide/inject 在组合式函数中共享状态:
// useTheme.ts
import { provide, inject, ref, type InjectionKey, type Ref } from 'vue'
const ThemeSymbol: InjectionKey<Ref<string>> = Symbol('theme')
export function provideTheme() {
const theme = ref('light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(ThemeSymbol, theme)
return { theme, toggleTheme }
}
export function useTheme() {
const theme = inject(ThemeSymbol)
if (!theme) {
throw new Error('useTheme must be used within provideTheme')
}
return theme
}
// 使用
// 父组件
const { theme, toggleTheme } = provideTheme()
// 子组件
const theme = useTheme()5.3 异步组合式函数
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// 立即执行
execute()
return { data, error, loading, refetch: execute }
}
// 使用
const { data, error, loading, refetch } = useAsyncData(async () => {
const res = await fetch('/api/data')
return res.json()
})5.4 响应式参数
组合式函数接受响应式参数:
export function useFilteredList<T>(
list: Ref<T[]>,
filter: Ref<string>
) {
return computed(() => {
return list.value.filter(item =>
String(item).includes(filter.value)
)
})
}
// 使用
const list = ref([1, 2, 3, 4, 5])
const filter = ref('2')
const filtered = useFilteredList(list, filter)
// filter 或 list 变化时,filtered 自动更新六、TypeScript 支持
6.1 组合式函数类型定义
import { ref, computed, type Ref, type ComputedRef } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
step?: number
}
interface UseCounterReturn {
count: Ref<number>
double: ComputedRef<number>
increment: () => void
decrement: () => void
reset: () => void
}
export function useCounter(
initialValue: number = 0,
options: UseCounterOptions = {}
): UseCounterReturn {
const { min = 0, max = Infinity, step = 1 } = options
const count = ref(initialValue)
const double = computed(() => count.value * 2)
function increment() {
count.value = Math.min(count.value + step, max)
}
function decrement() {
count.value = Math.max(count.value - step, min)
}
function reset() {
count.value = initialValue
}
return { count, double, increment, decrement, reset }
}6.2 泛型组合式函数
export function useList<T>(initialList: T[] = []) {
const list = ref<T[]>(initialList)
function add(item: T) {
list.value.push(item)
}
function remove(index: number) {
list.value.splice(index, 1)
}
function clear() {
list.value = []
}
return { list, add, remove, clear }
}
// 使用
interface User {
id: number
name: string
}
const { list, add, remove } = useList<User>()
add({ id: 1, name: 'Alice' }) // 类型安全面试高频题
1: Composition API 解决了什么问题?
答案:
- 逻辑复用困难:Mixins 有命名冲突和来源不清的问题
- 代码组织混乱:Options API 按选项分散相关逻辑
- 类型推导弱:this 的类型推导困难
- 大组件难维护:相关逻辑分散在不同选项中
Composition API 通过组合式函数提供更好的逻辑复用和代码组织方式。
2: <script setup> 相比普通 setup 有什么优势?
答案:
- 更少的样板代码:无需 return,顶层绑定自动暴露
- 更好的性能:编译时优化,减少运行时开销
- 更好的 IDE 支持:类型推导更准确
- 自动注册组件:导入的组件直接可用
- 更简洁的语法:defineProps、defineEmits 等宏
3: 组合式函数的最佳实践是什么?
答案:
- 命名:use 开头,驼峰命名
- 返回值:返回 ref 对象,便于解构
- 参数规范化:支持 ref/getter/普通值(使用 toValue)
- 副作用清理:使用 onUnmounted 或 onWatcherCleanup
- 类型定义:提供完整的 TypeScript 类型
- 单一职责:每个组合式函数只做一件事
4: Composition API 和 React Hooks 有什么区别?
答案:
核心区别:
- 执行时机:Vue setup 只执行一次,React 每次渲染都执行
- 依赖追踪:Vue 自动追踪,React 手动声明依赖数组
- 调用位置:Vue 可以在条件/循环中,React 必须在顶层
- 性能:Vue 更好(不重复执行),React 需要 useMemo/useCallback 优化
Vue 优势:
- 无闭包陷阱,始终访问最新值
- 自动依赖追踪,不易出错
- 性能更好
5: 如何在组合式函数中处理异步操作?
答案:
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}关键点:
- 使用 loading 状态
- 错误处理
- 提供 refetch 方法
- 使用 onWatcherCleanup 清理请求
6: defineModel 的实现原理是什么?
答案:
defineModel 是编译器宏,编译时会转换为:
// 源码
const model = defineModel<string>()
// 编译后
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const model = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})本质是 computed 的 getter/setter,自动处理 props 和 emit。
7: 如何在组合式函数中使用依赖注入?
答案:
import { provide, inject, type InjectionKey, type Ref } from 'vue'
// 定义 key(类型安全)
const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')
// 提供者
export function provideTheme() {
const theme = ref('light')
provide(ThemeKey, theme)
return { theme }
}
// 消费者
export function useTheme() {
const theme = inject(ThemeKey)
if (!theme) {
throw new Error('useTheme must be used within provideTheme')
}
return theme
}关键点:
- 使用 Symbol 作为 key
- 使用 InjectionKey 提供类型
- 检查 inject 返回值
8: 组合式函数可以在哪里调用?
答案:
可以调用的地方:
- setup() 函数中(同步)
<script setup>中(同步)- 其他组合式函数中(同步)
- 生命周期钩子中
不能调用的地方:
- 异步回调中
- 条件语句中(如果包含生命周期钩子)
- 普通函数中
// ✅ 正确
setup() {
const { x, y } = useMouse()
}
// ❌ 错误
setup() {
setTimeout(() => {
const { x, y } = useMouse() // 异步调用
}, 100)
}9: 如何测试组合式函数?
答案:
import { mount } from '@vue/test-utils'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should increment', () => {
// 创建测试组件
const wrapper = mount({
setup() {
const { count, increment } = useCounter()
return { count, increment }
},
template: '<div>{{ count }}</div>'
})
expect(wrapper.text()).toBe('0')
wrapper.vm.increment()
expect(wrapper.text()).toBe('1')
})
})或者使用 @vue/test-utils 的 flushPromises:
import { flushPromises } from '@vue/test-utils'
it('should fetch data', async () => {
const wrapper = mount({
setup() {
const { data, loading } = useFetch('/api/data')
return { data, loading }
}
})
expect(wrapper.vm.loading).toBe(true)
await flushPromises()
expect(wrapper.vm.loading).toBe(false)
expect(wrapper.vm.data).toBeDefined()
})10: 什么时候使用 Composition API,什么时候使用 Options API?
答案:
使用 Composition API:
- 逻辑复用需求多
- 大型复杂组件
- 需要更好的 TypeScript 支持
- 团队熟悉函数式编程
- 新项目
使用 Options API:
- 简单组件
- 团队更熟悉 Options API
- 维护老项目
- 学习曲线考虑
建议:
- 新项目优先 Composition API
- 两种 API 可以混用
- 根据团队情况选择