Skip to content

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,否则依赖关系会变得隐式。

常见坑 ​

  1. 解构 reactive。解构后的值失去响应式,使用 toRefs 或改为 ref。
  2. ref 忘记 .value。模板自动解包,JS 中不会,这是迁移期最常见的错误。
  3. 在 setup 之外调用生命周期钩子。onMounted 等必须在 setup 同步执行阶段注册,await 之后注册会失效。
  4. 大对象使用深层响应式。图表配置、大列表数据可以用 shallowRef 或 markRaw,避免无意义的 Proxy 开销。
  5. 组合函数职责过宽。一个 usePage 同时管请求、分页、弹窗和权限,最终会变成另一种 mixin。组合函数应该围绕单一能力。

小结 ​

组合式 API 的核心不是 ref 和 reactive 的 API,而是以功能为单位组织代码。当逻辑能被抽成独立、可测试、无组件依赖的组合函数时,Vue 3 的收益才真正体现出来。

基于 VitePress 构建 · 持续更新中