ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue 3组合式函数实战:用useRequest/useTable/useForm替代Mixin

Vue 3组合式函数实战:用useRequest/useTable/useForm替代Mixin 在做后台管理系统的时候我经常跟同事说一句话别再往组件里堆 Mixin 了等哪天这些 Mixin 里的 data 互相覆盖出 bug你查得会比写还累。这句话不是危言耸听是从 Vue 2 时代一路搬砖过来的真实体会。刚好最近团队项目全面切到 Vue 3 Composition API我把原来那套基于 Mixin 的请求、表格、表单逻辑全部重构成了组合式函数收益非常明显今天就把这份实战经验完整分享出来。这篇文章不是讲基础语法而是从 Mixin 的缺陷出发逐步拆解useRequest、useTable、useForm三个高频组合式函数的设计思路、完整实现和落地细节。适合正在用 Vue 3 开发后台管理系统、想把复用逻辑从 Mixin 迁移到组合式函数、或者想理解组合式函数到底该怎么设计的开发者。无论你是在写业务组件还是做通用封装这篇都能给你一套可以直接抄走的方案同时帮你避开我在实际项目中踩过的一些坑。1. Mixin 的翻身史它为什么被嫌弃1.1 命名冲突不止是玄学是必然结果网上聊 Mixin 缺陷的文章很多大多会提到命名冲突四个字但很少有人解释清楚冲突到底是怎么发生的。本质原因很简单Mixin 选项是扁平合并的。比如我在tableMixin.js里定义了一个data() { return { loading: false } }又在pageMixin.js里也定义了data() { return { loading: true } }二者合并时 Vue 会默认采用组件自身 data 优先、Mixin 其次的规则而同名 Mixin 之间则以后加载的为准。这种规则带来的问题是你的组件最终loading到底是false还是true取决于两个 Mixin 的加载顺序而不取决于你的业务意图。有一次排查一个列表页刷新异常打开 Vue 开发者工具发现loading在请求发起前就变成false查了很久才发现是paginationMixin和requestMixin里都定义了page字段一个默认值是1另一个默认值是0合并后把分页初始值冲掉了。如果项目里只有一两个 Mixin这个问题还能靠约定规避但后台系统发展到十几个共享 Mixin 的时候这种冲突几乎是必然的。1.2 数据来源不透明代码可读性急剧下降Mixin 第二个难以忍受的问题是隐式依赖。一个业务组件引入了requestMixin这个 Mixin 内部可能依赖另一个 Mixin 提供的cancelToken数据但组件模板里并不会直接看到这个依赖关系。新成员接手时往往是一脸懵这个$http哪来的this.searchForm为什么被自动重置了我查过一个真实的线上 bug某个 Mixin 在created钩子里调用了this.fetchData()但引入该 Mixin 的组件本身并没有定义fetchData方法运行时直接报TypeError。原因是另一个 Mixin 曾经提供过fetchData但代码调整后那个 Mixin 被移除了。组件层面完全看不出这个隐形调用链这种幽灵方法问题在 Mixin 模式下非常难追踪。1.3 复用逻辑与组件状态互相纠缠Mixin 本质上是把逻辑平铺进组件实例它没有办法把相关的能力封装成一个带状态的对象。请求的loading、data、error散落在组件 data 里请求方法挂在methods里监听逻辑放在watch里知识被切成了三块。复用的时候你只能整包引入不能按需取用。组合式函数解决这些问题的思路完全不同把一组相关的状态和方法收拢到一个函数返回的对象里外部按需解构使用。数据从哪来、方法绑在谁身上一目了然。这也是为什么 Vue 3 官方会推荐用组合式函数替代 Mixin——它从机制上消除了命名冲突和隐式依赖的土壤。2. 组合式函数的工作原理先搞懂 setup 的底层逻辑2.1 不是内置也不是魔法是基础 API 的排列组合组合式函数Composable本身并不是 Vue 框架里某个特殊的内置 API它只是利用 Composition API 的 ref、reactive、computed、watch、生命周期钩子等基础能力把一个独立逻辑封装成普通函数。这个名字的威力在于封装和复用——函数内部可以用 ref/reactive 定义状态用 computed 定义派生值用 watch 做副作用用onMounted注册生命周期最后把这些状态和方法通过 return 暴露出来。我在团队内部经常打一个比方Mixin 像把菜谱直接贴到你家厨房墙上你不想做某道菜也得看着它组合式函数则像一个专门的调料柜做哪道菜就取哪格调料。2.2 ref 和 reactive 的引用语义为什么不用 .value很多刚转 Vue 3 的朋友会被ref的.value搞晕。理解它的核心在于ref创建的是一个响应式引用而在 setup 顶层模板中它会自动解包所以模板里写{{ loading }}即可不需要loading.value。但在组合式函数内部如果你把ref实例直接 return 给组件在组件setup里使用时必须写作const { loading } useRequest()然后读取时写loading.value。如果希望外部像普通变量一样读写可以使用toRefs展开 reactive 对象。我在实际封装时习惯性使用reactive集中定义状态对象再配合toRefs让外部解构后依然保持响应性。// useRequest.js import { reactive, toRefs } from vue export function useRequest() { const state reactive({ data: null, loading: false, error: null }) // 外部这样解构const { data, loading } useRequest() // data、loading 依然保持响应性且不需要 .value return { ...toRefs(state) } }注意toRefs只能作用于reactive对象的第一层嵌套对象需要手动处理。实际开发中建议状态对象保持扁平避免过度嵌套带来解构响应性丢失问题。2.3 生命周期注册与依赖自动追踪setup 只执行一次但 watch 能跟住变化组合式函数的一大优势是可以在函数内部注册生命周期钩子例如在useTable中调用onMounted(() fetchList())。这会让人产生一个疑问setup只执行一次为什么函数内部注册的watch能持续响应数据变化答案在于 Vue 3 的响应式系统watch绑定的不是变量副本而是响应式数据源本身。ref和reactive创建的数据具有追踪依赖的能力watch作为副作用会在源数据变更后重新执行。组合式函数相当于在第一次执行时建立了一套持续生效的响应式规则。理解了这一点你就不需要担心函数执行一次是不是就固定了。3. 手写 useRequest一个涵盖加载、错误、竞态处理的请求封装3.1 设计 API为什么参数要收敛为一个对象所有请求封装的第一步是想清楚外部调用长什么样。最简单的方案是useRequest(fn, options)fn是用户传入的请求函数返回 Promiseoptions里控制是否立即执行、是否轮询、成功失败回调等。另一个问题是要不要做取消竞态支持。后台管理里经常出现快速切换搜索条件的情况上一次请求慢、下一次请求快回来后旧响应把新数据覆盖了。这类问题必须处理而且应该在封装层面解决而不是让业务代码每次都手写标志位。/** * useRequest —— 极简实用的请求状态封装 * param {Function} requestFn 传入一个返回 Promise 的请求函数 * param {Object} options * - immediate {Boolean} 是否立即执行默认 true * - manual {Boolean} 是否手动触发默认 falsemanual 为 true 时 immediate 失效 */ import { ref, watch } from vue export function useRequest(requestFn, options {}) { const { immediate true, manual false } options const data ref(null) const loading ref(false) const error ref(null) let cancelFlag false let requestSeq 0 async function run(...args) { // 自增序列用于竞态处理 const currentSeq requestSeq loading.value true error.value null cancelFlag false try { const result await requestFn(...args) // 只有最新一次请求的返回值才能写入状态 if (currentSeq requestSeq !cancelFlag) { data.value result } return result } catch (e) { if (currentSeq requestSeq !cancelFlag) { error.value e } throw e } finally { if (currentSeq requestSeq) { loading.value false } } } function cancel() { cancelFlag true loading.value false } if (immediate !manual) { run() } return { data, loading, error, run, cancel } }3.2 核心实现requestSeq 与 cancelFlag 是竞态安全的基石上面代码里最容易被忽略的是requestSeq和cancelFlag两个变量它们解决了两个不同的问题requestSeq请求序列号每一次run都会让序列号自增。响应返回时只有当前序列号等于最新序列号的请求才能更新数据。这确保了在快速触发多次请求的场景下只有最后一次请求的结果会被写入data。cancelFlag主动取消标志调用cancel()后在then/catch里不再更新数据同时把loading置为false。适合组件卸载时使用。从业务组件视角看const { data, loading, run } useRequest((params) api.getList(params)) watch(searchForm, () { run({ page: 1, ...searchForm }) })这里run每次都执行最新的请求但旧的响应即使后返回也不会覆盖data。代码里没有任何全局变量不会互相污染。3.3 和状态管理的关系它不等同于 Pinia有人问useRequest 已经管理了 loading/data/error那 Pinia 不就没用了吗我的经验是useRequest 管理的是瞬时请求状态Pinia 管理的是跨组件共享的业务数据。如果一个数据只被一个组件使用完全不需要进 store如果多个页面需要同一份数据并保持同步比如用户列表和详情页的数据联动应该把请求逻辑放 store 里而 useRequest 可以作为 store 内部的能力。实际项目里我通常让 useRequest 作为纯逻辑层复用store 只放需要跨页面的状态和更新动作。这样边界清晰既不会让 store 变成什么都要管的大容器也不会让组件里堆积大量请求代码。3.4 使用 useRequest 时容易踩的三个坑误传非 Promise如果requestFn没有返回 Promisethen/catch不会按预期工作。建议在run开头判断requestFn(...args)是否存在then方法没有就直接throw new TypeError方便使用者第一时间发现问题。立即执行与参数传递immediate: true时第一次请求是没有参数可传的。很多业务第一次请求其实需要带着默认参数我会在 options 里再支持defaultParams字段在run外部用defaultParams先兜底而不是在run内部判断。cancel和requestSeq的关系cancel会让当前最新的请求也无法更新状态。如果你希望取消上一轮请求、但允许新一轮请求更新数据正确的做法是在 run 开头重新生成currentSeqcancelFlag只作为主动取消标志不要让它影响新请求。4. useTable 封装让列表页从复制粘贴变成业务配置4.1 列表页的逻辑痛点到底在哪里后台系统里最重复的劳动就是列表页分页、搜索、筛选、排序、重置、导出。每个页面几乎一样的逻辑但大家通常都是从上一个页面复制一份改参数。这种做法问题很多搜索条件和请求参数混在一起经常出现重置搜索条件但页码没变或者改变了搜索条件但没跳回第一页的问题。分页组件当前页和请求参数里的page两套值某一处忘记同步表格和分页就各说各话。表格的loading状态、请求出错提示、数据为空提示逻辑重复散落。useTable 的目标就是把分页 搜索 请求 刷新这套流程固化下来。4.2 封装设计query、pagination、refresh 三层结构useTable 需要一个数据源函数fetcher它接收查询参数返回 Promise。封装后向外暴露list、loading、pagination、refresh、reset、search等。import { reactive, ref, computed, toRefs } from vue import { useRequest } from ./useRequest export function useTable(fetcher, options {}) { const { defaultParams {} } options // 查询参数层 const query reactive({ ...defaultParams }) // 分页状态层 const pagination reactive({ page: 1, pageSize: 10, total: 0 }) const { data, loading, run } useRequest( (params) fetcher(params), { manual: true } ) const list computed(() data.value?.list ?? data.value ?? []) // 汇总所有参数搜索条件 分页条件 function buildParams() { return { ...query, page: pagination.page, pageSize: pagination.pageSize } } async function fetchList() { const result await run(buildParams()) if (result) { pagination.total result.total ?? 0 } return result } // 搜索重置页码并请求 function search(nextQuery) { Object.assign(query, nextQuery || {}) pagination.page 1 return fetchList() } // 重置清空查询条件回到第一页 function reset() { Object.keys(query).forEach((key) { if (Array.isArray(query[key])) { query[key] [] } else if (typeof query[key] object query[key] ! null) { query[key] {} } else { query[key] undefined } }) pagination.page 1 return fetchList() } // 刷新保留当前页码 function refresh() { return fetchList() } // 分页变化 function handlePageChange(page, pageSize) { pagination.page page pagination.pageSize pageSize return fetchList() } return { list, loading, query, pagination, search, reset, refresh, handlePageChange } }4.3 后端分页参数适配不要被接口格式绑架不同后端的分页参数叫法不同page/pageSize、current/size、pageNum/pageSize、offset/limit返回结构也可能包一层data.records或result.list。我不建议在 useTable 内部写死后端字段名而是通过 options 传入映射函数。// 适配约定page/pageSize 入参返回 { list, total } const table useTable( (params) api.getUsers(params), { page: (p) p.page, pageSize: (p) p.pageSize, responseAdapter: (res) ({ list: res.data.records, total: res.data.total }) } )这样无论后端风格怎么变useTable 的核心逻辑都不动。实际项目里我最常遇到的是搜索结果总数在meta.total列表数据在data但分页组件要求total为数字适配器一改就解决了。4.4 用起来什么样一页代码搞定列表页模板不变核心逻辑在setup里const table useTable( (params) userApi.getList(params), { defaultParams: { keyword: , status: undefined } } ) const { list, loading, query, pagination, search, reset, refresh, handlePageChange } table模板里el-input v-modelquery.keyword changesearch() / el-button clickreset重置/el-button el-table v-loadingloading :datalist !-- 列配置 -- /el-table el-pagination :current-pagepagination.page :page-sizepagination.pageSize :totalpagination.total current-changehandlePageChange /和之前每个页面手写逻辑相比useTable 把状态和操作封装在一个对象里业务代码只保留这个列表的请求函数和默认搜索条件。5. useForm 封装把表单校验、提交和回显装进同一个容器5.1 表单组件为什么比表格更令人头疼表格页的痛点是重复逻辑多表单页的痛点是逻辑状态太分散。一个典型的新增/编辑页包含表单字段定义和响应式数据校验规则及动态增删规则提交前处理转换日期、去空字段编辑场景下的数据回显提交后返回列表刷新用选项式 API 写的时候这些逻辑分散在data、watch、methods里尤其是编辑时回显数据经常需要deep: true的 watch 配合稍不留神就会触发循环赋值。5.2 封装设计model、rules、submit 的联动useForm 的定位是自动同步model和校验状态提供可配置的submit流程同时在resetFields、setFields等操作上保持一致。import { reactive, ref, nextTick } from vue export function useForm(options {}) { const { formRef, initialModel {}, rules {} } options const model reactive({ ...initialModel }) const loading ref(false) // 手动校验触发表单组件校验 async function validate() { if (!formRef.value) return Promise.reject(new Error(formRef 未绑定)) try { await formRef.value.validate() return true } catch (e) { return false } } // 重置表单字段和校验状态 function resetFields() { Object.keys(model).forEach((key) { const initial initialModel[key] model[key] Array.isArray(initial) ? [...initial] : typeof initial object initial ! null ? JSON.parse(JSON.stringify(initial)) : initial undefined ? : initial }) nextTick(() { formRef.value?.clearValidate() }) } // 设置表单数据并自动触发校验逻辑回显 async function setFields(data) { Object.assign(model, data) await nextTick() // 有些校验是异步的设置数据后可以主动触发一次校验 formRef.value?.clearValidate() } async function submit(submitFn) { const valid await validate() if (!valid) return { ok: false, error: VALIDATION_FAILED } loading.value true try { const result await submitFn({ ...model }) return { ok: true, result } } catch (error) { return { ok: false, error } } finally { loading.value false } } return { model, loading, validate, resetFields, setFields, submit } }5.3 数据回显与编辑场景的处理编辑场景最容易踩的坑是表单数据初始化时序。如果组件加载时才去请求详情model可能已经用initialModel初始化了一轮请求回来后需要整体替换。我的做法是setFields里使用Object.assign合并而不是整体替换这样能保留model的引用稳定也不会触发不必要的响应式依赖重建。更重要的一个细节部分下拉框组件如 el-select 的远程搜索、el-cascader在数据回显时值已经设置但选项数据还没有加载完成显示就会是数字 code 而不是文字 label。这种问题的根因不在 useForm而在数据加载顺序。我的解决办法是在edit场景中先并行请求详情和选项数据等两者都就绪后再调用setFieldsconst [detail, categoryOptions] await Promise.all([ api.getDetail(id), api.getCategoryOptions() ]) categoryList.value categoryOptions form.setFields(detail)不要await一个再去请求另一个串行会让表单出现很明显的闪烁感。5.4 动态表单字段与校验规则的一些细节坑动态增减字段比如商品规格组在表单里非常常见但直接往model对象上动态添加新属性要注意Vue 3 的 reactive 可以检测新增属性但部分表单校验库比如 async-validator在初始rules里没有对应字段时会直接跳过校验。所以动态字段的校验规则也要动态维护。我的经验是在 useForm 之外单独维护一个dynamicRules的 ref在动态字段变化时整体更新const dynamicRules ref({}) function addSku() { model.skus.push({ name: , price: null }) dynamicRules.value { ...dynamicRules.value, [ sku_${model.skus.length - 1} ]: [ { required: true, message: 规格名称不能为空 }, ... ] } }组合式函数只管公共流程动态规则的逻辑放到业务里这样 useForm 不会因为兼容各种动态场景而变得失控。6. 从封装到团队规范组合式函数不是万能的6.1 该抽到什么粒度功能而非页面组合式函数最大的误用是把整个页面逻辑塞进一个 hook。useUserPage、useOrderDetail 这种按页面拆的hook等于把之前的methods、data、watch换个皮没有任何复用价值。正确的拆分维度是功能能力请求状态是一种能力分页逻辑是一种能力表单生命周期是一种能力。页面通过组合这些能力来组装自己的业务逻辑而不是把所有内容打包在一个函数里。我推荐的控制原则一个组合式函数只做一件事且对外暴露的状态不超过 5 个方法。如果超过你就需要反思是不是塞了太多不相关的逻辑。6.2 目录命名与文件组织建议在实际项目里我通常建一个composables/目录按域划分src/ composables/ request/ useRequest.js usePagination.js table/ useTable.js form/ useForm.js business/ useUserOptions.jsbusiness/目录放置跟业务强相关的组合式函数比如用户选项列表品类树数据这类跨页面复用的业务逻辑。这样通用的和业务的分离不会相互污染。6.3 组合式函数与 Pinia 怎么分工有一个很容易让人迷惑的问题组合式函数里的数据到底算局部状态还是全局状态我的判断标准是跨实例共享需求。如果多个组件要对同一份数据做相同的读写操作比如当前用户信息、字典数据那就该放 Pinia如果只是多次复用请求逻辑但各自维护状态那就用组合式函数。一个典型例子useDict(status)用于加载字典项。如果每个页面调用一次会产生多份响应式数据但字典数据其实是全局唯一的。更合理的做法是封装一个全局的dictStore组合式函数只做从 store 读取 副作用加载而不是自己维护一份副本。6.4 从 Mixin 项目迁移的路径参考如果你的项目目前还在 Vue 2 Mixin 模式不建议一刀切重写。一个稳妥的迁移策略是先做逻辑盘点找出被引用超过两次的 Mixin列出它到底暴露了哪些 data、methods、watch。按功能拆成组合式函数注意把 Mixin 里相互依赖的逻辑理顺暴露明确的输入输出。新建页面使用组合式函数旧页面保留 Mixin逐步替换。迁移完成后删除旧 Mixin并通过项目规范禁止新增 Mixin。在迁移中我最深的体会是很多 Mixin 表面上复用性好实际上内部依赖一堆隐式约定直接平移会给组合式函数带来同样的坏味道。所以迁移不是搬运而是一次混乱逻辑的清理机会。最后再分享一点个人体会。组合式函数封装到后期最需要警惕的不是不会封装而是过度封装。我在项目里见过一版 useForm参数选项超过 20 个支持联动、异步校验、动态规则、步进表单看起来功能完备强大实际上团队里没人能用好出了问题也极难调试。我个人现在的标准很简单先让三个以上真实页面出现重复逻辑再封装先解决手中 80% 的场景剩下的复杂场景单独处理。好的封装不是功能最多而是让调用者写更少的代码、出更少的错。
返回列表