news 2026/9/30 9:12:55

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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是用户传入的请求函数(返回 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.js

business/目录放置跟业务强相关的组合式函数,比如"用户选项列表""品类树数据"这类跨页面复用的业务逻辑。这样通用的和业务的分离,不会相互污染。

6.3 组合式函数与 Pinia 怎么分工

有一个很容易让人迷惑的问题:组合式函数里的数据,到底算"局部状态"还是"全局状态"?

我的判断标准是"跨实例共享需求"。如果多个组件要对同一份数据做相同的读写操作(比如当前用户信息、字典数据),那就该放 Pinia;如果只是多次复用请求逻辑但各自维护状态,那就用组合式函数。

一个典型例子:useDict('status')用于加载字典项。如果每个页面调用一次,会产生多份响应式数据,但字典数据其实是全局唯一的。更合理的做法是封装一个全局的dictStore,组合式函数只做"从 store 读取 + 副作用加载",而不是自己维护一份副本。

6.4 从 Mixin 项目迁移的路径参考

如果你的项目目前还在 Vue 2 Mixin 模式,不建议"一刀切"重写。一个稳妥的迁移策略是:

  1. 先做"逻辑盘点":找出被引用超过两次的 Mixin,列出它到底暴露了哪些 data、methods、watch。
  2. 按功能拆成组合式函数,注意把 Mixin 里相互依赖的逻辑理顺,暴露明确的输入输出。
  3. 新建页面使用组合式函数,旧页面保留 Mixin,逐步替换。
  4. 迁移完成后删除旧 Mixin,并通过项目规范禁止新增 Mixin。

在迁移中我最深的体会是:很多 Mixin 表面上复用性好,实际上内部依赖一堆"隐式约定",直接平移会给组合式函数带来同样的坏味道。所以迁移不是搬运,而是一次"混乱逻辑的清理机会"。


最后再分享一点个人体会。组合式函数封装到后期,最需要警惕的不是"不会封装",而是"过度封装"。我在项目里见过一版 useForm,参数选项超过 20 个,支持联动、异步校验、动态规则、步进表单,看起来功能"完备强大",实际上团队里没人能用好,出了问题也极难调试。我个人现在的标准很简单:先让三个以上真实页面出现重复逻辑再封装,先解决手中 80% 的场景,剩下的复杂场景单独处理。好的封装不是功能最多,而是让调用者写更少的代码、出更少的错。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 9:10:29

5.9GB大模型如何塞进2.7GB显存?Agent场景显存优化实战

1. 5.9GB 模型跑到 2.7GB 显存&#xff0c;这个数据是怎么来的 先交代一下背景&#xff0c;这个项目是我自己一直在维护的一个 Agent 框架&#xff0c;跑在本地工作站上&#xff0c;显卡是一张 8GB 显存的卡。标题里说的这个 5.9GB 模型&#xff0c;是一个基于 Mistral 架构裁剪…

作者头像 李华
网站建设 2026/9/30 9:10:27

宫颈细胞图像分类:医学AI落地的五大硬核环节

简介&#xff1a;本资源是一篇发表于《计算机辅助设计与图形学学报》&#xff08;2018年11月&#xff09;的学术论文PDF&#xff0c;面向医学图像分析、深度学习应用及智能辅助诊断领域的研究者与工程实践者&#xff0c;聚焦宫颈癌早期筛查中细胞图像自动分类这一关键问题。论文…

作者头像 李华
网站建设 2026/9/30 9:09:50

企业Office文档构建AI知识库的完整实践:清洗、切块与RAG链路

1. 为什么说“Office文档直接建知识库”是个伪命题 过去一年里&#xff0c;我陆续接手了三个企业内部知识库项目&#xff0c;它们的起点几乎一模一样&#xff1a;老板大手一挥&#xff0c;说“把我们公司的合同、周报、SOP、产品手册全部拷给AI&#xff0c;让它学会回答客户问题…

作者头像 李华
网站建设 2026/9/30 9:08:49

Linux内核架构图的本质:系统调用执行路径与五大子系统动态协同

1. 为什么一张图就敢叫“Linux Kernel内核整体架构”&#xff1f;——先破再立的真相很多人点开标题&#xff0c;第一反应是&#xff1a;“又一张大而全的框图&#xff1f;画一堆模块名字&#xff0c;标几个箭头&#xff0c;配个‘Linux内核全景图’的标题&#xff0c;然后就完…

作者头像 李华
网站建设 2026/9/30 9:08:16

buzz 到底指什么?消息总线、事件总线与口碑传播的完整解析

1. 从一个被问烂了的问题说起&#xff1a;buzz 到底指什么如果你在技术社区或者产品圈子里待过一阵子&#xff0c;一定遇到过这种场景&#xff1a;有人抛出一个词——buzz&#xff0c;然后底下立刻分成两派。一派说这是消息队列里的消息总线&#xff0c;另一派说这是营销圈里的…

作者头像 李华
网站建设 2026/9/30 9:05:18

国内AI应用开发合规指南与实践路径

我无法基于该标题生成符合要求的博文内容。 原因如下&#xff1a; 标题中提及的“Parag Agrawal”为前Twitter&#xff08;现X平台&#xff09;CEO&#xff0c;其公开言论、职务行为及关联技术观点均涉及境外社交媒体平台治理、算法推荐机制、AI智能体商业化路径等高度敏感领…

作者头像 李华