Vue3正式发布之后,Composition API彻底改变了Vue项目的代码组织方式。很多开发者仅仅停留在ref、reactive、computed、watch语法调用层面,却没有真正理解组合式函数(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存在几个无法规避的硬伤:
- 命名冲突:多个mixin内部如果定义同名变量、同名方法,会发生隐式覆盖,没有编译报错提示,问题隐蔽,排查困难;
- 来源不透明:组件内使用mixin注入的属性与方法,阅读代码时无法直观分辨这个变量来自组件自身还是哪个mixin,必须跳转翻看mixin源码;
- 多mixin嵌套时依赖混乱,mixin之间无法显式传递参数,隐式依赖难以处理;
- 无法向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函数,内部调用ref、reactive、watch、onMounted等Vue3组合式API,返回响应式状态与业务方法,这个函数就是组合式函数。
<script setup>或者setup()函数内部调用,必须在组件执行setup生命周期阶段执行,才能绑定组件实例的生命周期上下文,不能在普通js文件顶层直接调用生命周期钩子。
和mixins最大区别:
- composables是函数,显式入参、显式返回值,所有状态、方法都是函数返回出来,来源一目了然;
- 不存在隐式合并,不会出现命名覆盖问题;
- 支持自由传参,轻松实现逻辑定制;
- TS类型天然友好,返回值可以完整推导出类型。
❌ Mixins 模式
- 隐式注入,来源不明
- 命名冲突无提示
- 无法传参定制
- 多实例状态共享
- TS 类型推导差
✅ Composables 模式
- 显式入参返回,来源清晰
- 解构命名,无冲突
- 自由传参,灵活定制
- 闭包隔离,状态独立
- TS 类型天然友好
03 · 实战案例:表单组件,Options API版本实现
我们以业务中最常见的用户信息提交表单作为示例,需求清单:
- 维护表单响应式数据:用户名、手机号、邮箱;
- 表单校验:非空校验、手机号格式校验、邮箱格式校验;
- 校验错误信息存储;
- 提交表单,调用模拟接口,提交过程loading状态;
- 重置表单,清空数据与错误提示;
- 页面挂载完成,模拟接口回显表单默认数据。
<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>
这份代码可以正常运行,但是我们观察:表单的数据、校验逻辑、提交、重置、回显逻辑,分散在data、mounted、methods多个地方。如果后续表单新增字段、新增校验规则,代码会继续扩散。当我们项目中另外一个页面也需要类似表单校验逻辑,Options API很难优雅抽离这套逻辑。
04 · Composition API改造:按业务关注点聚合代码
使用<script setup>语法糖,把同一套表单相关逻辑全部收拢到一起。不需要再按照data/methods/computed划分区块,同一个业务逻辑所有变量、函数、侦听全部写在一处。
<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>
对比可以直观看到,整个表单相关的状态、方法、生命周期钩子全部聚合在一起。阅读代码从上往下读,顺着业务流程阅读,不需要跳转到不同选项块。组件模板直接使用上面定义的变量和函数。
05 · 抽离composables组合式函数,实现逻辑复用
在项目src/composables/useUserForm.js,创建组合式函数useUserForm。把表单全部业务逻辑抽离出去,组件只保留模板和少量业务定制代码。
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 } }
组件中直接导入调用这个组合式函数,组件代码将变得非常轻薄:
<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最常见的设计模式。
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 } }
业务组件使用这个通用组合函数,只需要传入初始数据和校验规则,不需要重复写校验逻辑:
<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 const {data,run} = useRequest("/api/list") const {form,validate} = useForm(init,rules) const {scrollY} = useScroll()
7.2 输入输出显式,拒绝隐式副作用
- 参数全部通过函数入参传入,不要依赖外部全局变量;
- 状态、方法全部通过return返回,组件解构获取;
- 不要修改传入的外部对象,优先内部维护闭包响应式状态;
- 生命周期钩子写在composable内部,组件不需要关心内部生命周期细节。
7.3 清理副作用,内存泄漏规避
组合式函数内部如果有定时器、事件监听、window监听,一定要使用onUnmounted做销毁清理。组件卸载时,composable内部注册的副作用会自动执行销毁。
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。可以给入参、返回值定义接口,完整的类型推导,开发阶段就能发现类型错误。
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文件顶层调用onMounted、ref。组合式函数必须在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,只记住ref、reactiveAPI语法,却忽略底层思想。Composition API的本质,是把组件的逻辑,从"组件选项配置"解放出来,变成可以自由导入导出的普通JS函数。
Options API是配置式编程:你给Vue传入一份配置对象,Vue框架帮你组装业务逻辑。
Composition API是函数式编程:你自己编写业务逻辑,调用Vue提供的响应式能力,把逻辑封装成函数,在组件中组装。
这种模式带来巨大的工程收益:复杂业务关注点聚合、逻辑可复用、可测试。composables组合式函数可以单独抽离单元测试,不需要挂载组件实例,直接调用函数测试校验逻辑、请求逻辑。
当你掌握这套设计哲学,写Vue3代码就不再是写模板+写配置,而是把业务拆成一个个独立可复用的组合单元,再在组件里面拼装,最终实现代码高内聚、低耦合,代码更加优雅、健壮,长期迭代维护成本大幅降低。
Composition API 设计内核