Vue3自定义Hooks实战:从逻辑复用到useRequest封装

发布时间:2026/10/20 8:13:57
Vue3自定义Hooks实战:从逻辑复用到useRequest封装 写业务代码的时候是不是经常遇到这种场景好几个组件都要处理“加载中、成功、报错、重试”这一套状态每个组件里都粘一遍loading、data、error的声明和逻辑又或者想把一段复用逻辑抽出去结果在mixins里因为命名冲突改了半天参数名最后代码反而更难看懂了我在用 Vue3 做项目一段时间后最大的感受就是自定义 Hooks也叫 composables把这类问题彻底解决掉了。它把一段可复用的业务逻辑连同响应式状态一起封装成一个普通函数组件里一行调用就能拿到所有状态和方法既不用关心内部实现也不会污染全局命名空间。这个内容对谁有价值只要你在写 Vue3 项目不管是刚接触组合式 API 的新手还是已经在用、但觉得自己的 Hooks 写得不顺手的老手这篇文章都值得你花十分钟看完。1. 为什么需要自定义 Hooks——从逻辑复用的演进说起1.1 mixins 时期的老大难先回头看 Vue2 时代最常用的复用方案mixins。它的思路是把可复用的选项对象“混入”组件里比如一个fetchListMixin负责提供list、loading、getList方法组件里mixins: [fetchListMixin]就能直接用。听起来很美实际用起来问题一堆。最典型的是命名冲突两个 mixin 都定义了data属性或者 mixin 方法名和组件内方法撞了Vue 的合并策略是组件优先但混入多个 mixin 时谁覆盖谁就要去翻源码逻辑。其次是指标不透明getList内部的依赖散落在 mixin 的data、methods、created各个选项里组件里看不出来它到底需要哪些外部条件改一处可能牵动另一个组件排查成本相当高。第三个痛点是复用状态来源不明多个组件共用同一个 mixin 实例是复制式复用状态互不相通想在两个组件之间共享一份数据光靠 mixin 是做不到的。社区也尝试过一些替代方案比如高阶组件HOC和 renderless 组件但前者嵌套地狱后者模板里包一层插槽代理心智负担没有真正降下来。这些方案本质上都在和“Vue2 的选项式 API 把逻辑按类型拆散”这件事较劲。1.2 组合式 API 的范式转移到了 Vue3组合式 API 给了一个完全不同的思路逻辑不再按data、methods、computed这种“选项类型”切分而是按“业务功能”组织。一段功能涉及响应式状态就把状态用ref创建涉及方法就定义普通函数涉及生命周期就在函数体里直接调用onMounted。最后把这些变量返回出去这一整段就是一个自洽的单元。用我习惯的说法mixin 是往组件里“塞东西”Hooks 是“借出去再收回来”。组件调用一个 Hook得到的是函数返回的那几个变量和方法内部到底怎么折腾、创建了多少个临时 ref都和外面没关系。就像借工具你用完了只关心手里有锤子和钉子不用管工具箱里其他杂物。1.3 Hooks 到底解决了哪几类真实问题我归纳下来自定义 Hooks 带来最直接的收益有三个第一跨组件逻辑复用。两个页面都要做“列表加载 筛选 分页”把这段逻辑收到usePaginationList里谁用谁调参数传接口地址和默认条件就行。第二状态共享与触发同步。配合全局状态或模块级变量可以让多个组件订阅同一份 Hook 返回的状态一个页面修改另一个页面自动更新。比如用户信息、通知数量用 Hooks 包装一层模块级 ref 就能实现轻量状态共享。第三代码可读性提升。把一个功能的所有相关代码集中到一个区域内不像 Options API 那样一个功能散落在三五个选项块里。我曾在一个 800 行的组件里把“图表加载、表单校验、搜索防抖、权限判断”等逻辑分别拆成四个 Hook组件代码骤减到 200 多行后续加需求时定位代码的速度快得多。2. 自定义 Hooks 的设计逻辑与核心原理2.1 Hook 的本质一个按约定命名的函数首先要明确一个关键概念自定义 Hook 在 Vue3 里没有任何特殊魔法它就是一个普通 JavaScript 函数唯一的约定是命名以use开头内部可以调用 Vue 提供的组合式 APIref、computed、watch、生命周期等。use前缀为什么重要它不只是社区共识对开发体验有实际意义编辑器检查工具如eslint-plugin-vue能识别出以use开头的函数是 Hook从而正确检查内部规则阅读代码时一眼能分辨哪些是普通工具函数、哪些内部可能持有响应式状态避免在非组件上下文里误用。Vue 官方文档也对 composables 做了明确定位用于封装有状态逻辑的函数。这句话要点在“有状态”——如果你的函数只是formatDate、deepClone这类纯工具函数不要叫useDateFormat直接叫formatDate就行否则反而误导代码阅读。2.2 响应式状态是怎么在函数内外流动的理解 Hook 内部状态流动要先理解两个细节。第一ref和reactive创建的响应式对象本质上是在函数作用域内创建的普通对象响应式系统通过代理或 getter/setter 机制拦截读写。Hook 里的const count ref(0)创建的是一个内部持有当前值的响应式引用返回给组件后组件模板里写count会自动解包脚本里要改值就count.value。第二组件里调用 Hook 时状态被“带回”组件的数据流中组件和 Hook 之间通过返回值建立连接。比如// useCounter.js import { ref } from vue export function useCounter(initialValue 0) { const count ref(initialValue) const increment () { count.value } const decrement () { count.value-- } return { count, increment, decrement } }组件里const { count, increment } useCounter(10)这个count和组件内部定义的ref没有区别模板中使用{{ count }}一样具有响应性。Hook 内部创建的状态变量在函数执行期间一直存活被返回后生命周期和组件绑定组件卸载后这些变量会被垃圾回收除非有外部引用。2.3 生命周期挂钩的正确打开方式自定义 Hook 里能不能用onMounted、onUnmounted可以但有个使用前提这些生命周期注册函数必须在组件的setup同步执行上下文中被调用。也就是说钩子函数内可以调用但调用链必须是从setup同步串下来的。举一个错误的例子// 错误示例 export function useMouse() { const x ref(0) if (someCondition.value) { onMounted(() { window.addEventListener(mousemove, handler) }) } return { x } }如果onMounted被放在条件分支里且这个分支在同步执行过程中没有被走到Vue 无法将生命周期注册到当前组件实例上会直接报错提示不在setup作用域内。正确的做法是把生命周期调用放在顶层用内部状态决定事件是否生效// 正确示例 export function useMouse() { const x ref(0) let handler onMounted(() { handler (e) { x.value e.clientX } window.addEventListener(mousemove, handler) }) onUnmounted(() { window.removeEventListener(mousemove, handler) }) return { x } }2.4 命名与返回值设计的工程规范这部分属于团队协作层面的软约束但直接决定 Hook 好不好用。返回值方面我强烈建议单值返回 ref多值返回对象。单个 ref 时const data useUserInfo()模板里直接当变量使用多值时用对象返回并解构const { data, load, reset } useUserInfo()。不要在返回对象里塞一堆只有内部才用的变量。命名方面用完整的语义化前缀如useUserList、useLoginState。如果几个 Hook 协同工作可以考虑一个总入口 Hook 统一返回内部按业务拆分。参数方面把可选配置统一收进 options 对象和主参数分开。这一点我会在后面的实战部分具体展开因为它直接影响 Hook 后续扩展是否方便。3. 实战从零封装一个真正可用的 useRequest3.1 需求拆解与参数设计光说原理不够动手写一个才过瘾。我挑了业务里最常用的场景异步请求封装。网上各种useRequest库很多但自己封装一遍你会彻底理解请求状态管理的常见矛盾和解决思路。先定义这个 Hook 要有哪些能力管理data、loading、error三个基础状态提供run方法手动触发请求支持组件挂载后自动请求请求期间避免重复触发组件卸载后不再更新状态避免内存泄漏警告处理竞态——多次触发时只保留最后一次请求的结果。基于这些参数设计如下/** * param {Function} requestFn 实际发起请求的函数必须返回 Promise * param {Object} options 配置项 * param {boolean} options.immediate 是否在组件挂载后立即执行 * param {boolean} options.manual true 时不自动请求等用户调用 run */requestFn放在第一位因为它是这个 Hook 的主体用户几乎每次都要传options 放第二位配置项会持续扩展收进对象里加字段不影响调用方。3.2 基础版实现状态管理与请求执行// useRequest.js import { ref, onMounted, onUnmounted } from vue export function useRequest(requestFn, options {}) { const { immediate true } options const data ref(null) const loading ref(false) const error ref(null) let isCancelled false const run async (...args) { // 上一次请求还没结束直接忽略避免重复触发 if (loading.value) return loading.value true error.value null isCancelled false try { const result await requestFn(...args) if (isCancelled) return data.value result } catch (e) { if (isCancelled) return error.value e } finally { if (!isCancelled) { loading.value false } } } onMounted(() { if (immediate) { run() } }) onUnmounted(() { isCancelled true }) return { data, loading, error, run } }这就是一个可用的基础版。用loading.value判断屏蔽重复请求用isCancelled标记卸载后丢弃响应避免那些初学者最容易踩的坑组件销毁后才 set 响应式状态。来看一个实际调用场景template div v-ifloading加载中.../div div v-else-iferror加载失败{{ error.message }}/div div v-else{{ data }}/div /template script setup import { useRequest } from /hooks/useRequest const { data, loading, error, run } useRequest( () fetch(/api/user/list).then(res res.json()) ) /script3.3 进阶设计依赖变化自动刷新很多列表页还有联动需求筛选条件变了要自动重新请求。基础版不支持这个我们把 options 扩展一个refreshDeps选项类型是ref数组当依赖值变化时自动重新执行。import { watch, isRef } from vue export function useRequest(requestFn, options {}) { const { immediate true, refreshDeps [] } options // ... 基础状态和 run 同上面 // 依赖变化时重新请求 if (refreshDeps.length 0) { watch(refreshDeps, () { run() }) } // ... 生命周期逻辑不变 }这里有个容易犯的错refreshDeps里传的是 ref 实例还是ref.value如果调用方写成refreshDeps: [searchForm]其中searchForm是reactive对象那么 watch 数组监听的是对象本身对象引用不变就不会触发。正确的做法是调用方传两个依赖const searchForm reactive({ keyword: , page: 1 }) const { run } useRequest(fetchList, { refreshDeps: [() searchForm.keyword, () searchForm.page] })用 getter 函数表达依赖watch内部会执行 getter 拿到值一旦值变化就重新请求。这种方式更准确也避开了深层响应式对象的引用陷阱。3.4 进阶设计竞态处理的正确姿势竞态是异步请求里最隐蔽的问题。用户快速点击两次“下一页”第一次请求慢、第二次请求快结果后发先至页面数据被第一次的旧请求覆盖。如果不处理竞态列表页可能出现“数据对不上当前筛选条件”这种诡异 bug。处理竞态的核心思想是每次请求生成一个自增请求编号或随机 token响应回来时检查编号是否还是最新的不是就丢弃。export function useRequest(requestFn, options {}) { // ... let requestId 0 const run async (...args) { const currentId requestId loading.value true error.value null try { const result await requestFn(...args) // 如果期间又有新的请求当前请求编号不是最新直接丢弃 if (currentId ! requestId) return data.value result } catch (e) { if (currentId ! requestId) return error.value e } finally { if (currentId requestId) { loading.value false } } } // ... }这种基于请求编号的方案比AbortController更通用。AbortController能真正常掉底层网络请求但有些场景比如 GraphQL 查询、WebSocket 消息、mock 数据不支持 abort而编号方案是纯逻辑层面的丢弃任何 Promise 都适用。当然两者可以结合支持 abort 时调用abortController.abort()释放连接不支持时至少保证状态不被污染。3.5 最终整合一个带防抖和轮询的完整版把上面这些能力整合起来加上防抖和轮询就是一个生产可用的useRequest了// useRequest.js import { ref, watch, onMounted, onUnmounted } from vue export function useRequest(requestFn, options {}) { const { immediate true, refreshDeps [], debounceWait 0, pollInterval 0 } options const data ref(null) const loading ref(false) const error ref(null) let requestId 0 let pollTimer null let debounceTimer null const run async (...args) { const currentId requestId loading.value true error.value null try { const result await requestFn(...args) if (currentId ! requestId) return data.value result } catch (e) { if (currentId ! requestId) return error.value e } finally { if (currentId requestId) { loading.value false } } } const debouncedRun (...args) { clearTimeout(debounceTimer) debounceTimer setTimeout(() run(...args), debounceWait) } const startPoll () { stopPoll() if (pollInterval 0) { pollTimer setInterval(run, pollInterval) } } const stopPoll () { clearInterval(pollTimer) pollTimer null } onMounted(() { if (immediate) { if (debounceWait 0) { debouncedRun() } else { run() } } startPoll() }) onUnmounted(() { requestId clearTimeout(debounceTimer) stopPoll() }) watch(refreshDeps, () { debounceWait 0 ? debouncedRun() : run() }) return { data, loading, error, run: debounceWait 0 ? debouncedRun : run, stopPoll } }这个版本已经能覆盖大多数业务场景了。我把防抖内置化是因为搜索场景太常见——用户在输入框输入关键词每次v-model变化都调run会很浪费请求加一个 300ms 防抖体验提升明显。轮询则适合做列表自动刷新、在线状态检测这类需求。实际项目中我通常只保留requestId 防抖轮询按需扩展免得用户不传pollInterval时白白挂一套定时器逻辑。开发这个 Hook 时的一个教训是不要在 onMounted 里重复调用 run否则和 watch 初值触发叠加起来会发出两次请求。如果 refreshDeps 的 watcher 在挂载后还要再触发一次的话建议在 watch 选项里加flush: post或者自己标一个isMounted标志位。3.6 非请求类 Hook 示例useMousePosition不是所有 Hook 都必须和请求相关。自己写小工具类 Hook 也很好用比如追踪鼠标位置// useMousePosition.js import { ref, onMounted, onUnmounted } from vue export function useMousePosition() { const x ref(0) const y ref(0) const update (e) { x.value e.clientX y.value e.clientY } onMounted(() { window.addEventListener(mousemove, update) }) onUnmounted(() { window.removeEventListener(mousemove, update) }) return { x, y } }这种 Hook 的特点是纯展示型不涉及异步一眼能看懂。它教了一个很重要的点Hooks 的通用设计模式是“创建状态 绑定副作用 清理副作用”所有复杂 Hook 都逃不出这个框架。4. 常见问题与排查技巧实录4.1 “onMounted 报错当前不在 setup 作用域内”很多人第一次写 Hook 会踩这个坑。原因是onMounted这类函数必须在setup同步执行期间注册异步调用链中调用就会报错。比如// 错误写法 export function useFoo() { setTimeout(() { onMounted(() { ... }) }, 100) }如果确实需要延迟注册要在同步阶段先准备好状态再在onMounted回调里做异步逻辑export function useFoo() { const loaded ref(false) onMounted(async () { await doSomething() loaded.value true }) return { loaded } }4.2 解构丢失响应性的坑reactive对象解构后属性会丢失响应性这是使用 Hook 返回值时最常见的疑难杂症。const { data } useRequest(fetchUserInfo) // data 是 ref解构没问题但如果你的 Hook 返回的是reactive对象// useFormState.js export function useFormState() { const form reactive({ username: , password: }) return { form } }组件里const { form } useFormState()没问题但一旦写成const { username } form或const { form } useFormState(); const username form.username解构出来的普通字符串就不再具有响应性。这时模板里数据变化不会更新。排查方法看到 Hook 内部返回reactive时宁可多写一层form.xxx也不要直接解构内层属性。4.3 ref 和 reactive 的选择策略我把自己摸索出来的判断准则分享一下简单值字符串、数字、布尔一律用ref对象/数组如果整体替换比较频繁用ref包一层替换时直接value newDataref内部会调用reactive处理对象性能没有额外损耗深层嵌套、属性频繁单独修改的数据结构用reactive更自然模板里直接form.name不需要.value。最忌讳的是在同一个 Hook 内部混用大量ref和reactive返回时还要为调用方决定每一个变量的解包方式徒增心智负担。我见过一个项目里useUser返回四个 ref 一个 reactive每次使用前都要想一下这不是好的设计。4.4 为什么 Hook 里共享状态会失效自定义 Hook 每次调用都会重新执行内部代码也就是说状态是相互独立的const { count: countA, increment: incA } useCounter() const { count: countB, increment: incB } useCounter()countA和countB是独立的值incA只改countA。如果希望两个组件共享同一份计数器状态需要把状态提升到 Hook 外部的模块作用域// useSharedCounter.js import { ref } from vue const sharedCount ref(0) export function useSharedCounter() { const increment () sharedCount.value const decrement () sharedCount.value-- return { count: sharedCount, increment, decrement } }这里有个副作用要提醒模块级 ref 会一直存在内存中页面刷新才重置。如果只是短时共享用完记得手动清空或者用其他方案如 Pinia管理真正需要全局共享的状态。4.5 调试技巧给 Hook 加日志与边界检测调试自定义 Hook 时我习惯在返回前挂一个console.log模版// 开发环境打印状态变化 if (import.meta.env.DEV) { watch([data, loading, error], () { console.log([useRequest] state updated:, { data: data.value }) }) }另外对入参做一层防御性校验也很有必要if (typeof requestFn ! function) { throw new Error([useRequest] requestFn must be a function) }这样做的好处不是啰嗦而是当 Hook 在项目里被几十个地方调用后如果哪次入参传错报错信息会直接指向 Hook 的入口而不是内部莫名其妙的运行时异常。排查过几次同事的 bug 后我现在每个正式 Hook 都会加这个校验能省下大量定位时间。5. 组织 Hooks 代码的团队协作建议5.1 Hooks 目录与命名约定如果项目里 Hooks 数量超过两三个建议单独建目录并按功能分类。我惯用的目录结构是src/hooks/ ├── useRequest.js ├── useMousePosition.js ├── useUserInfo.js └── ...文件命名推荐小驼峰因为会 import 进来直接使用保持和函数名一致方便搜索。如果 Hook 内部逻辑复杂、超过 200 行可以拆成一个小文件夹把私有辅助函数放进去公开入口只留下 index.js。5.2 测试与文档的最小成本方案Hooks 本质是函数测试起来比组件容易得多。用 Vitest 写一个最简单的响应式逻辑测试// useCounter.spec.js import { describe, it, expect } from vitest import { useCounter } from ../useCounter describe(useCounter, () { it(increment should add 1, () { const { count, increment } useCounter(0) expect(count.value).toBe(0) increment() expect(count.value).toBe(1) }) })注意测试环境需要挂载一个组件实例才能完整测试生命周期逻辑这时可以用vue/test-utils的mount一个最小测试组件来调用 Hook。文档方面每个 Hook 顶部写上大 JSDoc 注释说明参数、返回值和示例就能满足绝大多数协作需求。5.3 Hooks 重构的实操顺序把现有组件逻辑抽成 Hook 不是一蹴而就的事。我推荐从这两个低风险点入手先从重复代码最多、和 UI 无关的部分开始。比如多个组件都有的“弹窗开关 确定/取消回调”这种和界面解耦的纯逻辑最适合先抽。再从“包含完整生命周期副作用”的模块开始比如定时器、事件监听、滚动监听这些逻辑拆出来还能顺便释放资源减少内存泄漏。抽的时候遵循一个原则Hook 内不要直接操作 DOM 结构或依赖特定组件上下文尽量让它接受参数、返回状态和动作保持输入输出接口干净。这样以后迁移到其他组件或框架比如 React 的 hook 思路时也更容易。我在实际项目中踩过几次坑之后现在的流程基本固定为先分析重复逻辑拉出共同返回值设计参数和配置项再写实现和测试。顺序反过来最容易写出一个“看起来很通用但谁都用不好”的抽象。每次把一段脏乱差的老业务逻辑收进一个 Hook组件代码瞬间薄一截这种成就感会促使你把越来越多的逻辑往 Hook 方向整理。但也要提醒一句别为了 Hook 而 Hook。只有一段逻辑被两处以上复用或者逻辑确实太复杂需要隔离时拆分成 Hook 才有价值。一次性的简单逻辑写死在组件里反而更直白。最后分享一个我常用的定位方法当你觉得某个组件又长又乱、不知道从哪下手拆的时候拿一支笔把不相关的逻辑分别圈出来每一个圈就是一个潜在的 Hook 候选。从最小的圈开始动手拆完一个再拆下一个。等全部拆完你会发现组件的代码基本只剩模板和状态拼装那种清爽感就是 Hook 设计给你的回报。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询