☰
Vue3自定义Hooks实战:从逻辑复用到useRequest封装
2026/10/20 8:13:50 网站建设 项目流程

写业务代码的时候,是不是经常遇到这种场景:好几个组件都要处理“加载中、成功、报错、重试”这一套状态,每个组件里都粘一遍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 提供的组合式 API(ref、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. 实战:从零封装一个真正可用的 useRequest

3.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-if="loading">加载中...</div> <div v-else-if="error">加载失败:{{ 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()) ) </script>

3.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 = newData;ref内部会调用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 设计给你的回报。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询