ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3 Composables 常见文件命名与封装实践指南

Vue3 Composables 常见文件命名与封装实践指南 最近帮一个朋友的 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.tsDOM 事件监听自动addEventListener与移除useClickOutside.ts点击元素外部触发回调弹窗/下拉的点击关闭useResize.ts监听容器或窗口尺寸响应式布局的基础useBreakpoint.ts获取当前断点根据视口宽度返回 xs/sm/md/lguseStorage.tslocalStorage/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 useRequestT(fn: (...args: any[]) PromiseT) { const loading ref(false) const error shallowRefError | null(null) const data shallowRefT | 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-loadingloading直接绑定即可。后续你还可以在里面加缓存、加轮询、加取消请求都是在这个壳子上扩展。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 useStorageT(key: string, initialValue: T) { const raw localStorage.getItem(key) const state refT(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 useDebounceT(source: RefT, delay 300) { const debounced refT(source.value) as RefT let timer: ReturnTypetypeof 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 | RefEventTarget | 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: RefHTMLElement | 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. 从文件名到源码亲手实现一份可复用的 useRequest4.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 RequestFnT, P extends any[] (...args: P) PromiseT export function useRequestT, P extends any[](fn: RequestFnT, P) { const loading ref(false) const data shallowRefT | null(null) const error shallowRefError | 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 内部维护一个requestIdlet 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 项目或者准备重构老项目的状态管理不妨照着我这份清单先建一个空目录一个文件一个文件地往里填。填的过程中你会发现页面代码在变薄逻辑边界在变清晰维护成本也会肉眼可见地降下来。
返回列表