首页 Vue3 Composition API
Vue3 Composition API Composables 实战

Vue3 Composition API实战:组合式函数的设计哲学

从Options API痛点出发,深度拆解composables复用模式,通过完整表单组件实战案例,讲透组合式函数的精髓

凡尘
2024年12月 约 25 分钟阅读 12.8k

Vue3正式发布之后,Composition API彻底改变了Vue项目的代码组织方式。很多开发者仅仅停留在refreactivecomputedwatch语法调用层面,却没有真正理解组合式函数(composables)背后的设计哲学。Options API是Vue2时代的经典范式,而Composition API不只是一套新语法,更是一套逻辑复用、关注点聚合的工程思想。本文将通过完整表单组件实战案例,对比Options API与Composition API的差异,深度拆解composables复用模式,从基础到进阶讲透组合式函数的精髓,帮助你写出更加优雅、高内聚、易维护的Vue3业务代码。

01 · 为什么需要Composition API?Options API的固有痛点

在Vue2的Options API时代,我们写组件会把代码按照选项分类:data存放响应式数据、methods存放业务方法、computed放计算属性、watch做侦听、created/mounted处理生命周期逻辑。这种面向选项分割代码的写法,入门门槛极低,新手很容易上手。但随着业务组件复杂度上涨,它的短板会被无限放大。

1.1 关注点分散,业务逻辑碎片化

假设我们开发一个复杂表单组件,组件需要实现:表单数据双向绑定、表单校验规则、提交表单请求、重置表单、加载回显接口数据、处理字段联动、监听路由回填表单、错误状态处理。使用Options API开发时,同一套业务逻辑的代码会被强行拆分到不同选项块中。

表单字段定义写在data(),校验函数写在methods,校验派生状态写在computed,接口回显写在mounted,路由监听写在watch。当业务迭代修改某一个表单逻辑时,你需要在多个代码块之间来回跳转翻阅,上下滚动文件查找关联代码。上千行的复杂组件,维护成本会急剧升高。同一个业务关注点被切割到文件各处,这就是Options API最核心的痛点

🔴 Options API 代码组织 — 同一业务逻辑被分散

data()

form, errors, loading

methods

validate, submit, reset, fetch

mounted / watch

fetchFormData, route watch

修改表单逻辑需要跨多个选项块跳转

1.2 逻辑复用的缺陷:mixins的先天问题

Vue2中实现组件逻辑复用主要依靠mixins。mixins存在几个无法规避的硬伤:

  1. 命名冲突:多个mixin内部如果定义同名变量、同名方法,会发生隐式覆盖,没有编译报错提示,问题隐蔽,排查困难;
  2. 来源不透明:组件内使用mixin注入的属性与方法,阅读代码时无法直观分辨这个变量来自组件自身还是哪个mixin,必须跳转翻看mixin源码;
  3. 多mixin嵌套时依赖混乱,mixin之间无法显式传递参数,隐式依赖难以处理;
  4. 无法向mixin传递自定义参数,想要定制逻辑必须封装高阶组件,代码臃肿。

高阶组件HOC也可以做逻辑复用,但会产生大量嵌套组件实例,造成组件树层级污染,调试的时候Vue Devtools会充斥大量包装组件,可读性很差。Vue2没有一套干净原生的逻辑复用方案。

1.3 类型推导困难

Options API对于TypeScript支持并不友好,this上下文类型推导存在诸多限制,复杂业务下类型提示经常失效,需要大量手动标注类型,不利于大型TS项目开发。

正是为了解决以上工程问题,Vue3推出Composition API。它不是用来完全替代Options API,二者可以共存;它的核心目标:把按选项分割的代码,转变为按业务关注点聚合的代码,同时提供一套干净的逻辑复用机制——组合式函数(composables)

02 · 组合式函数基础概念:什么是composables

组合式函数(Composable):利用Vue的组合式API封装可复用逻辑的普通函数,约定存放于项目composables/目录。

简单来说:一个普通JS函数,内部调用refreactivewatchonMounted等Vue3组合式API,返回响应式状态与业务方法,这个函数就是组合式函数。

⚠️ 重要约束:组合式函数只能在<script setup>或者setup()函数内部调用,必须在组件执行setup生命周期阶段执行,才能绑定组件实例的生命周期上下文,不能在普通js文件顶层直接调用生命周期钩子。

和mixins最大区别:

  • composables是函数,显式入参、显式返回值,所有状态、方法都是函数返回出来,来源一目了然;
  • 不存在隐式合并,不会出现命名覆盖问题;
  • 支持自由传参,轻松实现逻辑定制;
  • TS类型天然友好,返回值可以完整推导出类型。

❌ Mixins 模式

  • 隐式注入,来源不明
  • 命名冲突无提示
  • 无法传参定制
  • 多实例状态共享
  • TS 类型推导差

✅ Composables 模式

  • 显式入参返回,来源清晰
  • 解构命名,无冲突
  • 自由传参,灵活定制
  • 闭包隔离,状态独立
  • TS 类型天然友好

03 · 实战案例:表单组件,Options API版本实现

我们以业务中最常见的用户信息提交表单作为示例,需求清单:

  1. 维护表单响应式数据:用户名、手机号、邮箱;
  2. 表单校验:非空校验、手机号格式校验、邮箱格式校验;
  3. 校验错误信息存储;
  4. 提交表单,调用模拟接口,提交过程loading状态;
  5. 重置表单,清空数据与错误提示;
  6. 页面挂载完成,模拟接口回显表单默认数据。
UserForm.vue — Options API
<template>
  <div class="form-wrap">
    <div>
      <label>用户名</label>
      <input v-model="form.username"/>
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
    <!-- 手机号、邮箱类似省略 -->
    <button @click="handleSubmit" :disabled="loading">提交</button>
    <button @click="handleReset">重置</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      loading: false,
      form: { username: "", phone: "", email: "" },
      errors: { username: "", phone: "", email: "" }
    }
  },
  mounted() {
    this.fetchFormData()
  },
  methods: {
    async fetchFormData() {
      const mockRes = await new Promise(resolve => {
        setTimeout(()=>resolve({
          username:"测试用户",phone:"13800138000",email:"test@demo.com"
        }), 600)
      })
      this.form = mockRes
    },
    validateForm() {
      let valid = true
      this.errors = {username:"",phone:"",email:""}
      if(!this.form.username) {
        this.errors.username = "用户名不能为空"; valid = false
      }
      // ... 其他校验省略
      return valid
    },
    async handleSubmit() {
      if(!this.validateForm()) return
      this.loading = true
      try { await new Promise(r=>setTimeout(r,1000)); alert("提交成功") }
      finally { this.loading = false }
    },
    handleReset() {
      this.form = {username:"",phone:"",email:""}
      this.errors = {username:"",phone:"",email:""}
    }
  }
}
</script>

这份代码可以正常运行,但是我们观察:表单的数据、校验逻辑、提交、重置、回显逻辑,分散在datamountedmethods多个地方。如果后续表单新增字段、新增校验规则,代码会继续扩散。当我们项目中另外一个页面也需要类似表单校验逻辑,Options API很难优雅抽离这套逻辑。

04 · Composition API改造:按业务关注点聚合代码

使用<script setup>语法糖,把同一套表单相关逻辑全部收拢到一起。不需要再按照data/methods/computed划分区块,同一个业务逻辑所有变量、函数、侦听全部写在一处

UserForm.vue — script setup 组件内版本
<script setup>
import {ref,reactive,onMounted} from "vue"

// ==========表单整套业务逻辑全部聚合在此==========
const loading = ref(false)
const form = reactive({
  username:"",
  phone:"",
  email:""
})
const errors = reactive({
  username:"",
  phone:"",
  email:""
})

// 获取回显数据
async function fetchFormData() {
  const mockRes = await new Promise(resolve=>{
    setTimeout(()=>resolve({
      username:"测试用户",phone:"13800138000",email:"test@demo.com"
    }),600)
  })
  Object.assign(form, mockRes)
}

// 表单校验
function validateForm() {
  let valid = true
  Object.keys(errors).forEach(key=>errors[key]="")
  if(!form.username){
    errors.username = "用户名不能为空"; valid = false
  }
  if(!/^1[3-9]\d{9}$/.test(form.phone)){
    errors.phone = "手机号格式错误"; valid = false
  }
  if(!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(form.email)){
    errors.email = "邮箱格式错误"; valid = false
  }
  return valid
}

// 提交
async function handleSubmit() {
  if(!validateForm()) return
  loading.value = true
  try{
    await new Promise(resolve=>setTimeout(resolve,1000))
    alert("提交成功")
  }catch(e){ console.error(e) }
  finally{ loading.value = false }
}

// 重置
function handleReset() {
  form.username = ""; form.phone = ""; form.email = ""
  Object.keys(errors).forEach(key=>errors[key]="")
}

onMounted(()=>{
  fetchFormData()
})
</script>

对比可以直观看到,整个表单相关的状态、方法、生命周期钩子全部聚合在一起。阅读代码从上往下读,顺着业务流程阅读,不需要跳转到不同选项块。组件模板直接使用上面定义的变量和函数。

💡 但是目前所有逻辑还写在组件内部。如果项目多个页面都需要表单校验、提交、重置这套通用逻辑,我们就可以把整套逻辑抽离成独立组合式函数composable

05 · 抽离composables组合式函数,实现逻辑复用

在项目src/composables/useUserForm.js,创建组合式函数useUserForm。把表单全部业务逻辑抽离出去,组件只保留模板和少量业务定制代码。

src/composables/useUserForm.js
import {reactive,ref,onMounted} from "vue"

export function useUserForm() {
  // 内部响应式状态,闭包隔离
  const loading = ref(false)
  const form = reactive({
    username:"", phone:"", email:""
  })
  const errors = reactive({
    username:"", phone:"", email:""
  })

  async function fetchFormData() {
    const mockRes = await new Promise(resolve=>{
      setTimeout(()=>resolve({
        username:"测试用户",phone:"13800138000",email:"test@demo.com"
      }),600)
    })
    Object.assign(form, mockRes)
  }

  function validateForm() {
    let valid = true
    Object.keys(errors).forEach(key=>errors[key]="")
    if(!form.username){ errors.username="用户名不能为空"; valid=false }
    if(!/^1[3-9]\d{9}$/.test(form.phone)){ errors.phone="手机号格式错误"; valid=false }
    if(!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(form.email)){ errors.email="邮箱格式错误"; valid=false }
    return valid
  }

  async function handleSubmit() {
    if(!validateForm()) return false
    loading.value = true
    try{
      await new Promise(resolve=>setTimeout(resolve,1000))
      return true
    }catch(e){ console.error(e); return false }
    finally{ loading.value = false }
  }

  function handleReset() {
    form.username=""; form.phone=""; form.email=""
    Object.keys(errors).forEach(key=>errors[key]="")
  }

  onMounted(()=>{ fetchFormData() })

  // 对外暴露状态与方法
  return { loading, form, errors, validateForm, handleSubmit, handleReset, fetchFormData }
}

组件中直接导入调用这个组合式函数,组件代码将变得非常轻薄:

UserForm.vue — 使用组合式函数
<script setup>
import { useUserForm } from "@/composables/useUserForm"

// 调用组合函数,拿到所有状态与方法
const { loading, form, errors, handleSubmit, handleReset } = useUserForm()

// 组件层可以继续做业务定制,不污染composable内部逻辑
async function onSubmitClick() {
  const success = await handleSubmit()
  if(success){
    alert("表单提交完成,跳转页面")
  }
}
</script>

5.1 组合式函数闭包隔离特性

每一次调用useUserForm(),都会生成一套全新独立的响应式对象。在同一个组件多次调用,或者多个组件同时使用useUserForm,各个实例之间状态完全隔离,互不干扰。这是mixins做不到的。

闭包隔离示例
// 两套完全独立表单状态
const {form:formA} = useUserForm()
const {form:formB} = useUserForm()

// formA 和 formB 互不影响,各自独立维护响应式数据

06 · 进阶:可配置化的通用composable设计

上面useUserForm是强耦合业务字段的。我们继续升级,设计一个通用表单校验组合函数useForm,支持传入表单初始值、校验规则配置,实现高度可复用。这是实际项目中composables最常见的设计模式。

src/composables/useForm.js — 通用表单组合式函数
import {reactive,ref} from "vue"

/**
 * 通用表单组合式函数
 * @param {Object} initialValue 表单初始数据
 * @param {Object} rules 校验规则 {字段名: [{validator,message}]}
 * @returns
 */
export function useForm(initialValue, rules={}) {
  const form = reactive({...initialValue})
  const errors = reactive({})
  const loading = ref(false)

  // 执行单字段校验
  function validateField(fieldName) {
    if(!rules[fieldName]) return true
    const fieldRules = rules[fieldName]
    for(let rule of fieldRules){
      const pass = rule.validator(form[fieldName])
      if(!pass){
        errors[fieldName] = rule.message
        return false
      }
    }
    errors[fieldName] = ""
    return true
  }

  // 全表单校验
  function validate() {
    let allPass = true
    for(let key in rules){
      const ok = validateField(key)
      if(!ok) allPass = false
    }
    return allPass
  }

  // 重置表单
  function resetForm() {
    Object.assign(form, initialValue)
    Object.keys(errors).forEach(k=>errors[k]="")
  }

  return { form, errors, loading, validate, validateField, resetForm }
}

业务组件使用这个通用组合函数,只需要传入初始数据和校验规则,不需要重复写校验逻辑:

业务组件中使用 useForm
<script setup>
import { useForm } from "@/composables/useForm"

const {form,errors,loading,validate,resetForm} = useForm(
  { username:"", phone:"", email:"" },
  {
    username: [
      {validator:(val)=>!!val, message:"用户名不能为空"}
    ],
    phone: [
      {validator:(val)=>/^1[3-9]\d{9}$/.test(val), message:"手机号格式错误"}
    ],
    email: [
      {validator:(val)=>/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(val), message:"邮箱格式错误"}
    ]
  }
)

async function handleSubmit() {
  if(!validate()) return
  loading.value = true
  // 业务提交逻辑
  loading.value = false
}
</script>

这样,useForm可以被项目里所有表单组件复用。新增表单,只需要定义初始值与规则配置,极大减少重复样板代码。这就是组合式函数强大的复用能力。

07 · 组合式函数设计哲学与最佳实践

7.1 关注点聚合原则

一个composable只负责一件事。不要写大而全的巨型组合函数。比如把网络请求封装为useRequest,表单封装useForm,弹窗封装useModal,本地存储封装useLocalStorage,监听页面滚动useScroll。每个组合函数只解决一类问题。业务组件内部可以自由组合多个composable。

组件内组合多个composable
// 组件内部组合多个composable
const {data,run} = useRequest("/api/list")
const {form,validate} = useForm(init,rules)
const {scrollY} = useScroll()

7.2 输入输出显式,拒绝隐式副作用

  1. 参数全部通过函数入参传入,不要依赖外部全局变量;
  2. 状态、方法全部通过return返回,组件解构获取;
  3. 不要修改传入的外部对象,优先内部维护闭包响应式状态;
  4. 生命周期钩子写在composable内部,组件不需要关心内部生命周期细节。

7.3 清理副作用,内存泄漏规避

组合式函数内部如果有定时器、事件监听、window监听,一定要使用onUnmounted做销毁清理。组件卸载时,composable内部注册的副作用会自动执行销毁。

useScroll — 副作用清理示例
import {ref,onMounted,onUnmounted} from "vue"

export function useScroll() {
  const scrollY = ref(0)
  function handler() {
    scrollY.value = window.scrollY
  }
  onMounted(()=>window.addEventListener("scroll",handler))
  onUnmounted(()=>window.removeEventListener("scroll",handler))
  return {scrollY}
}

7.4 Composable不返回原始普通对象,优先返回响应式

组合函数内部使用ref/reactive维护状态,对外直接返回ref/reactive对象。组件解构拿到的依然是响应式引用。不要在composable内部把响应式对象解包成普通js对象,会丢失响应式。

7.5 和Options API共存

Composition API不是强制替代Options API。简单页面、简单组件,继续用Options API完全没问题。大型复杂业务组件,优先使用<script setup>+composables模式。二者可以混合,setup()函数可以和options选项共存。

7.6 TS类型加持

组合式函数天然适合TypeScript。可以给入参、返回值定义接口,完整的类型推导,开发阶段就能发现类型错误。

useForm.ts — TypeScript 类型加持
interface FormState {
  username: string
  phone: string
  email: string
}

export function useForm<T>(initialValue:T, rules:Record<string, Array<Rule>>) {
  // ...逻辑
  return {
    form: reactive({...initialValue}) as T,
    errors,
    validate,
    resetForm
  }
}

08 · Composition API对比Options API完整总结

维度 Options API Composition API (Composables)
代码组织方式 按选项分类(data/methods/computed) 按业务关注点聚合
逻辑复用 mixins/HOC,隐式注入,缺陷多 组合式函数,显式入参返回,闭包隔离
代码阅读 同一业务逻辑分散多处,长文件维护困难 一套业务逻辑代码收拢在一起,阅读流畅
类型支持 TS支持薄弱 原生友好,类型推导完整
实例隔离 mixin多实例容易互相干扰 每次调用生成独立闭包状态,天然隔离
上手门槛 低,适合新手入门 需要理解响应式、闭包,有一定学习成本

09 · 真实业务开发常见误区

误区一:把composable当成普通工具函数

在普通js文件顶层调用onMountedref。组合式函数必须在setup执行阶段调用,绑定组件实例上下文。

误区二:巨型composable

一个函数塞入太多无关业务,违背单一职责。应当拆分成多个小composable,组件中组合使用。

误区三:直接解构reactive对象丢失响应式

const {form}=useForm()没问题,但拿到form之后不要再次解构const {username}=form,会丢失响应式,应当使用toRefs

正确解构方式
import {toRefs} from "vue"
const {form} = useForm()

// 如果需要单独解构字段,使用 toRefs 保持响应式
const {username, phone} = toRefs(form)

误区四:在composable内部修改传入的外部响应式对象

造成外部状态意外变更,尽量内部维护自己的闭包状态。

误区五:忘记清理定时器、事件监听

造成内存泄漏,所有副作用必须配套onUnmounted销毁。

10 · 写在最后:组合式函数的设计内核

很多同学学习Vue3,只记住refreactiveAPI语法,却忽略底层思想。Composition API的本质,是把组件的逻辑,从"组件选项配置"解放出来,变成可以自由导入导出的普通JS函数

Options API是配置式编程:你给Vue传入一份配置对象,Vue框架帮你组装业务逻辑。

Composition API是函数式编程:你自己编写业务逻辑,调用Vue提供的响应式能力,把逻辑封装成函数,在组件中组装。

这种模式带来巨大的工程收益:复杂业务关注点聚合、逻辑可复用、可测试。composables组合式函数可以单独抽离单元测试,不需要挂载组件实例,直接调用函数测试校验逻辑、请求逻辑。

当你掌握这套设计哲学,写Vue3代码就不再是写模板+写配置,而是把业务拆成一个个独立可复用的组合单元,再在组件里面拼装,最终实现代码高内聚、低耦合,代码更加优雅、健壮,长期迭代维护成本大幅降低。

Composition API 设计内核

关注点聚合
逻辑可复用
可测试性
高内聚低耦合

凡尘

前端工程化实践者,专注 Vue 生态与组件设计模式