跳到主要内容

Vue 组合式 API

组合式 API(Composition API)是 Vue 3 引入的核心特性,它提供了一种更灵活、更可组合的方式来组织组件逻辑。与 Options API 相比,组合式 API 提供了更好的逻辑复用能力和 TypeScript 支持。

setup() 函数

setup() 是组合式 API 的入口点,在组件实例创建之前执行。

基本结构

import { ref, onMounted } from 'vue'

export default {
setup() {
// 响应式数据
const count = ref(0)

// 方法
function increment() {
count.value++
}

// 生命周期钩子
onMounted(() => {
console.log('组件已挂载')
})

// 返回给模板使用的数据和方法
return {
count,
increment
}
}
}

setup() 的参数

export default {
setup(props, context) {
// props:组件传入的属性
console.log(props.title)

// context:上下文对象
// context.attrs - 非响应式对象,包含未在 props 中声明的属性
// context.slots - 非响应式对象,包含插槽
// context.emit - 触发事件的方法
// context.expose - 暴露公共属性

context.emit('update', newValue)
}
}

script setup 语法糖

<script setup>setup() 的编译时语法糖,代码更简洁:

<script setup>
import { ref, onMounted } from 'vue'

// 所有代码都在 setup 中执行
const count = ref(0)

function increment() {
count.value++
}

onMounted(() => {
console.log('组件已挂载')
})

// 不需要 return,模板可以直接访问
</script>

<template>
<button @click="increment">{{ count }}</button>
</template>

script setup 的优势

<script setup>
// 1. 顶层变量自动暴露给模板
const message = 'Hello'

// 2. 导入的组件直接使用
import MyComponent from './MyComponent.vue'

// 3. 导入的函数直接使用
import { format } from 'date-fns'

// 4. defineProps 和 defineEmits 不需要导入
const props = defineProps({
title: String
})

const emit = defineEmits(['update'])
</script>

<template>
<h1>{{ title }}</h1>
<MyComponent />
<p>{{ format(new Date(), 'yyyy-MM-dd') }}</p>
</template>

Composables(组合式函数)

Composables 是 Vue 3 推荐的逻辑复用方式,类似于 React 的 Hooks。它是一个利用 Vue 组合式 API 来封装和复用有状态逻辑的函数。

什么是 Composable?

Composable 是一个函数,具有以下特点:

  1. use 开头命名
  2. 使用 Vue 的响应式 API
  3. 返回可以在组件中使用的响应式数据和方法
// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
// 状态封装在 composable 内部
const count = ref(initialValue)

// 可复用的方法
function increment() {
count.value++
}

function decrement() {
count.value--
}

function reset() {
count.value = initialValue
}

// 暴露状态和方法
return {
count,
increment,
decrement,
reset
}
}

使用 Composable

<script setup>
import { useCounter } from './composables/useCounter'

// 组件中使用
const { count, increment, decrement, reset } = useCounter(10)
</script>

<template>
<div>
<p>计数: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">重置</button>
</div>
</template>

示例:useMouse

跟踪鼠标位置:

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
const x = ref(0)
const y = ref(0)

function update(event) {
x.value = event.pageX
y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))

return { x, y }
}
<script setup>
import { useMouse } from './composables/useMouse'

const { x, y } = useMouse()
</script>

<template>
鼠标位置: {{ x }}, {{ y }}
</template>

示例:useEventListener

封装事件监听逻辑:

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
onMounted(() => target.addEventListener(event, callback))
onUnmounted(() => target.removeEventListener(event, callback))
}

组合使用 Composables

Composables 可以相互组合:

// composables/useMouse.js
import { ref } from 'vue'
import { useEventListener } from './useEventListener'

export function useMouse() {
const x = ref(0)
const y = ref(0)

// 使用另一个 composable
useEventListener(window, 'mousemove', (event) => {
x.value = event.pageX
y.value = event.pageY
})

return { x, y }
}

示例:useFetch

封装异步数据获取:

// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)

async function fetchData() {
loading.value = true
error.value = null

try {
const response = await fetch(toValue(url))
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}

// 当 URL 变化时重新获取
watchEffect(() => {
fetchData()
})

return {
data,
error,
loading,
refetch: fetchData
}
}
<script setup>
import { ref } from 'vue'
import { useFetch } from './composables/useFetch'

const url = ref('https://api.example.com/users')
const { data, error, loading, refetch } = useFetch(url)
</script>

<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<div v-else>
<pre>{{ data }}</pre>
<button @click="refetch">重新加载</button>
</div>
</template>

示例:useLocalStorage

封装本地存储:

// composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored ? JSON.parse(stored) : defaultValue)

// 数据变化时自动保存
watch(data, (newValue) => {
if (newValue === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
}, { deep: true })

function remove() {
data.value = null
}

return {
data,
remove
}
}
<script setup>
import { useLocalStorage } from './composables/useLocalStorage'

const { data: username } = useLocalStorage('username', '')
const { data: theme } = useLocalStorage('theme', 'light')
</script>

<template>
<input v-model="username" placeholder="用户名" />
<select v-model="theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</template>

示例:useDebounce

防抖功能:

// composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
const debouncedValue = ref(value.value)
let timeout

watch(value, (newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})

return debouncedValue
}
<script setup>
import { ref } from 'vue'
import { useDebounce } from './composables/useDebounce'

const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
</script>

示例:useToggle

切换状态:

// composables/useToggle.js
import { ref } from 'vue'

export function useToggle(initialValue = 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
}
}

Composables 最佳实践

命名约定

// ✅ 正确:以 use 开头
export function useCounter() { }
export function useMouse() { }
export function useFetch() { }

// ❌ 错误:不以 use 开头
export function counter() { }
export function getMouse() { }

输入参数处理

使用 toValue() 处理 ref、getter 或普通值:

import { toValue } from 'vue'

export function useFeature(maybeRefOrGetter) {
// toValue 会自动解包 ref 和调用 getter
const value = toValue(maybeRefOrGetter)

// 在 watchEffect 中使用
watchEffect(() => {
console.log(toValue(maybeRefOrGetter))
})
}
import { useFeature } from './useFeature'
import { ref } from 'vue'

// 可以接受多种形式的参数
const refValue = ref('hello')
useFeature(refValue) // ref
useFeature(() => 'hello') // getter
useFeature('hello') // 普通值

返回值约定

  1. 返回 ref 而不是 reactive:方便调用者解构
// ✅ 推荐:返回 ref
export function useCounter() {
const count = ref(0)
return { count }
}

// 调用者可以解构
const { count } = useCounter()

// ❌ 不推荐:返回 reactive
export function useCounter() {
const state = reactive({ count: 0 })
return state
}

// 调用者解构会丢失响应式
const { count } = useCounter() // 失去响应式
  1. 使用 toRefs 返回 reactive
export function useForm() {
const state = reactive({
username: '',
password: ''
})

// 返回 toRefs 包装后的值
return {
...toRefs(state),
submit: () => { /* ... */ }
}
}

// 调用者可以安全解构
const { username, password, submit } = useForm()
  1. 返回只读的响应式数据
import { ref, readonly } from 'vue'

export function useUser() {
const user = ref(null)
const loading = ref(false)

async function fetchUser(id) {
loading.value = true
user.value = await api.getUser(id)
loading.value = false
}

// 返回只读版本,防止外部修改
return {
user: readonly(user),
loading: readonly(loading),
fetchUser
}
}

副作用处理

onUnmounted 中清理副作用:

import { ref, onUnmounted } from 'vue'

export function useInterval(callback, delay) {
const intervalId = ref(null)

function start() {
stop()
intervalId.value = setInterval(callback, delay)
}

function stop() {
if (intervalId.value) {
clearInterval(intervalId.value)
intervalId.value = null
}
}

// 组件卸载时清理
onUnmounted(stop)

return { start, stop, intervalId }
}

使用限制

Composables 只能在特定位置调用:

// ✅ 正确:在 setup 顶层调用
import { useMouse } from './useMouse'

export function useMouseAndKeyboard() {
const { x, y } = useMouse() // 正确
return { x, y }
}

// ❌ 错误:在条件语句中调用
export function useConditionalFeature(enabled) {
if (enabled) {
const { x, y } = useMouse() // 错误!
}
}

// ❌ 错误:在循环中调用
export function useMultiple() {
for (let i = 0; i < 3; i++) {
const { x, y } = useMouse() // 错误!
}
}

// ✅ 正确:在生命周期钩子中调用
import { onMounted } from 'vue'

export function useFeature() {
onMounted(() => {
// 某些操作
})
}

SSR 注意事项

在服务端渲染时,确保在 onMounted 中访问 DOM:

export function useWindowSize() {
const width = ref(0)
const height = ref(0)

function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}

// 只在客户端执行
onMounted(() => {
update()
window.addEventListener('resize', update)
})

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

return { width, height }
}

异步组件

defineAsyncComponent

import { defineAsyncComponent } from 'vue'

// 简单用法
const AsyncComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)

// 完整配置
const AsyncComponent = defineAsyncComponent({
loader: () => import('./components/HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200, // 延迟显示 loading 组件
timeout: 3000, // 超时时间
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // 重试
} else {
fail() // 失败
}
}
})

Suspense 与异步组件

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncUsers = defineAsyncComponent(() =>
import('./components/Users.vue')
)
</script>

<template>
<Suspense>
<template #default>
<AsyncUsers />
</template>
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>

依赖注入

provide / inject

用于跨层级组件通信:

// 父组件提供数据
import { provide, ref, readonly } from 'vue'

const theme = ref('dark')
const user = ref({ name: '张三' })

// 提供只读版本
provide('theme', readonly(theme))
provide('user', readonly(user))

// 提供修改方法
provide('updateTheme', (newTheme) => {
theme.value = newTheme
})
// 后代组件注入数据
import { inject } from 'vue'

const theme = inject('theme')
const user = inject('user')
const updateTheme = inject('updateTheme')

// 使用默认值
const appName = inject('appName', '默认应用名')

// 使用工厂函数作为默认值
const config = inject('config', () => ({ api: '/api' }))

响应式注入

// 使用 symbol 作为 key(推荐)
export const ThemeKey = Symbol('theme')

// 父组件
import { provide, ref } from 'vue'
import { ThemeKey } from './keys'

provide(ThemeKey, ref('dark'))

// 后代组件
import { inject } from 'vue'
import { ThemeKey } from './keys'

const theme = inject(ThemeKey)

模板引用

模板引用让我们可以直接访问 DOM 元素或子组件实例。Vue 提供了两种获取模板引用的方式。

方式一:ref 属性(传统方式)

使用与 ref() 响应式变量同名的 ref 属性:

<script setup>
import { ref, onMounted } from 'vue'

// 变量名必须与 ref 属性值相同
const inputRef = ref(null)

onMounted(() => {
// 通过 .value 访问 DOM 元素
inputRef.value?.focus()
})
</script>

<template>
<!-- ref 属性值与变量名相同 -->
<input ref="inputRef" type="text" />
</template>

工作原理:当 Vue 渲染组件时,它会查找所有带有 ref 属性的元素,并将对应的 DOM 元素赋值给同名的 ref 变量。

限制:这种方式要求 ref 属性必须是静态字符串,无法使用动态值。

方式二:useTemplateRef()(Vue 3.5+)

Vue 3.5 引入了新的 useTemplateRef() API,通过字符串 ID 匹配模板引用:

<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 通过字符串 ID 获取模板引用
// 参数必须与模板中 ref 属性的值匹配
const inputEl = useTemplateRef('my-input')

onMounted(() => {
inputEl.value?.focus()
})

function logValue() {
console.log('输入值:', inputEl.value?.value)
}
</script>

<template>
<input ref="my-input" type="text" />
<button @click="logValue">获取输入值</button>
</template>

为什么推荐 useTemplateRef?

  1. 支持动态 refref 属性可以是动态值
  2. 更好的 IDE 支持:自动补全和类型检查
  3. 更明确的语义:函数名清楚地表明这是一个模板引用
<script setup>
import { useTemplateRef, ref } from 'vue'

// 动态切换引用不同的元素
const activeRef = ref('input1')
const currentEl = useTemplateRef('dynamic-ref')
</script>

<template>
<!-- 动态 ref 属性 -->
<input :ref="activeRef" />
<div ref="dynamic-ref">动态引用的元素</div>
</template>

v-for 中的 ref

当在 v-for 中使用模板引用时,引用会是一个数组:

<script setup>
import { ref, onMounted } from 'vue'

const list = ref([1, 2, 3])
const itemRefs = ref([])

onMounted(() => {
// itemRefs.value 是一个数组
console.log(itemRefs.value) // [div, div, div]
itemRefs.value.forEach(el => {
console.log(el.textContent)
})
})

// 使用 useTemplateRef(Vue 3.5+)
import { useTemplateRef } from 'vue'
const itemEls = useTemplateRef('items')
</script>

<template>
<div v-for="item in list" :key="item" ref="itemRefs">
{{ item }}
</div>

<!-- Vue 3.5+ 也可以这样写 -->
<div v-for="item in list" :key="item" ref="items">
{{ item }}
</div>
</template>

v-for 中的 ref

<script setup>
import { ref, onMounted } from 'vue'

const list = ref([1, 2, 3])
const itemRefs = ref([])

onMounted(() => {
console.log(itemRefs.value) // [div, div, div]
})
</script>

<template>
<div v-for="item in list" :key="item" ref="itemRefs">
{{ item }}
</div>
</template>

组件引用

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
count.value++
}

// 暴露给父组件
defineExpose({
count,
increment
})
</script>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const childRef = ref(null)

function handleIncrement() {
childRef.value?.increment()
}
</script>

<template>
<Child ref="childRef" />
<button @click="handleIncrement">调用子组件方法</button>
</template>

useId()(Vue 3.5+)

useId() 是 Vue 3.5 引入的新 API,用于生成在服务端和客户端渲染中都稳定的唯一 ID。这在 SSR 应用中特别有用。

基本用法

<script setup>
import { useId } from 'vue'

// 生成唯一 ID
const id = useId()
</script>

<template>
<form>
<!-- 用于关联表单元素和标签 -->
<label :for="id">姓名:</label>
<input :id="id" type="text" />
</form>
</template>

为什么需要 useId?

在 SSR(服务端渲染)应用中,使用普通的 ID 生成方式会导致问题:

// ❌ 问题:SSR 和客户端生成的 ID 不一致
const id = `input-${Math.random()}` // 服务端和客户端值不同
const id2 = `input-${Date.now()}` // 同样会不一致

// ✅ 正确:useId 保证 SSR 和客户端 ID 一致
const id = useId() // 服务端和客户端生成相同的 ID

SSR 水合不匹配问题:如果服务端渲染的 HTML 中的 ID 与客户端生成的不一致,Vue 会警告"hydration mismatch"。useId() 通过在应用级别生成一致的 ID 序列解决了这个问题。

实际应用示例

<script setup>
import { useId } from 'vue'

// 为表单元素生成唯一 ID
const emailId = useId()
const passwordId = useId()

// 为无障碍属性生成 ID
const descriptionId = useId()
</script>

<template>
<form>
<div>
<label :for="emailId">邮箱</label>
<input
:id="emailId"
type="email"
:aria-describedby="descriptionId"
/>
<small :id="descriptionId">
请输入有效的邮箱地址
</small>
</div>

<div>
<label :for="passwordId">密码</label>
<input :id="passwordId" type="password" />
</div>
</form>
</template>

onWatcherCleanup()(Vue 3.5+)

Vue 3.5 引入了全局的 onWatcherCleanup() API,用于在 watcher 中注册清理回调。相比传统的 onCleanup 参数,它可以在外部函数中使用。

基本用法

import { watch, ref, onWatcherCleanup } from 'vue'

const id = ref(1)

watch(id, (newId) => {
// 创建可取消的请求控制器
const controller = new AbortController()

// 注册清理回调
// 当 id 再次变化或 watcher 停止时自动调用
onWatcherCleanup(() => {
controller.abort() // 取消未完成的请求
console.log('清理之前的请求')
})

// 发起请求
fetch(`/api/user/${newId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
console.log('获取数据:', data)
})
})

与 onCleanup 参数的对比

watchwatchEffect 的回调函数支持 onCleanup 参数,功能相似但使用场景不同:

// 方式一:使用回调参数(Vue 3.0+)
watch(id, (newId, oldId, onCleanup) => {
const controller = new AbortController()

onCleanup(() => {
controller.abort()
})

fetch(`/api/user/${newId}`, { signal: controller.signal })
})

// 方式二:使用全局 API(Vue 3.5+)
// 优势:可以在回调函数调用的其他函数中使用
watch(id, (newId) => {
const controller = new AbortController()

onWatcherCleanup(() => {
controller.abort()
})

fetch(`/api/user/${newId}`, { signal: controller.signal })
})

两种方式的核心区别

  • onCleanup 参数:只能在 watcher 回调函数内部直接使用
  • onWatcherCleanup():可以在回调函数调用的其他函数中使用,更适合在 composable 中复用

在 Composable 中使用

onWatcherCleanup() 特别适合在 composable 函数中复用清理逻辑:

// composables/useFetch.js
import { ref, watchEffect, onWatcherCleanup, toValue } from 'vue'

export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)

watchEffect(async () => {
const controller = new AbortController()

// 在 watchEffect 外部注册清理回调也能工作
onWatcherCleanup(() => {
controller.abort()
})

loading.value = true
error.value = null

try {
const response = await fetch(toValue(url), {
signal: controller.signal
})
data.value = await response.json()
} catch (e) {
if (e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
})

return { data, error, loading }
}

清理时机

清理回调在以下情况下会被调用:

import { watch, ref, onWatcherCleanup } from 'vue'

const count = ref(0)

const stop = watch(count, (newValue) => {
console.log('count:', newValue)

onWatcherCleanup(() => {
console.log('清理回调被调用')
})
})

// 情况1:依赖变化时,先清理再执行新回调
count.value = 1 // 输出: 清理回调被调用, 然后 count: 1

// 情况2:手动停止 watcher
stop() // 输出: 清理回调被调用

// 情况3:组件卸载时(在组件内使用)
// watcher 会随组件一起销毁,触发清理

与 Mixins 和无渲染组件对比

vs Mixins

Mixins 在 Vue 3 中已不推荐使用:

问题MixinsComposables
属性来源不清晰❌ 难以追踪✅ 明确的函数调用
命名冲突❌ 容易冲突✅ 可重命名解构
隐式通信❌ 依赖共享属性✅ 显式参数传递
TypeScript 支持❌ 差✅ 好
// ❌ Mixins 方式(不推荐)
const mixin = {
data() {
return { count: 0 }
},
methods: {
increment() {
this.count++
}
}
}

// ✅ Composables 方式(推荐)
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}

vs 无渲染组件

无渲染组件会创建额外的组件实例,性能开销更大:

<!-- ❌ 无渲染组件方式 -->
<template>
<MouseTracker v-slot="{ x, y }">
鼠标位置: {{ x }}, {{ y }}
</MouseTracker>
</template>

<!-- ✅ Composables 方式 -->
<script setup>
import { useMouse } from './useMouse'
const { x, y } = useMouse()
</script>

<template>
鼠标位置: {{ x }}, {{ y }}
</template>

小结

本章我们详细学习了组合式 API 的完整内容:

  1. setup() 和 script setup:组合式 API 的入口点
  2. Composables:逻辑复用的最佳实践
  3. 命名约定:以 use 开头
  4. 输入参数处理:使用 toValue()
  5. 返回值约定:返回 ref 或使用 toRefs
  6. 副作用清理:在 onUnmounted 中清理
  7. 使用限制:只能在 setup 顶层同步调用
  8. provide/inject:跨层级组件通信
  9. 模板引用:访问 DOM 和子组件
  10. 与其他模式对比:Composables 优于 Mixins 和无渲染组件

练习

  1. 创建一个 useDebounceFn Composable,封装防抖函数
  2. 创建一个 usePagination Composable,处理分页逻辑
  3. 创建一个表单验证 Composable,支持多个字段验证
  4. 创建一个 useAsync Composable,封装异步状态管理

参考资源