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 是一个函数,具有以下特点:
- 以
use开头命名 - 使用 Vue 的响应式 API
- 返回可以在组件中使用的响应式数据和方法
// 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') // 普通值
返回值约定
- 返回 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() // 失去响应式
- 使用 toRefs 返回 reactive:
export function useForm() {
const state = reactive({
username: '',
password: ''
})
// 返回 toRefs 包装后的值
return {
...toRefs(state),
submit: () => { /* ... */ }
}
}
// 调用者可以安全解构
const { username, password, submit } = useForm()
- 返回只读的响应式数据:
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?
- 支持动态 ref:
ref属性可以是动态值 - 更好的 IDE 支持:自动补全和类型检查
- 更明确的语义:函数名清楚地表明这是一个模板引用
<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 参数的对比
watch 和 watchEffect 的回调函数支持 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 中已不推荐使用:
| 问题 | Mixins | Composables |
|---|---|---|
| 属性来源不清晰 | ❌ 难以追踪 | ✅ 明确的函数调用 |
| 命名冲突 | ❌ 容易冲突 | ✅ 可重命名解构 |
| 隐式通信 | ❌ 依赖共享属性 | ✅ 显式参数传递 |
| 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 的完整内容:
- setup() 和 script setup:组合式 API 的入口点
- Composables:逻辑复用的最佳实践
- 命名约定:以
use开头 - 输入参数处理:使用
toValue() - 返回值约定:返回 ref 或使用 toRefs
- 副作用清理:在 onUnmounted 中清理
- 使用限制:只能在 setup 顶层同步调用
- provide/inject:跨层级组件通信
- 模板引用:访问 DOM 和子组件
- 与其他模式对比:Composables 优于 Mixins 和无渲染组件
练习
- 创建一个
useDebounceFnComposable,封装防抖函数 - 创建一个
usePaginationComposable,处理分页逻辑 - 创建一个表单验证 Composable,支持多个字段验证
- 创建一个
useAsyncComposable,封装异步状态管理
参考资源
- Vue 官方文档 - 组合式 API
- Vue 官方文档 - Composables
- VueUse - Vue Composables 工具库