最近帮一个朋友的 Vue3 后台管理系统做代码走查,打开src/composables目录,一眼扫过去,三十多个use开头的文件齐刷刷列在那,useUser.ts、useRequest.ts、useTable.ts、useDict.ts……我说这不就是典型 Vue3 项目的“标配全家桶”吗。很多刚接触Composables的人会好奇,这些文件名到底是怎么来的,每个文件里到底装了什么,为什么大家的项目里翻来覆去就是这一批名字。
这篇文章就把我在多个 Vue3 项目(后台管理、商城、毕业设计、内部中台)里反复看到、也亲手写过的常见 composable 文件名整理一遍。不吹概念,直接讲每个文件是干什么的、里面通常长什么样、为什么值得单独抽成一个use文件。希望你看完既能对着名字说清楚用途,也能直接照着把项目里的公共逻辑拆出来。
1. 为什么打开每个 Vue3 项目,都会看见一堆 use 开头的文件
1.1 Composables 到底解决了什么问题
Composables 这个词在 Vue3 里其实没有特别玄乎:它就是“把一段带响应式状态的逻辑封装成一个函数,函数名以use开头”。和 Vue2 时代的mixin相比,最大区别是它不需要依赖组件实例,你能在普通 TS/JS 模块里直接调用ref、computed、watch,然后在组件里像调普通函数一样去消费它。
我之前维护过一个老后台项目,一个表格页里既有分页,又有搜索表单,又要处理导出、又要控制弹窗开关,所有逻辑全部堆在setup里,最后组件代码直接飙到八百行。后来我把里面的逻辑拆成useTable、useForm、useDialog三个 composable 文件,组件代码降到一百多行,新同事接手时看文件名就能猜到大概逻辑位置——这就是文件名列表的意义:它是项目里逻辑分布的地图。
1.2 常见目录结构与必备文件名清单
大多数 Vue3 项目会单独建一个src/composables目录(也有人习惯叫src/hooks,意思相同),里面每个文件对应一个可复用逻辑。我整理了一份在我见过的项目里出现频率最高的清单:
| 文件名 | 典型用途 | 一句话本质 |
|---|---|---|
useRequest.ts | 统一管理异步请求和 loading | 把axios/fetch包成响应式方案 |
useTable.ts | 表格页的数据加载、分页、筛选 | 业务层封装的“表格控制器” |
useForm.ts | 表单的初始化、校验、提交 | 表单状态与提交逻辑的集合 |
usePagination.ts | 维护页码、页大小、总数 | 通用分页状态机 |
useDict.ts | 字典数据获取与映射 | 后台系统特别常用的标签翻译 |
useToggle.ts | 布尔开关切换 | 弹窗、折叠面板、菜单状态 |
useCounter.ts | 数字增减与重置 | 计数器状态管理 |
useDebounce.ts | 防抖函数 | 把高频触发变低频执行 |
useThrottle.ts | 节流函数 | 限制固定时间内的执行次数 |
useEventListener.ts | DOM 事件监听 | 自动addEventListener与移除 |
useClickOutside.ts | 点击元素外部触发回调 | 弹窗/下拉的点击关闭 |
useResize.ts | 监听容器或窗口尺寸 | 响应式布局的基础 |
useBreakpoint.ts | 获取当前断点 | 根据视口宽度返回 xs/sm/md/lg |
useStorage.ts | localStorage/sessionStorage 响应式封装 | 持久化状态 |
useRouteQuery.ts | 同步路由 query 与本地状态 | 分享链接时保留筛选条件 |
usePageLeave.ts | 监听页面离开/隐藏 | 统计、草稿保存、埋点 |
useAuth.ts | 用户权限判断 | 按钮级/路由级权限控制 |
这些文件名之所以反复出现,不是大家互相抄,而是因为它们背后都对应着几乎每个业务项目都会碰到的场景:请求要 loading、表格要分页、弹窗要开关、输入要防抖、广告位要节流、DOM 要监听、权限要判断。谁的项目都绕不开这些事,于是谁的项目里都会有这些文件。
1.3 这些重复出现的命名是怎么形成的
use前缀来自 Vue3 官方组合式函数命名约定,目的是让人一眼看出“这是个 composable,里面可能有响应式状态”。后面跟的名字基本遵循“动词优先”或“领域名词优先”两种风格:useDebounce是以行为命名,useUser是以领域对象命名。实际项目中我更推荐前者多用于工具函数,后者多用于业务逻辑,这样目录里看起来不要混成一片,至少能靠前缀区分“通用工具型”和“业务模型型”。
2. 站在业务顶层的三类高频组合:请求、路由与全局状态
2.1 useRequest / useFetch:把接口调用变成一句话的事
后台管理系统里最常见的动作就是发请求。如果每个页面都用axios.get().then().catch()写一遍,loading 状态、错误提示、重复提交控制这些代码会散落一地。useRequest就是把这件事收口的文件。
一个最小实现大概是这样:
import { ref, shallowRef } from 'vue' export function useRequest<T>(fn: (...args: any[]) => Promise<T>) { const loading = ref(false) const error = shallowRef<Error | null>(null) const data = shallowRef<T | null>(null) async function run(...args: any[]) { loading.value = true error.value = null try { data.value = await fn(...args) return data.value } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) throw error.value } finally { loading.value = false } } return { loading, error, data, run } }然后页面里就能这样用:
const { loading, data, run } = useRequest(fetchUserList) onMounted(() => { run({ page: 1, size: 20 }) })别看代码不多,它真正解决的问题是“请求状态与 UI 绑定”。loading 变成响应式之后,模板里v-loading="loading"直接绑定即可。后续你还可以在里面加缓存、加轮询、加取消请求,都是在这个壳子上扩展。
2.2 useToggle / useCounter / useBool:用 20 行代码干掉页面里 80% 的开关状态
我走查代码时最常看到的一种重复是:
const dialogVisible = ref(false) const openDialog = () => { dialogVisible.value = true } const closeDialog = () => { dialogVisible.value = false }一个页面里有五六个弹窗,就要写五六套这种代码。抽成useToggle之后是这样的:
export function useToggle(initial = false) { const state = ref(initial) const toggle = () => { state.value = !state.value } const setTrue = () => { state.value = true } const setFalse = () => { state.value = false } const set = (v: boolean) => { state.value = v } return { state, toggle, setTrue, setFalse, set } }这个文件里的逻辑极其简单,但收益非常大。组件里写const { state: dialogVisible, setTrue: openDialog } = useToggle(),语义清晰,命名还能在解构时自定义。useCounter同理,加减、重置、步长控制都是同一个套路,特别适合购物车数量、分页页码这类数字状态。
2.3 useRouteQuery 与 usePageLeave:把路由和页面离开勾住
有搜索条件的列表页,通常希望刷新、分享链接后筛选条件不丢。最自然的做法是把条件塞进 URL query,但每次手动router.push和route.query来回同步非常繁琐。useRouteQuery做的事情就是帮你维护这个同步:
import { ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' export function useRouteQuery(key: string, defaultValue: string = '') { const route = useRoute() const router = useRouter() const value = ref((route.query[key] as string) ?? defaultValue) watch(value, (v) => { router.replace({ query: { ...route.query, [key]: v || undefined } }) }) return { value } }类似的,电商商城和在线文档项目很常用usePageLeave。浏览器切后台、关标签页时触发草稿保存或埋点上报。逻辑本身不复杂,但结合visibilitychange和pagehide事件时,很多人会忘了解除监听,封装成 composable 后组件卸载时统一清理,这个坑就填上了。
2.4 useStorage / useLocalStorage:持久化的一层薄封装
Vue3 官方文档有一个useLocalStorage的示例,思路是用ref包一层,初始化时读 storage,写入时同步写回。实际项目里我一般会在此基础上加一层序列化处理,让它可以存对象:
import { ref, watch } from 'vue' export function useStorage<T>(key: string, initialValue: T) { const raw = localStorage.getItem(key) const state = ref<T>(raw ? JSON.parse(raw) : initialValue) watch(state, (val) => { localStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) return state }这里要注意的两个坑:第一,JSON.parse可能抛异常,建议加try/catch,否则脏数据会让整个应用崩掉;第二,watch默认不是深监听,存对象时必须写{ deep: true },不然对象内部的属性变了页面不刷新,但 localStorage 里也没更新,这个 bug 排查起来非常隐蔽。
3. 站在交互底层的常用文件:防抖、点击外部、尺寸与监听
3.1 useDebounce / useThrottle:同一个 timer 逻辑的两种姿态
搜索框输入、窗口 resize、滚动事件,都是高频触发场景。useDebounce和useThrottle是最容易搞混的一对,我习惯用一句话区分:useDebounce是“最后一次说了算”,useThrottle是“固定间隔内只说一次”。
防抖的经典实现:
import { ref, watch } from 'vue' export function useDebounce<T>(source: Ref<T>, delay = 300) { const debounced = ref<T>(source.value) as Ref<T> let timer: ReturnType<typeof setTimeout> | null = null watch(source, (val) => { if (timer) clearTimeout(timer) timer = setTimeout(() => { debounced.value = val }, delay) }) return debounced }组件里const keyword = ref(''),然后const debouncedKeyword = useDebounce(keyword, 300),监听debouncedKeyword去请求接口就行。注意这个版本只在源变化后才触发防抖,如果你希望初始化时也执行一次,需要额外加个flush参数。对应地,节流实现通常用时间戳对比或锁变量,语义不同,但文件名和入参风格保持一致就对了。
3.2 useEventListener:手动 addEventListener 的终结者
很多人在onMounted里加监听,在onUnmounted里移除,稍不留神就漏掉移除导致内存泄漏。useEventListener把这两步封装掉:
import { onMounted, onBeforeUnmount } from 'vue' export function useEventListener( target: EventTarget | Ref<EventTarget | null>, event: string, handler: EventListener, options?: AddEventListenerOptions ) { const targetRef = typeof target === 'object' && 'value' in target ? target : null const getTarget = () => targetRef?.value ?? target onMounted(() => { getTarget()?.addEventListener(event, handler, options) }) onBeforeUnmount(() => { getTarget()?.removeEventListener(event, handler, options) }) }用起来就是useEventListener(window, 'scroll', onScroll)。这个文件的价值不在代码量,而在于它彻底消除了“忘记清理”这个最常见的错误。如果再进一步,还能在 handler 变化时自动替换监听,但那会复杂不少,通用版本先保证这个基础安全性即可。
3.3 useClickOutside:弹窗与下拉菜单的标准答案
后台管理系统和商城页面里,下拉菜单、气泡卡片、弹窗遮罩都需要“点击外部关闭”。useClickOutside的做法是:给一个目标元素 ref,然后监听pointerdown或mousedown,判断点击坐标是否落在元素范围内。
import { ref, onMounted, onBeforeUnmount } from 'vue' export function useClickOutside(target: Ref<HTMLElement | null>, callback: () => void) { const handler = (e: MouseEvent) => { if (target.value && !target.value.contains(e.target as Node)) { callback() } } onMounted(() => document.addEventListener('mousedown', handler)) onBeforeUnmount(() => document.removeEventListener('mousedown', handler)) }这里有一个性能细节:不要在目标元素上监听自己的blur事件来实现“点击外部关闭”,因为blur在点击元素内部子节点时也会触发,处理起来很绕。直接用document级监听 +contains判断是最稳的。
3.4 useResize / useBreakpoint:响应式布局好在哪
商城首页经常要按屏幕宽度渲染不同数量的商品列,复杂图表要跟随容器尺寸变化重绘。useResize返回容器或窗口的宽高,内部用ResizeObserver实现;useBreakpoint则是在窗口宽度跨越某个阈值时切换断点值。两者经常组合出现。
useResize要注意的是ResizeObserver的回调不是同步触发的,拿到新尺寸后如果直接nextTick使用,某些场景下会晚一帧。实际项目里我见过同事因为这个 bug,图表容器初始化时宽度永远是 0,排查了半天才发现是ResizeObserver首次回调还没跑完。后来统一在 composable 里加一个initialized状态,业务侧再根据它去做二次渲染。
4. 从文件名到源码:亲手实现一份可复用的 useRequest
4.1 先想清楚 API 长什么样
很多新人拿起键盘就开始写代码,但写 composable 前更应该先想清楚“使用方体验”。我设计useRequest的 API 时,问了自己三个问题:
- 调用方需要拿到哪些响应式状态?——至少
loading、data、error - 用什么方式触发请求?——返回
run函数,而不是在 composable 内部自动执行,这样能支持“点击按钮才请求”的场景 - 如何处理重复触发?——默认不允许并发,上一次没结束、下一次
run进来就取消上一次
如果你要的是“进页面就自动请求”,那可以在useRequest基础上再包一层useRequestOnLoad,或者给useRequest加一个immediate参数。这些都属于渐进增强,核心 API 不变,后续扩展不会推翻重来。
4.2 实现骨架与类型推导
TypeScript 是这个文件的重头,因为泛型推导直接决定业务侧好不好用。标准骨架:
import { ref, shallowRef } from 'vue' type RequestFn<T, P extends any[]> = (...args: P) => Promise<T> export function useRequest<T, P extends any[]>(fn: RequestFn<T, P>) { const loading = ref(false) const data = shallowRef<T | null>(null) const error = shallowRef<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 res = await fn(...args) data.value = res return res } catch (e) { if ((e as Error).name !== 'AbortError') { error.value = e as Error throw e } } finally { loading.value = false } } return { loading, data, error, run } }这里我刻意用了shallowRef而不是ref来存放data,因为接口返回的嵌套对象结构通常很复杂,ref的深层响应式转换会带来不必要的性能开销,而业务侧一般只关心整体替换结果。这个细节面试时也常问,答出来会觉得你是真写过。
4.3 给 composable 加上 AbortController 和竞态处理
上一步代码里已经带了AbortController,原理是每次run时把上一次的请求中断掉。为什么需要它?页面里请求 A 还没返回,用户又触发了请求 B,如果 A 稍后返回了,它带着的过期数据可能会覆盖 B 的结果——这就是经典的竞态问题。
AbortController负责网络层取消,但有些请求库(比如只包了fetch的封装)不一定支持,那我通常还会在 composable 内部维护一个requestId:
let requestId = 0 async function run(...args: P) { const currentId = ++requestId // 调用 fn 拿到结果后 if (currentId === requestId) { data.value = res } }这样即使底层请求没有真正取消,也不会拿过期结果覆盖新状态。性价比极高,我建议每个项目里的useRequest都要有这层保护。
4.4 我建议的目录拆分方式
到了文件数量多起来之后,src/composables目录里如果像平铺一样堆三四十个文件,找东西时也很痛苦。我的经验是把目录拆成两层:
src/composables/ core/ useRequest.ts useDebounce.ts useEventListener.ts business/ useUser.ts useDict.ts useTable.tscore放与业务无关的通用逻辑,business放基于当前后端接口的领域封装。命名上,业务型 composable 我习惯用名词,比如useUser、useOrder,表示“用户这个领域的所有状态与操作”;通用型用动词,比如useDebounce、useToggle,表示“我要做某个行为”。这样目录一打开,光靠文件名和分类就能建立第一层认知。
5. 同名不同命:composable 命名冲突与维护原则
5.1 useXxx 的命名冲突发生在哪
最常见的情况是,你和同事各自为不同页面封装了两个同名文件,一个放在composables/business/useTable.ts,另一个放在composables/table/useTable.ts。当项目里有几十个 composable 时,IDE 自动导入会把两个文件混着引,编译不报错,但运行期行为完全对不上。
我的处理原则是“目录名 + 文件名”一起作为逻辑标识,尽量避免同目录下出现同名词。如果两个文件确实都叫useTable,那至少在文件顶部写清楚边界注释,比如“本文件负责标准表格页的分页搜索,不处理拖拽排序和树形表格,树形表格请用useTreeTable.ts”。
5.2 什么时候别自己写,去选现成库
看到这里你可能发现,我讲的很多 composable 其实都有成熟开源实现,比如 VueUse。这不是一个“不能用别人的”的问题,而是“你怎么用它”的问题。
我们的useStorage、useDebounce、useEventListener完全可以直接引入 VueUse 的版本,人家迭代多年,边界情况处理得比我上面给的示例完善得多。但业务型 composable 如useTable、useDict、useAuth,强依赖项目自己的接口协议和数据格式,开源库反而帮不了你。所以我的建议是:通用逻辑优先试用 VueUse,业务逻辑再自己封装。如果公司内部同时有多个 Vue3 项目,可以考虑沉淀一套私有基础 composables 库,公共层直接复用,业务层各自为战。
5.3 我在真实项目里留的那份 composables 清单文档
最后分享一个实操习惯。我在每个中大型项目里都会维护一份composables.md,表头很简单:文件名、用途、入参、返回值、维护人。这份文档不需要长篇大论,两三行写完一个文件即可。作用有两个:一是新同学入职不用再翻十几二十个文件才能搞清楚某个页面调用的useXxx是哪来的;二是在评审新页面方案时,能先查一下清单,看看“这个功能是不是已经有 composable 能覆盖了”。很多代码重复和命名混乱,其实不是写作水平问题,而是没人知道已经存在了什么。
我见过最夸张的一次,项目里有三个useTable相关文件,分别来自三个同事不同时期的手笔,接口返回结构还发生了两次变更,最后维护它们的同事只能一遍遍地靠文件名猜当前页该用哪个。从那以后我就格外坚持每个 composable 文件顶部用三行注释写清楚唯一责任,并且把注释同步到清单文档里。文件名列表看起来只是几个单词,但它背后是一整套“哪些逻辑被抽象出来、为什么这样抽象、该去哪里改动”的项目结构认知。
如果你正在搭新 Vue3 项目,或者准备重构老项目的状态管理,不妨照着我这份清单先建一个空目录,一个文件一个文件地往里填。填的过程中你会发现,页面代码在变薄,逻辑边界在变清晰,维护成本也会肉眼可见地降下来。