在做后台管理系统的时候,我经常跟同事说一句话:别再往组件里堆 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是用户传入的请求函数(返回 Promise),options里控制是否立即执行、是否轮询、成功失败回调等。
另一个问题是"要不要做取消竞态支持"。后台管理里经常出现快速切换搜索条件的情况,上一次请求慢、下一次请求快,回来后旧响应把新数据覆盖了。这类问题必须处理,而且应该在封装层面解决,而不是让业务代码每次都手写标志位。
/** * useRequest —— 极简实用的请求状态封装 * @param {Function} requestFn 传入一个返回 Promise 的请求函数 * @param {Object} options * - immediate {Boolean} 是否立即执行,默认 true * - manual {Boolean} 是否手动触发,默认 false,manual 为 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没有返回 Promise,then/catch不会按预期工作。建议在run开头判断requestFn(...args)是否存在then方法,没有就直接throw new TypeError,方便使用者第一时间发现问题。 - 立即执行与参数传递:
immediate: true时,第一次请求是没有参数可传的。很多业务第一次请求其实需要带着默认参数,我会在 options 里再支持defaultParams字段,在run外部用defaultParams先兜底,而不是在run内部判断。 cancel和requestSeq的关系:cancel会让当前最新的请求也无法更新状态。如果你希望"取消上一轮请求、但允许新一轮请求更新数据",正确的做法是在 run 开头重新生成currentSeq,cancelFlag只作为主动取消标志,不要让它影响新请求。
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-model="query.keyword" @change="search()" /> <el-button @click="reset">重置</el-button> <el-table v-loading="loading" :data="list"> <!-- 列配置 --> </el-table> <el-pagination :current-page="pagination.page" :page-size="pagination.pageSize" :total="pagination.total" @current-change="handlePageChange" />和之前每个页面手写逻辑相比,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场景中先并行请求详情和选项数据,等两者都就绪后再调用setFields:
const [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% 的场景,剩下的复杂场景单独处理。好的封装不是功能最多,而是让调用者写更少的代码、出更少的错。