去年团队接到一个后台管理系统重构,光是搜索表格这类页面就有二十几个。某个版本上线前我随手搜了一下代码库里复制粘贴的列表请求逻辑,结果搜出来十几份几乎一模一样的loading、page、data状态管理代码。那时候团队里大多数人还在用 Options API 写业务,组件之间复用逻辑基本靠 mixin 和工具函数硬凑。后来我们逐步把 Vue3 组合式 API 用起来,核心抓手就是自定义 Hooks。这篇文章就把我这段时间在 Vue3 里封装和使用自定义 Hooks 的完整心得写出来,包括设计思路、完整代码、踩坑记录,以及怎么在团队里把 Hooks 这套东西落地。适合已经能熟练写 Vue3 基础、但还没系统整理过自定义 Hooks 的同学,也适合想在项目里推进组合式 API 复用的技术负责人参考。
1. 复制粘贴了五遍之后,我才开始理解自定义 Hooks
1.1 一个让人无语的代码复制现场
事情是这样的。我们的系统里有一个"用户管理"页面,功能非常标准:顶部搜索栏,中间表格,底部翻页器。大概长这样:
<template> <div> <el-form :inline="true"> <el-input v-model="query.keyword" placeholder="用户名" /> <el-button @click="handleSearch">搜索</el-button> <el-button @click="handleReset">重置</el-button> </el-form> <el-table :data="list" v-loading="loading"> <!-- 列配置 --> </el-table> <el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange" /> </div> </template>刚接手时我在这个文件里看到了这样一段代码:
const list = ref([]) const loading = ref(false) const page = ref(1) const pageSize = ref(10) const total = ref(0) const query = reactive({ keyword: '' }) async function getList() { loading.value = true try { const res = await fetchList({ page: page.value, pageSize: pageSize.value, ...query }) list.value = res.data.rows total.value = res.data.total } finally { loading.value = false } } function handleSearch() { page.value = 1 getList() } function handleReset() { query.keyword = '' page.value = 1 getList() } function handlePageChange(p) { page.value = p getList() } getList()坦白讲这段代码写得还算规矩。问题在于——类似这样的逻辑在系统里出现了十几次。每个页面都重新写一遍list、loading、page、pageSize、total,每个页面都写一遍getList、handleSearch、handleReset、handlePageChange。有同事图省事,直接把整个组件的 script 复制过去改两行完事。
复制粘贴的后果很快显现了:某次接口数据结构调整,后端把rows改成了records。我们全局搜字段名,光列表接口就改了八处,累得够呛。那段时间我就在想,能不能抽出一套东西,把这种"带响应式状态的逻辑"真正复用起来,而不是复用一段文本。
1.2 工具函数为什么救不了带状态的逻辑
很多人第一反应是:把请求逻辑抽成工具函数不就行了?
确实可以。我们当时的工具函数长这样:
export async function fetchUserList(params) { return http.get('/user/list', { params }) }这个工具函数能复用的是接口调用,但它解决不了组件里的loading、list、total这些响应式状态。因为这些状态和组件的生命周期绑定在一起,而且它们彼此之间是有联动关系的:请求开始要loading = true,请求结束要loading = false,拿到total要更新分页。你可以把每个函数包装成独立工具,但状态之间的联动逻辑没法靠工具函数表达。
换句话说,工具函数适合封装"输入-输出"明确的纯逻辑,比如日期格式化、数组去重。而带状态的 UI 逻辑,比如"加载中"、 "有没有数据"、"错误信息是什么",它们天然是响应式的,需要一套能和组件渲染绑定的机制来处理。
这就是自定义 Hooks 登场的背景。在 Vue3 里,它其实就是组合式 API 的函数化封装:把一组相关的响应式状态,连同修改这些状态的函数,打包进一个可复用的函数里。
1.3 mixin 的老账:来自 Options API 时代的前车之鉴
在 Vue2 时代,复用带状态逻辑的官方方案是 mixin。我见过不少项目把 mixin 用成了维护噩梦。举几个真实例子:
第一,命名冲突不可控。两个 mixin 都定义了一个getList方法,组件的methods里再定义一个,最终生效的是谁?规则是有的,但排查成本很高。尤其是当 mixin 传了好几层,你要去看 mixin 源码才知道数据是从哪来的。
第二,来源不清晰。模板里突然出现一个this.total,这是一个 data 字段、一个 computed、还是一个 mixin 里提供的?不翻代码根本不知道。如果 mixin 里改了某个 data 字段,只影响这个组件还好,但它要是间接改了别的共享对象,排查起来真是一场灾难。
第三,隐式共享状态。有些同学会在 mixin 外面定义共享对象给多个组件用,这比不用 mixin 还危险。组件 A 改了它,组件 B 的展示就变了,而这种跨组件的隐式通信几乎没有可追踪性。
我见过最夸张的一个 mixin 有八百多行,同时被十几个组件引用。那个项目最后没人敢动它,动了就牵连一片。自定义 Hooks 在一定程度上解决了这些问题,因为它的数据流是显式传递的:组件里明确知道调用了哪个 Hooks,也知道它返回了哪些状态。
2. 自定义 Hooks 的底层逻辑:组合式 API 的工程化输出
2.1 它本质上就是把"逻辑"和"模板"拆开
Vue3 的组合式 API 把 Options API 里按选项组织代码的方式,改成了按功能组织代码的方式。在 Options API 里,一个功能的"数据、计算属性、方法、watch "被拆散到data、computed、methods、watch各个选项里;而在组合式 API 里,它们可以放在一起。
// Options API 时代,一个搜索功能被拆成四块 export default { data() { return { list: [], loading: false, page: 1 } }, computed: { hasMore() { return this.list.length < this.total } }, methods: { async getList() { this.loading = true this.list = await fetchList() this.loading = false } }, watch: { keyword() { this.getList() } } }// 组合式 API 时代,同一块逻辑可以紧凑地放在一起 export function useList() { const list = ref([]) const loading = ref(false) const page = ref(1) async function getList() { loading.value = true list.value = await fetchList() loading.value = false } watch(page, getList) return { list, loading, page, getList } }自定义 Hooks 就是在这个基础上,进一步把"某一种业务能力"抽成独立函数。它的价值不在于写法变好看了,而在于跨组件复用的单位从"整个组件"缩小到了"一组逻辑"。
2.2 为什么 Vue 的 Hooks 比 React 更像"普通函数"
写过 React Hooks 的同学上手 Vue3 自定义 Hooks 时,会有一个非常强烈的感受:太自由了。
React 的useState、useEffect有严格的调用顺序要求,Hooks 不能写在条件语句里,不能在循环里调用,否则 hooks 链会错乱。这是 React 的实现机制决定的——它靠调用顺序来记住每个 state 属于哪个组件。
Vue3 没有这个限制。原因在于 Vue 的响应式系统是基于 Proxy 的代理式响应,每个ref、reactive返回的对象本身就是一个独立的响应式引用。你可以在任意嵌套、循环、条件语句里创建它们,没有执行顺序的约束。
// React 里这么写会炸 function Component({ flag }) { if (flag) { const [state, setState] = useState(0) // Error } }// Vue 里这么写完全没问题 function useTest(flag) { let state = null if (flag) { state = ref(0) } return state }这个差异让 Vue3 的自定义 Hooks 在实现上更像"普通函数组合",对开发者的心智负担更小。但也带来一个反面问题:正因为太自由,很多人会把 Hooks 当成随手抽出的普通工具函数,忽略了它作为"业务逻辑单元"的边界和职责。这个我在后面第 6 节展开说。
2.3 一个最小的 useScrollPosition 示例
先给一个最小的入门示例,让还没写过自定义 Hooks 的同学感受一下它的基本形态。
需求很简单:监听页面滚动,返回滚动位置。
import { ref, onMounted, onUnmounted } from 'vue' export function useScrollPosition() { const scrollX = ref(0) const scrollY = ref(0) function updatePosition() { scrollX.value = window.scrollX scrollY.value = window.scrollY } onMounted(() => { window.addEventListener('scroll', updatePosition) updatePosition() }) onUnmounted(() => { window.removeEventListener('scroll', updatePosition) }) return { scrollX, scrollY } }组件里这样用:
<script setup> import { useScrollPosition } from '@/hooks/useScrollPosition' const { scrollX, scrollY } = useScrollPosition() </script> <template> <div>当前位置:{{ scrollX }}, {{ scrollY }}</div> </template>这个例子虽然简单,但已经包含了自定义 Hooks 的全部核心要素:用ref创建响应式状态、用onMounted/onUnmounted管理副作用、返回状态给组件使用。组件里拿到的scrollX和scrollY是响应式的,模板里可以直接绑定。
3. 手写 useTable:搜索、分页、刷新一体的后台表格 Hooks
3.1 需求拆解:一个后台表格到底要什么状态
做后台管理系统的都知道,表格页面是最高频的场景。先把需求列清楚:
- 表格数据
list - 加载状态
loading - 分页信息:
page、pageSize、total - 查询条件
query - 刷新方法
refresh:重新拉当前页数据 - 搜索方法
search:重置页码到第 1 页再拉取 - 重置方法
reset:清空查询条件、重置页码、重新拉取 - 当前选中的行
selection(有的表格需要多选)
这些状态和方法是强关联的,非常适合封装成一个 Hooks。
3.2 先写一个能用版本
最开始不必追求通用,先把当前页面的逻辑抽出来,验证 Hooks 的形式是否可行。
import { ref, reactive } from 'vue' export function useTable(api, initialQuery = {}) { const list = ref([]) const loading = ref(false) const page = ref(1) const pageSize = ref(10) const total = ref(0) const query = reactive({ ...initialQuery }) async function loadList() { loading.value = true try { const res = await api({ page: page.value, pageSize: pageSize.value, ...query }) list.value = res.data.rows total.value = res.data.total } finally { loading.value = false } } function handleSearch() { page.value = 1 loadList() } function handleReset() { Object.keys(query).forEach((key) => { query[key] = undefined }) page.value = 1 loadList() } function handlePageChange(p) { page.value = p loadList() } loadList() return { list, loading, page, pageSize, total, query, refresh: loadList, search: handleSearch, reset: handleReset, handlePageChange } }组件里的使用方式:
<script setup> import { fetchUserList } from '@/api/user' import { useTable } from '@/hooks/useTable' const { list, loading, page, pageSize, total, query, search, reset, handlePageChange } = useTable(fetchUserList, { keyword: '' }) </script>这个版本能直接跑。它比复制粘贴好在哪里?第一,所有和列表请求相关的状态都集中在一个函数里;第二,下一个页面接过来就能用,不用重新声明loading、list、total这些样板状态。
3.3 再写一个好用版本:支持泛型和配置项
能用版本有个问题:res.data.rows和res.data.total是写死的字段名。不同后端返回结构可能不一样,有的接口直接返回数组,有的包了一层{ records, total }。所以好用的版本需要把"数据解析"交给调用方。
另外还要支持一个场景:有的页面不是用page/pageSize分页,而是直接用pageNum/pageSize。这些参数名差异如果写死在 Hooks 里,复用性会大打折扣。
于是我把版本升级了一下,用泛型约束返回的数据类型,用配置项传入分页参数名和解析函数:
<script setup lang="ts" generic="T"> import { ref, reactive, onMounted } from 'vue' interface UseTableConfig { api: (params: any) => Promise<any> params?: Record<string, any> pageField?: string pageSizeField?: string transform?: (res: any) => { list: T[]; total: number } </script>export function useTable<T>( config: { api: (params: any) => Promise<any> params?: Record<string, any> pageField?: string pageSizeField?: string transform?: (res: any) => { list: T[]; total: number } } ) { const { api, params = {}, pageField = 'page', pageSizeField = 'pageSize', transform = (res: any) => res.data } = config const list = ref<T[]>([]) as Ref<T[]> const loading = ref(false) const page = ref(1) const pageSize = ref(10) const total = ref(0) const query = reactive({ ...params }) async function loadList() { loading.value = true try { const res = await api({ [pageField]: page.value, [pageSizeField]: pageSize.value, ...query }) const { list: newList, total: newTotal } = transform(res) list.value = newList total.value = newTotal } finally { loading.value = false } } // ... 其余方法同上 return { list, loading, page, pageSize, total, query, refresh: loadList, search: handleSearch, reset: handleReset, handlePageChange } }这种写法有一个细节值得注意:transform函数的默认值设成(res: any) => res.data。因为很多后端接口在data字段里既包含了rows又包含了total:
{ "code": 200, "data": { "rows": [], "total": 0 } }如果你的后端长这样,直接传transform: (res) => ({ list: res.data.rows, total: res.data.total })就行。如果你的后端直接返回数组,transform就写(res) => ({ list: res, total: res.length })。
这段代码从实际项目里抽出来,我自己的体会是,Hooks 的配置项设计比 Hooks 本身更重要。因为配置项决定了这个 Hooks 能覆盖多少场景,也决定了别人用起来是否顺手。
4. useRequest 的封装与竞态处理:异步逻辑才是 Hooks 的主战场
4.1 useRequest 的基本形态
列表页的 Hooks 偏向业务,而useRequest是更通用的一层封装。它的目标是把任何异步请求的状态管理抽出来——不管你是请求列表、提交表单,还是删除一条数据,都能用。
基本形态:
import { ref } from 'vue' export function useRequest<T, P extends any[]>( fn: (...args: P) => Promise<T> ) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) async function run(...args: P) { loading.value = true error.value = null try { data.value = await fn(...args) return data.value } catch (e) { error.value = e as Error throw e } finally { loading.value = false } } return { data, loading, error, run } }注意这里run函数把fn的返回值透传出去,所以调用方既可以通过data读取结果,也可以通过await run()拿返回值。这个设计很关键,因为有的场景里你需要在请求完成后继续做别的操作(比如提交完表单关闭弹窗)。
4.2 竞态问题:为什么要用 AbortController
竞态是异步请求里最常见、却最容易被忽略的问题。我举一个真实的例子。
用户管理页面有一个搜索框,输入关键字后请求用户列表。用户在输入框里快速输入"张"、"张三"、"张三丰",触发了三个请求。这三个请求是并行发出的,但返回顺序不一定是发出顺序。如果"张"的请求最后返回,页面就会显示关键字为"张三丰"的搜索结果却渲染成"张"的数据。
响应拦截器能解决一部分问题,但更稳妥的做法是在请求层面取消过期请求。AbortController是浏览器原生 API,专门用来中断 fetch 请求。
import { ref, onUnmounted } from 'vue' export function useRequest<T, P extends any[]>( fn: (...args: P) => Promise<T>, { manual = false } = {} ) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) let abortController: AbortController | null = null async function run(...args: P) { if (abortController) { abortController.abort() } abortController = new AbortController() loading.value = true error.value = null try { const result = await fn(...args, abortController.signal) data.value = result return result } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { console.log('请求已取消') return undefined as any } error.value = e as Error throw e } finally { loading.value = false } } onUnmounted(() => { abortController?.abort() }) return { data, loading, error, run } }实际调用时,fn需要接受signal参数,并把它传给底层请求库。axios 的写法是:
async function fetchUserList(params, signal) { return http.get('/user/list', { params, signal }) }如果你的项目用的是 axios,它原生支持signal选项,也就是说,不需要改动接口封装,只需要在调用时把 signal 透传下去。
用了 AbortController 之后,快速切换搜索条件时,前一个请求会被中断,从根源上避免了状态错乱。如果在不支持AbortController的环境下,退而求其次,可以在 Hooks 里维护一个自增 id,每次请求前id + 1,返回时校验 id 是否匹配,不匹配就丢弃结果。原理是一样的——让一次请求的结果只能被"当次"请求消费。
4.3 参数变化时的处理:watch 与刷新
有些场景需要监听某些响应式值的变化,自动重新请求。比如上面提到的"用户列表根据路由参数变化自动刷新",或者"选择不同项目后下面联动列表"。
一个简单的做法是给useRequest增加一个onWatch选项,传入一个响应式源(可以是一个 ref、一个 getter 函数、或一个数组),Hooks 内部用watch监听并触发run。
import { watch, type WatchSource } from 'vue' export function useRequest<T, P extends any[]>( fn: (...args: P) => Promise<T>, options: { manual?: boolean watchSource?: WatchSource | WatchSource[] } = {} ) { // ... 前面的代码 if (options.watchSource) { watch(options.watchSource, () => { run() }) } return { data, loading, error, run } }注意:这里run()如果接受不定参数,那watch触发的调用就不传参数,只依赖响应式源的变化。这种写法适合请求参数完全由响应式状态驱动的场景。
还有一个细节:watch 默认是惰性的,不会在组件初始化时触发。如果你希望在初始化时就执行一次请求,可以手动调用run(),或者初始化时run()。这个可以根据业务需要自行决定。
5. Hooks 的组合玩法:把 useTable 和 useRequest 串成一套查询系统
5.1 组合是 Hooks 的灵魂:从单一功能到业务闭环
自定义 Hooks 真正的威力不在于单个 Hooks 多复杂,而在于多个 Hooks 可以自由组合,形成一个更上层的业务能力。
举一个真实项目的例子。后台系统里有一个"订单管理"页面,它的逻辑比普通列表页复杂:查询条件包含日期范围、订单状态、城市,还有"导出当前条件下的全部订单"这个按钮。
拆解一下这个页面需要的逻辑:
- 订单列表的分页查询(基础表格逻辑)
- 订单状态的选项配置(顶级常量/字典逻辑)
- 导出功能(需要当前查询条件,但和分页无关)
如果直接用useTable,导出功能会面临一个问题:useTable里的page和pageSize是当前分页,但导出要求导出的是当前条件下全部订单,不是当前页。
这时候有两种做法:
- 给
useTable增加一个getQueryParams()方法,导出时只取查询条件,不取分页参数。 - 把查询条件单独抽成一个
useQueryHooks,useTable和导出功能都依赖它。
我倾向于第二种做法,因为它更符合"单一职责"的拆分思路。来看一下组合出来的结构:
// useOrderQuery.ts export function useOrderQuery() { const query = reactive({ orderNo: '', status: '', city: '', dateRange: [] }) function reset() { Object.assign(query, { orderNo: '', status: '', city: '', dateRange: [] }) } return { query, reset } }// useOrderTable.ts export function useOrderTable(query) { const table = useTable( { api: fetchOrderList, params: query } ) return table }<script setup> const { query, reset: resetQuery } = useOrderQuery() const table = useOrderTable(query) async function handleExport() { const res = await exportOrder({ ...query }) // 触发下载 } function handleReset() { resetQuery() table.search() } </script>这样一拆,query可以被多个逻辑共享,reset的逻辑也统一了。页面里新增的导出按钮只需要依赖query即可,不需要关心表格的分页状态。
这种组合方式还有一个好处:测试方便。useOrderQuery是一个纯 Hooks,可以在单测里直接调用并断言reset()后的值。完全不需要 mount 组件。
5.2 几个我常用的通用 Hooks 清单
除了表格和请求,我在项目里还沉淀了一批通用 Hooks,按使用频率列一下。
useLocalStorage:把状态同步到 localStorage,刷新后自动恢复。
import { ref, watch } from 'vue' export function useLocalStorage(key, defaultValue) { const data = ref(JSON.parse(localStorage.getItem(key)) ?? defaultValue) watch(data, (val) => { localStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) return data }useEventListener:统一的事件监听 Hook,自动在卸载时移除。它背后的原理很简单,就是封装addEventListener和removeEventListener,但好处是组件里不用再手写onUnmounted了,减少遗漏。
useDebounce:输入框防抖。表单搜索里非常常用。
import { ref, watch } from 'vue' export function useDebounce(value, delay = 300) { const debouncedValue = ref(value.value) watch(value, (newValue) => { const timer = setTimeout(() => { debouncedValue.value = newValue }, delay) // 这里需要清理 timer,防止前一次未执行的 timer 修改最新值 // 注意:不能在 watch 回调里用 onUnmounted 清理,因为回调生命周期已结束 // 正确做法是用一个外层变量保存 timer,在 watch 回调开头 clearTimeout }) return debouncedValue }严格来说,上面的写法有 timer 清理问题。完整版本:
export function useDebounce(value, delay = 300) { const debouncedValue = ref(value.value) let timer = null watch(value, (newValue) => { if (timer) clearTimeout(timer) timer = setTimeout(() => { debouncedValue.value = newValue }, delay) }) return debouncedValue }usePermission:权限控制 Hook。根据当前用户权限判断按钮/路由是否可访问。
useECharts:封装 echarts 的初始化、resize、销毁,以及配置项更新。这个是个大块头,我单独写一篇文章都够了,这里只提一下思路:初始化图表实例,监听容器尺寸变化自动 resize,组件卸载时销毁实例,配置项用watch驱动更新。
这些 Hooks 单个看起来都不复杂,但在项目里的聚合效果很惊人。团队里一旦形成了"先看 Hooks 库再写页面"的习惯,新页面的开发速度会有明显提升。
6. 我在自定义 Hooks 上踩过的坑,每条都是真金白银
6.1 别在 Hooks 里返回非响应式对象
这个坑我踩得特别深。早期封装一个useTable时,我的返回语句长这样:
return { list: list.value, // 错误! loading: loading.value, page: page.value }结果组件里list永远是初始值。原因很简单:ref对象.value是一个普通值,把它直接返回,后续ref.value的更新无法传递到组件里。
正确做法是返回 ref 本身:
return { list, // ref 对象 loading, // ref 对象 page // ref 对象 }如果你是给普通 JS 调用方用的,可以返回readonly包装:readonly(list)。这样能防止外部直接改状态,统一通过 Hooks 暴露的方法更新。但在 Vue 组件模板里,ref对象会自动解包,直接返回list完全没问题。
还有一个反向操作:如果你在 Hooks 内部reactive创建了一个对象,返回时直接返回reactive对象本身,而不是toRefs展开后的某个单一属性。因为展开后实际上返回的是普通值,同样会丢失响应式。
6.2 watch 的清理:组件卸载后仍在执行
这个问题通常发生在异步请求的场景里。留意一下这个代码:
watch(keyword, async (newVal) => { const res = await fetchList({ keyword: newVal }) list.value = res })组件卸载后,如果keyword变化仍然触发这个 watch 回调,而且请求恰好返回了,就会给一个"已卸载组件"的状态赋值。常见后果是内存泄漏,最直接的表现是控制台出现 "Failed to execute 'removeChild' on 'Node'" 之类报错。
正确做法是在组件卸载时清理副作用:
import { watch, onUnmounted } from 'vue' const stopWatch = watch(keyword, async (newVal) => { const res = await fetchList({ keyword: newVal }) list.value = res }) onUnmounted(() => { stopWatch() })你可能觉得组件都卸载了,谁还会改keyword?但在真实项目里,路由切换、跨页面状态管理、被 keep-alive 缓存后重新激活,都会触发这类问题。尤其是如果 watch 的源是一个全局状态,组件卸载后全局状态一变化,watch 依然会执行。这是很隐蔽的坑,排查时需要格外留意。
6.3 过度抽象:Hooks 滥用反而降低可维护性
自定义 Hooks 不是写得越通用越好。我见过的最夸张的情况,是一个useTable的配置项多到十几个,每个页面调用时传一堆回调函数,最后代码的可读性比直接写还差。
我的经验是:Hooks 的抽象层级要和业务复杂度匹配。
- 如果只有一两个页面用,直接写业务逻辑,不需要抽 Hooks。
- 如果三四个页面用了,而且逻辑相同,抽出来能明显减少样板代码,可以做。
- 如果一个 Hooks 的配置项超过五六个,考虑拆分为更小粒度的 Hooks 组合,而不是把全部场景塞进一个大 Hooks 里。
比如useTable的基础版本只需要api和initialQuery两个参数。当某个页面需要"多选行"、"树形表格"、"行内编辑"这种特殊能力时,用组合而不是加参数的方式扩展:
// 独立的多选 Hooks,需要时再组合进来 export function useSelection() { const selectedRows = ref([]) function handleSelectionChange(rows) { selectedRows.value = rows } return { selectedRows, handleSelectionChange } }// 在组件里组合使用 const { list, loading, page, ... } = useTable(config) const { selectedRows, handleSelectionChange } = useSelection()这样比给useTable加一个enableSelection选项清晰得多,也不会污染其他没有多选需求的页面。
6.4 命名与语义的自我约束
自定义 Hooks 的命名看起来是小问题,但在团队协作里非常影响使用体验。我归纳了三条约束,坚持下来收益很大:
第一,必须以use开头。这是组合式 API 的社区约定。如果你的 Hooks 不以use开头,别的开发者在搜索时很难判断哪些函数是组件逻辑、哪些是纯工具函数。也用不上 IDE 的自动补全提示。
第二,返回值必须有明确语义。尽量返回一个对象,而不是数组。对象的好处是调用方能按需解构,不受顺序影响。数组解构虽然灵活,但几个值之间如果没有强顺序语义,很容易写错位置。
第三,一个 Hooks 只做一件事。如果一个 Hooks 同时管理了useTable和useForm的逻辑,请拆开。因为它们的复用场景不同——有的页面只有表格没有表单,有的只有表单没有表格。混在一起,会让两个场景都别扭。
6.5 关于 vue3 用 ts 还是 js 的一个建议
热词里有人问 "vue3 用 ts 好还是 js 好",我结合 Hooks 开发的体会说两句。如果你主要在写业务页面,js 确实上手更快;但一旦开始封装 Hooks,泛型带来的类型推导优势会非常明显。上面的useTable<T>、useRequest<T, P>如果把泛型去掉,调用方拿到的data是any,编辑器给不了任何提示,很容易写错字段名。
所以我的建议是:如果你打算认真做 Hooks 复用,用 TypeScript。类型不光是给编译器看的,更是给下一个接手你代码的同事看的。
7. 把 Hooks 落地到团队:从一个人的习惯到一套约定
7.1 先找落地场景,不要全面铺开
自定义 Hooks 这件事,最难的不是写,而是让团队养成"先找 Hooks 再写页面"的习惯。我的经验是,不要搞"全员统一切换到 Hooks"这种大动作,而是先选一两个高频场景做样板。
后台管理系统里最高频的场景就是"表格页"。花两周时间把useTable、useRequest、useSelection这几个基础 Hooks 沉淀出来,选一个典型页面做完整改造。然后把改造前后的代码放在团队分享会上对比,让大家直观感受"少写了几十行样板代码"是种什么体验。
7.2 沉淀 Hooks 库,但要克制
当 Hooks 数量多了以后,可以在项目里建src/hooks目录。按功能拆分文件:
src/hooks/ ├── useTable.ts ├── useRequest.ts ├── useSelection.ts ├── useDebounce.ts ├── useEventListener.ts ├── useLocalStorage.ts ├── usePermission.ts └── useECharts.ts但要注意,每个 Hooks 都要有明确的适用场景和使用示例。我在项目里定的规矩是:Hooks 文档必须写清楚"解决的问题"、"参数说明"、"返回值说明"、和"一个最小示例"。写不出来的不建议放进去,宁可放在临时目录里验证一段时间。
7.3 为什么我仍然建议团队从自定义 Hooks 开始而不是直接上状态管理库
最后说一个团队协作中经常被问到的问题:都用 Hooks 了,还需要 Vuex / Pinia 吗?
我的看法是,它们解决的不是同一类问题。Pinia 适合管理跨组件的全局状态,比如用户信息、权限列表、主题配置。自定义 Hooks 适合管理组件内部的复杂逻辑,尤其是那些只和当前页面相关、却需要复用同一套逻辑的场景。
在实践中,我和团队的协作习惯是:先判断这个状态是"页面级"还是"全局级"。页面级的,用 Hooks 封装;全局级的,放 Pinia 管理。这个边界划清楚之后,状态管理从来不会打架,Hooks 库也越来越厚,维护成本反而下来了。
8. 趁手的调试方式与小技巧
开发 Hooks 期间有一些小技巧,能明显提升效率。最后一个部分顺手分享一下。
技巧一:用unref兼容 ref 和普通值
写 Hooks 时,尽量让参数既支持 ref 也支持普通值。Vue3 里unref就是干这件事的:
import { unref } from 'vue' function useNumber(value: number | Ref<number>) { const num = unref(value) // 如果是 ref 就取 .value,否则直接返回 }这样调用方传ref(5)也行,传5也行。很多高级 Hooks 的 API 设计都用了这个模式。
技巧二:在 template 里直接调试 Hooks 返回值
开发时如果想知道某个 Hooks 返回了哪些字段,可以在模板里临时加一段:
<pre>{{ JSON.stringify(useTable(...), null, 2) }}</pre>不过在 script setup 里不能这样直接用,普通组件的 setup 里可以。更简单的做法是直接在onMounted里console.log。
技巧三:用 Vite 的 alias 缩短导入路径
项目里用 Vite 的话,强烈建议配一个@指向src,这样导入 Hooks 的路径就非常简洁:
import { useTable } from '@/hooks/useTable'这个小配置能极大提升日常开发中的幸福感,尤其是 Hooks 嵌套组合的时候,路径短一点点,写起来都顺手得多。
回到开头那个复制粘贴了五遍的项目。重构之后,我们把所有列表页都改用useTable,新增页面时只写接口定义和列配置,几百行的组件缩到了几十行,改接口字段只需要改一个地方。团队里新来的同学第一次看useTable的时候还有点懵,我跟他讲了一句:以后写页面别急着复制,先想想这个逻辑是不是已经有人封装过了。这大概就是自定义 Hooks 最有价值的时刻——它让复用不再靠记忆和搜索,而是靠一套抽象好的 API 约定。如果你正在负责一个中后台项目,不妨找一个高频页面,从抽一个最小的useTable开始试试。