1. 自定义Hooks到底在解决什么问题
先说个我自己的真实感受。我刚从 Vue2 切换到 Vue3 的时候,最大冲击不是setup语法,也不是ref和reactive的取舍,而是"逻辑复用"这件事有了全新的姿势——不靠 Mixin,不靠slot,靠的就是自定义 Hooks。
那年我在某公司带一个中后台项目,遇到一个特别典型的场景:列表页、详情页、表单页都需要"初始化加载数据 + 加载中状态 + 错误处理 + 刷新重试"。Vue2 时代的做法大家都懂,抽一个 Mixin,往组件里一混,完事。但用过的都知道,Mixin 这玩意儿看源码时简直是灾难,数据来源不明,方法冲突了不知道去哪查,多个 Mixin 叠加起来压根分不清谁是谁。
后来切到 Vue3,我尝试用组合式函数把这些逻辑抽出来——刚开始就是很朴素地写几个函数,把loading、data、error和请求动作一起 return 出去。用了两周之后我发现,这个思路的威力远不止"复用一份代码",它直接改变了组件代码的组织方式:组件里只剩下跟 UI 和交互最贴近的状态,业务数据流全被合理收编到了独立函数里。
这也解释了什么是自定义 Hooks。在 Vue3 里,所谓 Hooks 并不是什么新 API,它就是基于 Composition API 封装的一类普通函数,约定以use开头,内部可以管理自己的响应式状态、可以在onMounted、onUnmounted等生命周期钩子里挂逻辑、也可以继续调用别的 Hooks 做组合。它的本质就是"把一段有状态、有副作用的逻辑,封装成一个干干净净的函数"。
这篇文章我从头到尾讲清楚:自定义 Hooks 的设计思路、实现手法、常见坑和排查技巧。无论是刚开始学 Composition API 的新手,还是已经在项目里用了很久但想进一步规范的老手,应该都能捞出点对自己有用的东西。
2. 设计自定义Hooks前,先把这几件事想明白
2.1 Hooks 不是工具函数,别搞混了
很多人第一次尝试写自定义 Hooks 会犯一个特别常见的错误——把它当工具函数写。比如写一个formatDate、写一个calcTotalPrice,然后放到src/hooks/目录下。这其实没什么大问题,但严格来说,自定义 Hooks 要解决的核心问题是"带状态和副作用的逻辑复用",而工具函数解决的是"纯函数计算复用",两者定位不同。
判断标准其实就一条:这个函数内部有没有用到ref、reactive、computed、watch这些响应式 API?有没有注册生命周期钩子?只有这两类东西出现,才值得被归为 Hooks。
我之前带过一个同学,把一段纯计算逻辑封装成了usePriceCalculator,里面什么响应式都没用到,结果整个页面渲染后根本不会因为外部依赖变化而重新计算,他一度怀疑"Vue3 Hooks 有 bug"。其实不是,是因为他把它当 Hooks 用,但它根本没有响应式依赖。这种情况正确的做法就是老老实实写成普通函数,需要计算的时候调用一次就行。
话虽如此,还有一层很容易被忽略的:即使一个函数内部没有响应式状态,但它在设计上就是"随组件生命周期走的",那它也可以是 Hooks。比如一个技能冷却计时器,它内部用了ref保存剩余秒数,用setInterval做倒计时,还注册了onUnmounted清理定时器——这就是一个典型 Hooks。
2.2 返回值怎么设计,直接决定调用方的体验
这是自定义 Hooks 设计里最容易被低估的一件事。返回值形态设计得不好,调用方的代码会特别别扭。
我第一次写自定义 Hook 时,return 了一个大对象:
const { data, loading, error, refresh } = useListData()看起来没什么问题,但用了一段就会发现,调用方经常只关心其中一两个值,比如只在模板里用loading和data,结果整个对象都暴露到模板作用域了。而且组件里写const { data, loading, error, refresh } = useListData()这种解构,如果两个 Hooks 返回的同名字段,在组件里还得改名,超级烦。
后来参考社区里比较成熟的方案,我会按"值类型"设计返回形态:
- 单一状态:直接返回一个
ref,比如useToggle返回isOn和toggle函数,调用方想怎么用怎么用。 - 多状态组合:返回对象,但尽量让字段语义唯一,不要出现
data1、data2这种名字。 - 常用状态+操作函数:用对象把"状态集合"和"操作集合"分开,比如
useRequest,返回对象里明确分为state和actions。
这里有个关键细节:解构不会丢失响应性。前提是返回出去的值本身就是ref对象或者reactive对象。如果你在 Hook 内部创建了一个reactive对象,直接 return 出来,组件里用const state = useXxx()拿到的还是响应式的。但你如果把它整体解构了,比如:
const { userInfo } = useUser() // userInfo 如果是一个 proxy 对象,这样用没问题但如果你把reactive内部某个属性单独解构出来,比如:
const { name } = useUser() // name 是普通字符串,不再具备响应式这就踩坑了。这种情况的补救办法是用toRefs或者toRef在 Hook 内部把属性转成ref。我当时碰到的解决方案是:在设计 Hooks 时,能返回ref就返回ref,需要返回对象就用reactive + toRefs。
2.3 参数设计:从"一次性传参"到"响应式传参"
自定义 Hooks 的参数设计,我认为是区分"能用"和"好用"的分水岭。初学者最喜欢写这种:
function useDebounce(callback, delay) { // 内部直接把 delay 当固定值用 }问题在于,很多场景下delay并不是固定的。用户可能在界面里动态调整防抖时间,或者callback本身依赖了响应式状态。如果 Hook 内部拿到的参数不是ref也不是函数,就无法感知变化。
我在实际项目里后来一般这样处理参数:
- 值类型参数:在内部
watch监听,值变了就重新执行相关逻辑。 - 函数参数:建议让调用方传入函数,但内部用
ref把它包一层。为什么包一层?因为如果不包,外部传入的arrow function每次渲染都会生成新引用,内部如果用正则的依赖收集就会反复触发。包成ref之后,保证永远是同一个引用,再通过watch监听来读取"最新函数"。 - 响应式参数:直接支持传入
ref,内部用unref取当前值,再用watch监听变化。这是最灵活的形态,调用方既可以直接传字符串,也可以传ref字符串。
我看了不少开源库里useRequest的设计,基本都是这个思路:请求函数可以作为参数传进来,内部包一层保持引用稳定;配置项用对象形式传入,内部用解构加默认值合并。
2.4 一个 Hook 只干一件事
这个原则听起来像废话,但实际做起来很容易膨胀。尤其是"刚开始觉得这个 Hook 好方便,以后所有类似逻辑都往里塞"的时候。
我见过一个真实的历史教训:有个同事写了个usePageLifecycle,目标是统一管理页面的加载、权限校验、路由守卫、埋点上报、弹窗逻辑,整个函数四百多行,组件里调一行就全齐了。刚开始很爽,后来业务方说"不需要校验权限的判断",要改就得在 Hook 里加配置项,再后来产品说"币种单位要动态切换",又要加参数。最后这个 Hook 膨胀到没人敢动。
正确的拆法是:一个 Hook 只负责一段完整的、可独立描述的业务逻辑。权限校验是权限校验,数据加载是数据加载,埋点是埋点。要组合的时候,在外面再封装一个高阶 Hook,而不是在一个 Hook 里越滚越大。
我自己常用的拆分类似这样:
useFetchData:负责请求、loading、错误、刷新useAuthCheck:负责权限判断、跳转逻辑useTrackEvent:负责埋点上报usePageLifecycle:组合上面几个,但内部只是编排顺序,不写具体实现
这样每次业务调整,需要动的只是最外层的编排,底层的逻辑仍然稳定。
3. 手把手实现三个高频场景的自定义Hooks
3.1 第一个:useRequest,请求状态管理的终结者
请求状态管理是业务开发里最普遍的痛点。我之前在多个项目里看到的写法都是组件里手动声明loading、data、error,请求前后手动置位,重复且容易漏。
用自定义 Hook 封装后,组件代码简洁得惊人。先看最终用法:
const { data, loading, error, refresh } = useRequest(fetchUserList, { immediate: true, })实现这样一个 Hook 的核心,要解决三个问题:用响应式状态保存数据、初始化时是否自动执行、暴露刷新方法。我会这么写:
// useRequest.ts import { ref, watch, isRef } from 'vue' export function useRequest(requestFn, options = {}) { const { immediate = true } = options const data = ref(null) const loading = ref(false) const error = ref(null) // 保存请求函数,避免外部每次传入新引用导致内部重复触发 const fnRef = ref(requestFn) async function run(...args) { loading.value = true error.value = null try { data.value = await fnRef.value(...args) } catch (e) { error.value = e } finally { loading.value = false } } if (immediate) { run() } return { data, loading, error, refresh: run, } }这个版本能覆盖大多数基础场景。但到真正业务里还要考虑几个点:
- 竞态问题:用户快速点击翻页,前面请求先发出、后返回,结果把最后一次结果覆盖了。我一般在
run内部用一个requestId来标记每一次请求,返回时对比是否是最新的一次,如果不是就丢弃结果。 - 卸载后的状态更新:组件已经卸载了,请求结果还回来,这时给已卸载组件的
ref赋值虽然不会报错,但会有内存泄漏的隐患。可以在onUnmounted里把cancelled置为true,返回结果时主动判断。 - 延迟执行:很多场景
immediate=false时,还需要手动调用run,这个已经暴露出来了。
我实际在项目里的完整版大概这个样子(简化版):
export function useRequest(requestFn, { immediate = true, onSuccess, onError } = {}) { const data = ref(null) const loading = ref(false) const error = ref(null) const requestId = ref(0) const fnRef = ref(requestFn) async function run(...args) { const currentId = ++requestId.value loading.value = true error.value = null try { const res = await fnRef.value(...args) if (currentId === requestId.value) { data.value = res onSuccess && onSuccess(res) } } catch (e) { if (currentId === requestId.value) { error.value = e onError && onError(e) } } finally { if (currentId === requestId.value) { loading.value = false } } } if (immediate) { run() } return { data, loading, error, run } }这个 Hook 我用了很久,后来团队里的列表页全部统一到它上面,整体代码删掉了一大批重复的手动状态管理。
3.2 第二个:useLocalStorage,让数据持久化不再是散装代码
第二个推荐实现的是useLocalStorage,这玩意儿做主题切换、记住用户偏好、表单草稿保存都特别顺手。
需求很清晰:把localStorage当作外部数据源,组件里读到的状态和本地存储保持同步,修改状态时自动写入存储。实现思路是这样:
- 初始化时读
localStorage,有值就JSON.parse到ref - 写入时
watch这个ref,变化后JSON.stringify写回localStorage - 支持传入
key和默认值
代码:
import { ref, watch } from 'vue' export function useLocalStorage(key, defaultValue = null) { let initialValue try { const stored = localStorage.getItem(key) initialValue = stored === null ? defaultValue : JSON.parse(stored) } catch (e) { console.error(`读取 ${key} 失败,使用默认值`, e) initialValue = defaultValue } const state = ref(initialValue) watch(state, (newValue) => { try { localStorage.setItem(key, JSON.stringify(newValue)) } catch (e) { console.error(`写入 ${key} 失败`, e) } }) return state }用的时候特别舒服:
const themeMode = useLocalStorage('theme-mode', 'light') // 直接改值,刷新页面会恢复上次的选择 themeMode.value = 'dark'这里有几个细节特别值得注意:
JSON.parse可能失败。尤其是旧版本里存了非法 JSON,整个 Hook 直接报错会导致组件白屏。所以我加了try...catch,出错时兜底到默认值。watch默认不是深度的。如果defaultValue是对象,且调用方直接修改了对象内部某个属性(state.value.user.name = 'xxx'),这样不会触发watch。这种情况需要传{ deep: true }或者用不可变方式整体替换。- 跨标签页同步。多标签页场景下,一个标签页改了
localStorage,其他标签页不通知。如果业务确实需要,可以在 Hook 内部注册storage事件监听,再把变更同步到state。我之前做过一版支持这个能力,但后来发现大部分业务用不到,就做成可配置项了。
3.3 第三个:useDebounce,防抖逻辑的封装套路
防抖和节流是另一个高频场景。搜索框输入、窗口 resize、滚动加载这些,都是经典应用位置。
手动在组件里写防抖逻辑是真麻烦:要存定时器、要在卸载时清理,每个组件里写一遍毫无意义。用 Hook 封装一次,整个世界就清爽了:
import { customRef } from 'vue' export function useDebouncedRef(value, delay = 300) { let timer = null return customRef((track, trigger) => ({ get() { track() return value }, set(newValue) { clearTimeout(timer) timer = setTimeout(() => { value = newValue trigger() }, delay) }, })) }这里用的customRef是 Vue3 提供的一个专门用来做自定义 ref 控制的 API。核心逻辑是:读的时候调用track()收集依赖,写的时候不直接改值,而是等delay毫秒之后再改并trigger()通知视图更新。
注意一个坑:trigger()必须放在value = newValue赋值之后。如果先trigger再赋值,视图虽然更新了,读到的可能还是旧值,或者那次更新没把新值渲染出来,容易产生看起来很诡异的 bug。
实际项目里我还封装过对函数做防抖的版本:
export function useDebounceFn(fn, delay = 300) { const fnRef = ref(fn) let timer = null const debounced = (...args) => { clearTimeout(timer) timer = setTimeout(() => { fnRef.value(...args) }, delay) } // 组件卸载前清理定时器 const stop = () => clearTimeout(timer) return { debounced, stop } }为什么这里用ref再包一层fnRef?前面说过了——保证函数引用稳定,同时每次执行的时候能拿到最新的外部传入函数。如果不包这一层,组件重渲染后fn变了,debounced里闭包捕获的还是旧函数,就会调用到旧逻辑。
4. 我在项目里踩过的坑和排查技巧
4.1 坑一:解构 reactive 后响应式丢失
这个坑可以说是 Vue3 新手区踩踏率最高的一个。我自己刚切 Vue3 时也中过招。
我写了一个 Hook:
export function useUserInfo() { const state = reactive({ name: '', age: 0, }) async function init() { const res = await getUserInfo() state.name = res.name state.age = res.age } return { ...state, init } }组件里用时:
const { name, age, init } = useUserInfo() init()然后我发现:页面上的name永远不更新。原因很简单:...state把reactive对象展开时,name和age已经变成了普通字符串、数字,彻底脱离了代理。展开的那一刻,响应式连接就断了。
正确的写法有两种。第一种是返回state本身:
return { state, init }组件里用state.name访问。第二种是把内部属性转成ref返回:
import { reactive, toRefs } from 'vue' export function useUserInfo() { const state = reactive({ ... }) return { ...toRefs(state), init } }toRefs会把reactive的每个属性都转成ref,并且保证修改ref.value也会同步到原reactive对象上。这是官方提供的能力,用好了基本不会出错。
我的习惯是:如果 Hook 要给外部暴露多个状态,统一用toRefs返回。调用方想解构就解构,不担心丢响应性。这个习惯帮我避免了很多潜在坑。
4.2 坑二:在组件外部调用周期钩子导致报错
这个坑更多出现在"半瓶子水"阶段。一个同事写了个搜索历史 Hook,想在每次调用时自动记录,在 Hook 里写了onMounted(() => fetchHistory())。咋一看没毛病,但他是在普通工具函数里动态调用这个 Hook 的,于是控制台直接报了错。
Vue3 的生命周期钩子是通过全局内部状态追踪"当前组件实例"的。你只能在组件的setup同步执行期间调用onMounted、onUnmounted这些。如果在一个异步回调、事件处理函数、或者普通函数里调用,Vue 不知道当前"正在 setup 的组件"是谁,就会报错。
排查方法其实很直接:看到类似onMounted is called when there is no active component instance的报错,八成就这问题。解决方案是:如果一段逻辑并非组件挂载完才应该执行,不要硬套生命周期钩子。大部分场景其实用不着onMounted,Hooks 内部直接调用函数就行;如果需要清理副作用,就用onUnmounted,但仍然要确保在使用它的组件setup里被调用。
4.3 坑三:闭包陷阱导致的"状态永远不变"
这类问题比较隐蔽。我举个很典型的例子,假设我封装了一个倒计时 Hook:
export function useCountdown(seconds) { const count = ref(seconds) let timer = null function start() { timer = setInterval(() => { count.value-- }, 1000) } onUnmounted(() => clearInterval(timer)) return { count, start } }看起来没问题。但如果在setInterval里读某个外部变量,而外部变量在组件的重渲染后变了,定时器里读到的仍然是旧值,这就是经典的闭包陷阱。
解决思路一般有两种:
- 每次创建定时器时,重新读取最新值,但通过
ref的value访问,不要直接捕获变量。 - 或者把需要读取的动态值也包成
ref,在回调里通过xxxRef.value获取。
我在排查这种问题时,会在代码里找setInterval、setTimeout、addEventListener回调里有没有直接读外部普通变量的情况。有的话,十有八九就是坑。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| 解构后状态不更新 | reactive展开后丢失响应性 | 用toRefs返回属性 |
| 生命周期钩子报错 | 在组件 setup 之外调用 | 改成在 setup 里同步调用,或去掉钩子 |
| Hooks 内部监听不触发 | watch没开deep或监听源不是响应式 | 检查监听目标是否是ref/reactive/ 函数 |
| 定时器/事件在组件销毁后还在跑 | 忘了在onUnmounted里清理 | 补上清理逻辑,必要时用onScopeDispose |
| 回调里读到的都是旧值 | 闭包捕获了旧变量 | 用ref间接访问最新值 |
| 同一个 Hook 在多处使用但共享状态 | 状态定义在 Hook 外部 | 把状态都定义在 Hook 函数内部,每次调用独立创建 |
4.5 给排查者的几句实在话
排查自定义 Hooks 的问题,最好用的工具不是 debugger,而是"缩小范围"。先确认问题出在 Hook 内部还是调用方:在 Hook 返回之前console.log一下值,看响应式有没有断;再在组件里console.log一下接到的值,看接的时候有没有丢。两步一夹,问题基本能锁定。
如果 Hook 内部有异步逻辑,还要记得检查时序问题。比如"先发请求 A、再发请求 B,A 比 B 晚返回,导致显示旧数据",这类问题靠日志很难发现,我给 Hook 加自增 id 的做法在上面已经说过了,这也是我在实际项目里解决竞态问题的核心手段。
5. 自定义Hooks的状态作用域与生命周期管理
5.1 为什么每个组件调用 Hook 得到的都是独立状态
这是理解自定义 Hooks 的关键一环。很多人初学时会担心:一个 Hook 在多个组件里用,会不会互相串数据?
只要你的状态是"在 Hook 函数内部用ref/reactive创建的",那么每次组件调用时都会重新执行一遍函数,创建一套全新的状态。每个组件调用得到的都是独立副本,互不干扰。
但有一种例外:如果状态是定义在 Hook 外部的模块级变量,那所有调用它的组件共享同一份状态。这在某些场景是有意的,比如全局的用户登录态、全局的主题配置。但如果不小心把本不该共享的列表状态放到了外部,就会出现"A 页面改了数据,B 页面也跟着变了"的诡异体验。
我整理过一套自己的判断法则:默认都放函数内部,除非明确想全局共享,才挪到外部。这条法则帮我把共享状态带来的 bug 概率降到了最低。
5.2 用 getCurrentInstance / onScopeDispose 管理更复杂的副作用
绝大多数情况下,onUnmounted够用了。但有一种场景比较特殊:如果你在一个普通函数里调用某个包含副作用的 Hook,而这个 Hook 又不是直接在组件的setup里被调用,那onUnmounted没法正确注册。
这种情况下,Vue3 还提供了getCurrentInstance和onScopeDispose机制。onScopeDispose是组合式 API 的一个底层 API,它会在当前作用域销毁时触发清理逻辑。如果是组件作用域,卸载时触发;如果是某个effectScope,scope 停止时触发。
我在实现一些"在工具函数里动态创建裸 ref 并监听"的场景时,会用它来注册清理。比如某个全局列表页组件,在路由切换时频繁创建临时状态,我会用effectScope+onScopeDispose管理,确保切换后旧状态被释放。
这部分内容有点深,日常业务中接触不到也不影响开发。但如果你的代码库已经大量使用了自定义 Hooks,我建议系统性读一遍effectScope相关文档,它让你对"状态生命周期"有一个更完整的掌控力。
5.3 组合式函数的艺术:Hook 套 Hook
最后再说一个我自己觉得特别有意思的点:自定义 Hooks 是可以无限嵌套的。
也就是说,上层 Hook 可以调用下层 Hook,下层 Hook 又调用其他基础 Hook,形成一颗逻辑树。这不只是代码组织方式的改变,更是一种"服务化"的思维模式:把业务能力拆成一个个独立的服务,然后像搭积木一样组合。
我举一个实际例子。后台系统里"用户详情页"需要做这些事:加载用户信息、获取权限角色、记录访问日志、上报埋点。我分别实现useUserInfo、useUserRole、useVisitLog、useTrackEvent四个 Hook,然后在上层组织一个useUserDetailPage:
export function useUserDetailPage(userId) { const { userInfo, loading: userLoading } = useUserInfo(userId) const { roles } = useUserRole(userId) useVisitLog(userId) useTrackEvent('user-detail-view', { userId }) return { userInfo, roles, loading: userLoading, } }组件里只需要调一个 Hook,就能拿到整个页面的数据。而且由于每个基础 Hook 内部都自己管理了生命周期和清理逻辑,组合时完全不担心副作用泄漏。
这种"业务逻辑按能力拆分、按场景聚合"的方式,是我用过最舒服的 Vue3 逻辑组织模式,也是自定义 Hooks 最值的深挖的一个维度。
6. 自定义Hooks的工程化收尾
6.1 文件放在哪、命名怎么定,直接影响协作效率
自定义 Hooks 写多了之后,目录规划和命名约定就变成一个真实问题了。我见过的典型混乱场景是:Hooks 文件散落在各种utils、composables、hooks目录里,叫useXXX的有,叫xxxComposable的也有,还有直接叫xxx.js的。新同事接手时根本不知道去哪找,项目里重复逻辑越来越多。
我现在比较推荐的做法是:
- 目录统一用
src/composables或src/hooks。 - 一个文件只导出一个主要的 Hook,文件名用大驼峰或小驼峰都行,但钩子名称必须以
use开头,这是社区硬约定。 - 按业务域拆分子目录,比如
src/hooks/request/useRequest.ts、src/hooks/auth/useAuth.ts,不要全堆在一层。 - 基础型 Hooks(状态、环境、浏览器 API 相关)和业务型 Hooks 分开,基础型尽量不依赖业务模型,业务型才引用业务类型。
这套约定看起来简单,但对长期维护的帮助非常大。至少我后来接手别人的项目,看到这种目录结构会非常安心,知道业务逻辑大致在哪个位置,不用翻遍全项目瞎猜。
6.2 给自定义 Hooks 写测试,重点测状态流转
自定义 Hooks 是纯逻辑,非常适合单测。但很多人测试写得不对劲,光测返回值对不对,没测状态流转。
我自己的测试思路重点放在几个维度:
- 初始状态:首次调用后 loading、data、error 分别是什么。
- 状态流转:调用
run后,loading 是否为 true,请求成功后 data 是否正确,失败后 error 是否正确。 - 竞态处理:连续多次调用,最终保留的是最后一次结果。
- 副作用清理:模拟组件卸载后,定时器是否被清理、事件是否解绑。
Vue3 官方提供了@vue/test-utils之外的一个工具函数withSetup,专门用来在测试环境中运行组合式函数。如果团队没有现成的测试框架,我一般会用vitest + @vue/test-utils来测,覆盖上面四类用例基本就够日常使用了。
6.3 别重复造轮子,VueUse 里有非常多的现成实现
最后想说一个很多人容易忽略的点:社区已经有大量成熟的自定义 Hooks 库,其中最有名的就是 VueUse。它覆盖了浏览器 API、传感器、状态管理、网络请求、时间格式化、动画等一大堆能力。
我自己的原则是:先用 VueUse,缺的再自己写。这不是懒,而是因为 VueUse 里面很多细节(跨浏览器兼容、TypeScript 类型、边界场景)都已经打磨得很完善,自己从头实现很容易漏。
当然,自己动手写一遍仍然是很好的学习路径。我的建议是:先用 VueUse 解决业务问题,然后抽时间把它里面几个核心 Hooks 的源码读一遍,再自己动手来实现一遍,写完和官方版本对比一下。这个过程中收获的不仅是实现技巧,更多是对组合式 API 设计思路的深入理解。
7. 写在最后的几点体会
自定义 Hooks 用到现在,我最大的感受是:它不是一种"新语法",而是一种"思维方式"的转变。它逼迫你把平时散落在组件里的状态逻辑抽象出来,给它命名、给它边界、给它测试,让代码变成可以审查、可以演进、可以交接的东西。
有一个小技巧分享给刚开始尝试的人:不用追求一步到位设计一个功能完善的 Hooks,先从当前组件里最痛的一小段逻辑开始抽,抽出来之后在另一个组件里复用它,用着不顺再改参数、改返回结构。迭代几次之后,你会自然形成自己的设计手感。
另外,如果项目里已经有海量重复代码,也不要指望靠一次"重构日"全量替换。我习惯的做法是:新功能一律用 Hooks,旧代码在每次改动时顺手迁移一小块,慢慢把整个代码库推到新的组织方式上。这种方式风险低、可持续,而且每迁移完一块,代码的好读性提升都是肉眼可见的。
自定义 Hooks 的上限并不取决于 API 本身,而取决于你对自己业务的理解深度。把业务逻辑拆得足够干净,组合得足够灵活,这个工具能发挥出的能量会远超你的预期。