Hi,我是前端人类学!
Vue3 的组合式 API(Composition API)带来了代码组织方式的根本性变革。它真正解决了 Vue2 时代逻辑复用的两大顽疾——命名冲突和来源不透明,让组件代码从“按选项类型分散”转向“按功能关注点聚合”。
本文从实战出发,系统梳理hooks封装的核心原则、业务逻辑抽离方案及代码复用的完整策略。
文章目录
- 一、为什么需要组合式 API?—— 从 Options API 的痛点说起
- 二、hooks 封装的核心原则
- 2.1 命名规范与基本结构
- 2. 2 三大设计原则
- 2.3 响应式状态共享的陷阱与解法
- 三、hooks 封装实战:三个典型场景
- 场景一:数据请求 Hook(useFetch)
- 场景二:表单管理 Hook(useForm)
- 场景三:验证码倒计时 Hook(useCountDown)
- 四、业务逻辑抽离的分层策略
- 五、ref vs reactive:在 composable 中如何选择
- 六、与 Pinia 的边界:什么时候用 hooks,什么时候用 Pinia?
一、为什么需要组合式 API?—— 从 Options API 的痛点说起
在 Options API 中,一个功能的逻辑被拆散到data、methods、computed、watch等不同选项中。当组件代码超过 300 行时,理解某个功能需要在多个选项之间反复跳转,维护成本急剧上升。
// Options API 写法:同一功能的代码分散在四个选项中exportdefault{data(){return{searchKeyword:'',// 搜索功能searchResults:[]// 搜索功能}},methods:{asyncdoSearch(){/* 搜索功能 */}// 搜索功能},watch:{searchKeyword:'doSearch'// 搜索功能,但写在 watch 里},computed:{hasResults(){/* 搜索功能 */}// 搜索功能,但写在 computed 里}}这种分散让代码难以维护,也使得逻辑复用必须依赖 Mixins——但 Mixins 会带来命名冲突(多个 Mixins 定义同名属性)和来源不透明(难以追踪逻辑来自哪个 Mixin)的问题。
组合式 API 的解法很简单:把同一个功能的所有逻辑(状态、计算属性、方法、生命周期钩子)封装在一个函数里。
二、hooks 封装的核心原则
2.1 命名规范与基本结构
自定义 Hook 遵循use前缀的命名约定,这既是社区共识,也便于 IDE 和工具链识别。
// hooks/useCounter.tsimport{ref}from'vue'exportfunctionuseCounter(initialValue=0){constcount=ref(initialValue)functionincrement(){count.value++}functiondecrement(){count.value--}return{count,increment,decrement}}2. 2 三大设计原则
一个好的 composable 应该具备三个特征:
| 原则 | 说明 |
|---|---|
| 单一职责 | 每个 composable 只负责一个明确的功能领域 |
| 自包含 | 内部响应式数据和副作用自成闭环,不依赖外部环境 |
| 可测试 | 输入和输出通过参数和返回值明确表达 |
过度的拆分会导致 composable 之间的依赖关系复杂化,适度控制粒度比追求极致拆分更重要。
2.3 响应式状态共享的陷阱与解法
这是 hooks 封装中最容易被忽略的问题。每次调用 composable 函数都会创建全新的响应式状态,因此多个组件调用同一个 composable 时,拿到的不是同一份状态。
// ❌ 错误:每次调用都创建新的 countexportfunctionuseStore(){constcount=ref(0)return{count}}// ✅ 正确:将状态定义在函数外部,实现共享constcount=ref(0)exportfunctionuseStore(){return{count}}如果需要在多个组件间共享同一份状态,将响应式数据定义在 composable 函数外部即可。如果状态更复杂,可以考虑配合 Pinia 使用。
三、hooks 封装实战:三个典型场景
场景一:数据请求 Hook(useFetch)
数据请求是最常见的逻辑复用场景。一个设计良好的useFetch应该包含数据、加载状态、错误状态,并可选的请求取消能力。
// hooks/useFetch.tsimport{ref}from'vue'exportfunctionuseFetch<T>(url:string){constdata=ref<T|null>(null)constloading=ref(false)consterror=ref<Error|null>(null)constfetchData=async()=>{loading.value=trueerror.value=nulltry{constresponse=awaitfetch(url)if(!response.ok)thrownewError(`HTTP${response.status}`)data.value=awaitresponse.json()}catch(err){error.value=errinstanceofError?err:newError('未知错误')}finally{loading.value=false}}return{data,loading,error,fetchData}}组件中使用:
<script setup>import{useFetch}from'@/hooks/useFetch'const{data,loading,error,fetchData}=useFetch('/api/users')</script>进阶优化:在并发场景下,需要考虑请求竞态处理——使用AbortController取消过期请求,避免先发后至的请求覆盖最新数据。
场景二:表单管理 Hook(useForm)
表单 Hook 封装了表单状态、验证规则和提交逻辑,避免在每个表单组件中重复编写验证代码。
// hooks/useForm.tsimport{reactive,ref}from'vue'exportfunctionuseForm<TextendsRecord<string,any>>(initialValues:T,validate:(values:T)=>Partial<Record<keyofT,string>>){constvalues=reactive<T>({...initialValues})consterrors=ref<Partial<Record<keyofT,string>>>({})consthandleSubmit=(callback:(values:T)=>void)=>{constvalidationErrors=validate(values)errors.value=validationErrorsif(Object.keys(validationErrors).length===0){callback(values)}}return{values,errors,handleSubmit}}场景三:验证码倒计时 Hook(useCountDown)
封装具有副作用的 UI 逻辑,自动管理定时器的创建与销毁。
// hooks/useCountDown.tsimport{ref,onUnmounted}from'vue'exportfunctionuseCountDown(){constcount=ref(0)lettimer:ReturnType<typeofsetInterval>|null=nullconststart=(seconds:number,onFinish?:()=>void)=>{if(count.value>0)returncount.value=seconds timer=setInterval(()=>{count.value--if(count.value===0){clearInterval(timer!)timer=nullonFinish?.()}},1000)}onUnmounted(()=>{if(timer){clearInterval(timer)timer=null}})return{count,start}}四、业务逻辑抽离的分层策略
对于大型组件,可以引入分层设计思想:
| 层级 | 职责 | 示例 |
|---|---|---|
| 数据层 | API 调用、数据缓存、状态管理 | useUserApi、useCache |
| 交互层 | 表单校验、UI 状态切换、用户操作响应 | useForm、useModal |
| 业务规则层 | 跨组件的业务逻辑封装 | usePermission、useWorkflow |
组件本身退化为薄薄的组装层,只负责将各层 composable 的输出按模板结构排列。
五、ref vs reactive:在 composable 中如何选择
这是高频困惑,在 composable 返回值设计中尤为关键:
| 对比维度 | ref | reactive |
|---|---|---|
| 适用类型 | 基本类型 + 对象(需整体替换时) | 对象、数组 |
| 访问方式 | .value(JS中) | 直接属性访问 |
| 解构响应性 | 配合toRefs可保持 | 直接解构会丢失 |
| 整体替换 | ✅ 可以 | ❌ 不能直接重新赋值 |
推荐策略:composable 返回值优先暴露ref而非reactive对象——因为解构reactive对象会丢失响应性,而模板中对ref的解构不会有问题。
六、与 Pinia 的边界:什么时候用 hooks,什么时候用 Pinia?
hooks 和 Pinia 都可以封装状态和逻辑,但适用场景不同:
| 场景 | 推荐方案 |
|---|---|
| 单个组件内或父子组件间共享逻辑 | 自定义 Hook |
| 跨多个不相关组件共享状态 | Pinia |
| 需要持久化存储(localStorage) | 两者皆可,Pinia 有插件支持 |
| 页面级状态(路由参数派生) | Hook(配合useRoute) |
| 应用级全局状态(用户信息、主题) | Pinia |
如果一个 hooks 需要在多个组件间共享同一份状态,把状态定义在 hooks 函数外部即可实现轻量级共享,不一定需要引入 Pinia。
组合式 API 的最佳实践可以概括为:
- 按功能组织代码:将同一功能的状态、计算、方法、生命周期封装在一个 composable 中,而非分散在 options 中
- 遵循三大设计原则:单一职责、自包含、可测试
- 注意状态共享边界:多个组件调用同一 composable 时,默认拿到的是独立状态;如需共享,将状态定义在函数外部
- 返回值优先用 ref:避免解构 reactive 导致响应性丢失
- 分层抽离:数据层、交互层、业务规则层各司其职,组件只做组装
- 善用生命周期清理:在
onUnmounted中清除事件监听和定时器,避免内存泄漏
从 Options API 到组合式 API 的迁移不需要一步到位。推荐的策略是:在新组件中全面使用组合式 API 建立规范,对老组件在每次修改时逐步重构——修改哪部分功能,就把哪部分逻辑抽取为 composable。