Skip to content

深入理解 Vue 3 Composition API

Composition API 设计理念、组合式函数最佳实践、与 React Hooks 对比

什么是 Composition API?

定义:基于函数的 API,用于在组件中组织和复用逻辑代码。

涉及场景

  • 逻辑复用:提取可复用的组合式函数(Composables)
  • 代码组织:按功能而非选项组织代码
  • 类型推导:更好的 TypeScript 支持
  • 大型组件:将复杂组件拆分为多个逻辑单元

作用

  1. 解决 Mixins 的命名冲突和来源不清问题
  2. 提供更灵活的代码组织方式
  3. 更好的 TypeScript 类型推导
  4. 支持 Tree-shaking,减小打包体积

一、核心 API

1.1 setup 函数

setup 是 Composition API 的入口,在组件创建前执行:

vue
<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 对象

typescript
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 函数的语法糖,提供更简洁的写法:

vue
<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>

优势

  1. 更少的样板代码:无需 return
  2. 更好的性能:编译时优化
  3. 更好的 IDE 支持:类型推导更准确
  4. 自动注册组件:导入的组件直接可用

编译后

javascript
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:

vue
<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 解构

vue
<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

暴露组件内部方法/属性给父组件:

vue
<!-- 子组件 -->
<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 实现:

vue
<!-- 子组件 -->
<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 生命周期

typescript
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 APIComposition API
beforeCreatesetup()
createdsetup()
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
activatedonActivated
deactivatedonDeactivated

2.3 生命周期钩子特性

可以多次调用

typescript
onMounted(() => {
  console.log('mounted 1')
})

onMounted(() => {
  console.log('mounted 2')
})

// 两个都会执行

只能在 setup 或 <script setup> 中同步调用

typescript
// ✅ 正确
setup() {
  onMounted(() => {
    console.log('mounted')
  })
}

// ❌ 错误 - 异步调用
setup() {
  setTimeout(() => {
    onMounted(() => {
      console.log('mounted')
    })
  }, 100)
}

三、组合式函数(Composables)

3.1 什么是组合式函数?

组合式函数是利用 Composition API 封装可复用逻辑的函数。

命名规范

  • use 开头(如 useMouseuseFetch
  • 使用驼峰命名

基本结构

typescript
// 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 对象,便于解构

typescript
// ✅ 推荐 - 返回 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. 参数规范化 - 支持多种类型

typescript
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. 副作用清理

typescript
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+)

typescript
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 - 数据请求

typescript
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 - 本地存储

typescript
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 - 防抖

typescript
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 - 布尔值切换

typescript
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 APIReact Hooks
执行时机setup 只执行一次每次渲染都执行
依赖追踪自动追踪手动声明依赖数组
调用位置可以在条件/循环中必须在顶层
性能更好(不重复执行)需要优化(useMemo、useCallback)
闭包陷阱

4.2 代码对比

Vue Composition API

vue
<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

jsx
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 优势

  1. setup 只执行一次,性能更好
  2. 自动依赖追踪,无需手动声明依赖数组
  3. 无闭包陷阱,始终访问最新值
  4. 更灵活,可以在条件/循环中调用

React Hooks 优势

  1. 更简单的心智模型(纯函数)
  2. 更强的约束(规则更严格)
  3. 生态更丰富

五、高级模式

5.1 组合式函数组合

组合式函数可以相互调用:

typescript
// 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 在组合式函数中共享状态:

typescript
// 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 异步组合式函数

typescript
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 响应式参数

组合式函数接受响应式参数:

typescript
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 组合式函数类型定义

typescript
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 泛型组合式函数

typescript
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 解决了什么问题?

答案

  1. 逻辑复用困难:Mixins 有命名冲突和来源不清的问题
  2. 代码组织混乱:Options API 按选项分散相关逻辑
  3. 类型推导弱:this 的类型推导困难
  4. 大组件难维护:相关逻辑分散在不同选项中

Composition API 通过组合式函数提供更好的逻辑复用和代码组织方式。


2: <script setup> 相比普通 setup 有什么优势?

答案

  1. 更少的样板代码:无需 return,顶层绑定自动暴露
  2. 更好的性能:编译时优化,减少运行时开销
  3. 更好的 IDE 支持:类型推导更准确
  4. 自动注册组件:导入的组件直接可用
  5. 更简洁的语法:defineProps、defineEmits 等宏

3: 组合式函数的最佳实践是什么?

答案

  1. 命名:use 开头,驼峰命名
  2. 返回值:返回 ref 对象,便于解构
  3. 参数规范化:支持 ref/getter/普通值(使用 toValue)
  4. 副作用清理:使用 onUnmounted 或 onWatcherCleanup
  5. 类型定义:提供完整的 TypeScript 类型
  6. 单一职责:每个组合式函数只做一件事

4: Composition API 和 React Hooks 有什么区别?

答案

核心区别

  • 执行时机:Vue setup 只执行一次,React 每次渲染都执行
  • 依赖追踪:Vue 自动追踪,React 手动声明依赖数组
  • 调用位置:Vue 可以在条件/循环中,React 必须在顶层
  • 性能:Vue 更好(不重复执行),React 需要 useMemo/useCallback 优化

Vue 优势

  • 无闭包陷阱,始终访问最新值
  • 自动依赖追踪,不易出错
  • 性能更好

5: 如何在组合式函数中处理异步操作?

答案

typescript
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 }
}

关键点:

  1. 使用 loading 状态
  2. 错误处理
  3. 提供 refetch 方法
  4. 使用 onWatcherCleanup 清理请求

6: defineModel 的实现原理是什么?

答案

defineModel 是编译器宏,编译时会转换为:

typescript
// 源码
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: 如何在组合式函数中使用依赖注入?

答案

typescript
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
}

关键点:

  1. 使用 Symbol 作为 key
  2. 使用 InjectionKey 提供类型
  3. 检查 inject 返回值

8: 组合式函数可以在哪里调用?

答案

可以调用的地方

  1. setup() 函数中(同步)
  2. <script setup> 中(同步)
  3. 其他组合式函数中(同步)
  4. 生命周期钩子中

不能调用的地方

  1. 异步回调中
  2. 条件语句中(如果包含生命周期钩子)
  3. 普通函数中
typescript
// ✅ 正确
setup() {
  const { x, y } = useMouse()
}

// ❌ 错误
setup() {
  setTimeout(() => {
    const { x, y } = useMouse() // 异步调用
  }, 100)
}

9: 如何测试组合式函数?

答案

typescript
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-utilsflushPromises

typescript
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 可以混用
  • 根据团队情况选择