外观
Vue 3 组合式 API:从选项式到组合式的思维转变
从 Vue 2 迁移到 Vue 3 时,很多人第一反应是"把 data 搬进 setup,把 methods 变成函数"。这样写当然能跑,但没有用到组合式 API 真正的价值。
组合式 API 解决的不是语法问题,而是逻辑组织问题:当一个组件的状态、副作用和业务规则跨越多个功能域时,如何让相关代码待在一起,并且能被复用和测试。
选项式的结构性问题
Vue 2 的选项式 API 按"类型"组织代码:数据放 data,方法放 methods,侦听放 watch,生命周期放钩子。组件小的时候很清晰,但组件一旦承载多个功能,同一个功能的代码就被拆散到五六个选项里:
- 搜索功能的状态在
data,请求方法在methods,防抖在watch,取消请求在组件销毁钩子里。 - 另一个筛选功能又重复一遍,逻辑之间只能通过
this互相访问。 - 复用主要靠 mixin,多个 mixin 容易出现命名冲突,来源也不透明。
组合式 API 把组织维度从"选项类型"换成了"功能域":一个功能的状态、派生值、方法和副作用写在同一个组合函数里。
setup 的运行时模型
setup 在组件实例创建之前执行,返回值暴露给模板。它只执行一次,不像 methods 每次调用都重新创建。
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">
{{ count }} / {{ doubled }}
</button>
</template><script setup> 是编译时语法糖,顶层的变量、函数和 import 会自动暴露给模板,减少了 return 样板代码。它还有一个重要特征:每个顶层绑定都能被模板编译器精确追踪,有利于后续优化。
ref 与 reactive 的边界
两者的核心区别不是"简单类型用 ref,对象用 reactive"这么简单,而是响应式实现方式不同:
ref用一个带get value/set value的对象包裹任意值,通过.value触发依赖收集。reactive用 Proxy 代理对象,直接访问属性即可触发收集。- 模板中
ref会自动解包,所以模板里不需要写.value;在 JS 中必须写。
reactive 有几个容易忽略的限制:
ts
import { reactive } from 'vue'
let state = reactive({ count: 0 })
// 重新赋值会丢失响应式,因为模板仍引用旧代理
state = reactive({ count: 1 })
const { count } = state
// 解构出来的是普通值,不再具有响应式
count++所以在需要整体替换、传递或解构的场景,ref 往往更稳。对象内部嵌套很深时,reactive 写起来更自然,但要避免整体替换。
用组合函数复用逻辑
组合式 API 最重要的产出是 composable:一个以 use 开头、内部使用 Vue 响应式 API 的函数。它返回状态和方法,调用方决定如何组合。
下面是一个带取消能力的请求封装:
ts
// composables/useRequest.ts
import { onScopeDispose, ref, shallowRef } from 'vue'
export function useRequest<T>(fetcher: (signal: AbortSignal) => Promise<T>) {
const data = shallowRef<T>()
const error = shallowRef<unknown>()
const loading = ref(false)
const controller = new AbortController()
async function run() {
loading.value = true
error.value = undefined
try {
data.value = await fetcher(controller.signal)
} catch (err) {
if (!controller.signal.aborted) {
error.value = err
}
} finally {
loading.value = false
}
}
onScopeDispose(() => controller.abort())
return { data, error, loading, run }
}使用方只关心业务:
vue
<script setup lang="ts">
import { useRequest } from '@/composables/useRequest'
interface Article {
id: string
title: string
}
const { data, loading, error, run } = useRequest<Article[]>(async (signal) => {
const res = await fetch('/api/articles', { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
run()
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">加载失败</div>
<ul v-else>
<li v-for="item in data" :key="item.id">{{ item.title }}</li>
</ul>
</template>useRequest 没有依赖组件实例,可以在任意 setup 或 effectScope 中使用,也更容易单独测试。
侦听与副作用
组合式 API 中的 watch 需要显式指定侦听源,这反而让依赖关系更清楚:
ts
import { ref, watch } from 'vue'
const keyword = ref('')
const stop = watch(keyword, (value, _oldValue, onCleanup) => {
const controller = new AbortController()
const timer = setTimeout(() => {
search(value, controller.signal)
}, 300)
onCleanup(() => {
clearTimeout(timer)
controller.abort()
})
})注意两点:
watch默认是浅层侦听。侦听对象内部变化需要deep: true,但深侦听大对象成本很高,优先侦听具体字段。- 异步副作用要使用
onCleanup取消上一次请求,否则快速输入时旧请求可能覆盖新结果。
watchEffect 会自动收集依赖,适合"依赖不固定、副作用整体执行"的场景,但不要用它代替所有 watch,否则依赖关系会变得隐式。
常见坑
- 解构
reactive。解构后的值失去响应式,使用toRefs或改为ref。 ref忘记.value。模板自动解包,JS 中不会,这是迁移期最常见的错误。- 在 setup 之外调用生命周期钩子。
onMounted等必须在setup同步执行阶段注册,await之后注册会失效。 - 大对象使用深层响应式。图表配置、大列表数据可以用
shallowRef或markRaw,避免无意义的 Proxy 开销。 - 组合函数职责过宽。一个
usePage同时管请求、分页、弹窗和权限,最终会变成另一种 mixin。组合函数应该围绕单一能力。
小结
组合式 API 的核心不是 ref 和 reactive 的 API,而是以功能为单位组织代码。当逻辑能被抽成独立、可测试、无组件依赖的组合函数时,Vue 3 的收益才真正体现出来。