同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。
一、先搞清楚一个问题:我们到底在解决什么?
写 Vue 项目久了,你一定遇到过这些场景:
- 好几个页面都有表格 + 分页 + 搜索 ,每个页面都写一遍
currentPage、pageSize、total、loading、tableData...... - 好几个弹窗表单都要做打开/关闭、表单校验、提交、重置,每次都 copy 一坨。
- 几乎所有接口调用都要处理 loading、error、retry,到处重复 try-catch。
核心问题就一个字:重复。
但重复本身不是最可怕的,可怕的是:
- 改一个逻辑要改 N 个地方(漏改一个就是 bug)
- 逻辑散落在
data、methods、watch、mounted各处,跟读小说一样要来回翻页 - 新人接手看不懂,老人自己过半年也看不懂
所以我们需要一种方式,把可复用的有状态逻辑抽出来,做到:写一次、用 N 次、改一处、全生效。
在 Vue2 时代,官方给的方案叫 Mixin 。
在 Vue3 时代,官方推荐的方案叫 Composables(组合式函数)。
二、Vue2 Mixin:能用,但有"三宗罪"
2.1 Mixin 是什么?
简单说:Mixin 就是一个普通的 Vue 组件选项对象 ,可以包含 data、methods、computed、watch、生命周期等任何组件选项。当你把它"混入"到一个组件里时,这些选项会和组件自身的选项合并。
2.2 一个典型的例子:分页逻辑复用
假设我们有很多列表页,都需要分页,先看不用 Mixin 时你要在每个页面写的东西:
javascript
// PageA.vue
export default {
data() {
return {
tableData: [],
currentPage: 1,
pageSize: 10,
total: 0,
loading: false
}
},
methods: {
async fetchList() {
this.loading = true
try {
const res = await api.getListA({
page: this.currentPage,
size: this.pageSize
})
this.tableData = res.data.list
this.total = res.data.total
} finally {
this.loading = false
}
},
handlePageChange(page) {
this.currentPage = page
this.fetchList()
},
handleSizeChange(size) {
this.pageSize = size
this.currentPage = 1
this.fetchList()
}
},
created() {
this.fetchList()
}
}
PageB、PageC...... 全是这套。唯一不同的就是 api.getListA 换成 api.getListB。
用 Mixin 抽出来:
javascript
// mixins/pagination.js
export default {
data() {
return {
tableData: [],
currentPage: 1,
pageSize: 10,
total: 0,
loading: false
}
},
methods: {
// 子组件必须自己实现这个方法,返回接口调用的 Promise
fetchList() {
throw new Error('组件必须实现 fetchList 方法')
},
handlePageChange(page) {
this.currentPage = page
this.fetchList()
},
handleSizeChange(size) {
this.pageSize = size
this.currentPage = 1
this.fetchList()
}
},
created() {
this.fetchList()
}
}
javascript
// PageA.vue
import paginationMixin from '@/mixins/pagination'
export default {
mixins: [paginationMixin],
methods: {
async fetchList() {
this.loading = true
try {
const res = await api.getListA({
page: this.currentPage,
size: this.pageSize
})
this.tableData = res.data.list
this.total = res.data.total
} finally {
this.loading = false
}
}
}
}
看起来不错对吧?确实能复用了。但用久了你就会遇到 Mixin 的三宗罪。
2.3 Mixin 的三宗罪
第一宗:来源不明("这变量哪来的?")
html
<template>
<div>
<!-- currentPage 是组件自己的?还是 mixin 带来的?还是哪个 mixin? -->
<span>第 {{ currentPage }} 页,共 {{ total }} 条</span>
<!-- userName 呢?是另一个 mixin 的? -->
<span>{{ userName }}</span>
</div>
</template>
<script>
import paginationMixin from '@/mixins/pagination'
import userMixin from '@/mixins/user'
export default {
mixins: [paginationMixin, userMixin],
// 你在 data、methods 里完全看不出 currentPage 和 userName 从哪来
// IDE 也没法跳转到定义,只能靠人肉去翻 mixin 文件
}
</script>
当你引了 2-3 个 mixin,模板里用的变量来源就成了悬案。新人接手的时候更是一脸懵。
第二宗:命名冲突("我的变量被吞了")
javascript
// mixins/pagination.js
export default {
data() {
return { loading: false } // 表格加载状态
}
}
// mixins/auth.js
export default {
data() {
return { loading: false } // 权限校验加载状态
}
}
// SomePage.vue
export default {
mixins: [paginationMixin, authMixin],
data() {
return { loading: false } // 提交按钮加载状态
}
// 三个 loading 打架了!
// Vue2 的合并策略:组件自身的 data 优先,后面的 mixin 覆盖前面的
// 最终只有一个 loading,另外两个的逻辑全乱了
// 而且------不会报任何错误或警告!
}
这是 Mixin 最要命的问题。项目小的时候还好,项目大了、mixin 多了,命名冲突几乎是必然的,而且是静默的------不报错、不警告,直接覆盖,等你发现 bug 的时候已经不知道要查到什么时候了。
第三宗:不灵活("我想用两份分页怎么办?")
javascript
// 如果一个页面有两个独立的表格,各自有各自的分页呢?
// Mixin 混进来就是一份,没法实例化两份
export default {
mixins: [paginationMixin], // 只有一份 currentPage、total......
// 第二个表格的分页数据怎么办?再写一遍?那还要 mixin 干嘛?
}
Mixin 本质上是对象合并 ,不是函数调用,所以你没法像调函数一样"new 两份出来"。
2.4 小结
| 能力 | Mixin |
|---|---|
| 能复用逻辑吗? | ✅ 能 |
| 来源清晰吗? | ❌ 不清晰,变量来源成谜 |
| 能避免冲突吗? | ❌ 不能,静默覆盖 |
| 能多实例吗? | ❌ 不能,混进来就是一份 |
| 类型推导友好吗? | ❌ TypeScript 几乎没法推导 |
三、Vue3 Composables:函数的胜利
3.1 核心思想:一切皆函数
Vue3 的 Composition API 给了我们一个极其简单但极其强大的模式:
把有状态的逻辑写成一个普通函数,函数里用 ref/reactive 创建响应式状态,最后 return 出去。
就这么简单。没有什么新 API、新概念,就是函数。
javascript
// composables/useCounter.js ------ 最简单的例子
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return { count, increment, decrement, reset }
}
使用的时候:
js
<script setup>
import { useCounter } from '@/composables/useCounter'
// 调一次就是一份独立的状态
const { count: countA, increment: incrementA } = useCounter(0)
const { count: countB, increment: incrementB } = useCounter(100)
// countA 和 countB 完全独立,互不影响
</script>
<template>
<button @click="incrementA">A: {{ countA }}</button>
<button @click="incrementB">B: {{ countB }}</button>
</template>
3.2 对比 Mixin,三宗罪全解了
| 问题 | Mixin | Composable |
|---|---|---|
| 来源不明 | ❌ 变量凭空出现 | ✅ 显式 import + 解构,清清楚楚 |
| 命名冲突 | ❌ 静默覆盖 | ✅ 解构时可以重命名 { count: countA } |
| 不能多实例 | ❌ 只有一份 | ✅ 调多次就是多份独立状态 |
| TS 支持 | ❌ 几乎不可用 | ✅ 完美推导,悬停即可看类型 |
3.3 命名约定
Vue 社区有一个约定俗成的规范(也是 Vue 官方文档推荐的):
- 文件名:
useXxx.js或useXxx.ts - 函数名:
useXxx,以use开头 - 放置位置:项目中统一放在
composables/或hooks/目录下
bash
src/
├── composables/ # 或叫 hooks/
│ ├── useRequest.js # 通用请求封装
│ ├── useTable.js # 表格逻辑封装
│ ├── useForm.js # 表单逻辑封装
│ ├── useLoading.js # 加载状态封装
│ └── index.js # 统一导出
├── views/
├── components/
└── ...
四、实战封装一:useRequest ------ 一切的基础
4.1 为什么先封装它?
因为 useTable 要请求数据,useForm 要提交数据,几乎所有业务逻辑都绕不开接口调用。把请求逻辑封装好了,后面的封装都会轻松很多。
4.2 先想清楚:一个接口调用需要管理哪些状态?
别急着写代码,先列需求:
markdown
1. data ------ 接口返回的数据
2. loading ------ 是否正在请求中(控制按钮 loading、骨架屏等)
3. error ------ 请求失败的错误信息
4. 手动触发 / 自动触发 ------ 有的接口进页面就要调,有的要点按钮才调
5. 防重复 ------ 快速点击不要发 N 个请求
4.3 最小可用版本(V1)
先写一个最简单的版本,能跑起来:
javascript
// composables/useRequest.js V1 - 最小可用版
import { ref } from 'vue'
/**
* 通用请求封装
* @param {Function} apiFn - 接口函数,需返回 Promise
* @param {Object} options - 配置项
* @param {boolean} options.immediate - 是否立即执行,默认 false
* @param {any} options.initialData - data 的初始值,默认 null
*/
export function useRequest(apiFn, options = {}) {
const {
immediate = false,
initialData = null
} = options
const data = ref(initialData)
const loading = ref(false)
const error = ref(null)
async function run(...args) {
loading.value = true
error.value = null
try {
const res = await apiFn(...args)
data.value = res
return res
} catch (err) {
error.value = err
throw err // 继续抛出,让调用方可以 catch
} finally {
loading.value = false
}
}
// 如果配置了 immediate,创建时就调一次
if (immediate) {
run()
}
return { data, loading, error, run }
}
使用示例:
js
<script setup>
import { useRequest } from '@/composables/useRequest'
import { getUserInfo } from '@/api/user'
// 场景1:进页面自动请求
const { data: userInfo, loading } = useRequest(
() => getUserInfo(userId),
{ immediate: true }
)
// 场景2:点按钮手动触发
const { loading: submitLoading, run: submitForm } = useRequest(
(formData) => saveUser(formData)
)
function handleSubmit() {
submitForm({ name: 'Tom', age: 18 })
}
</script>
<template>
<div v-loading="loading">{{ userInfo?.name }}</div>
<button :loading="submitLoading" @click="handleSubmit">提交</button>
</template>
核心理解:
useRequest接收一个"接口函数",帮你管理loading、data、error三个状态,并返回一个run方法让你手动触发。就这么简单。
4.4 进阶版本(V2):加上防重复和竞态处理
V1 有两个隐患:
- 防重复:用户快速点击提交按钮,会同时发出多个请求
- 竞态:用户快速切换筛选条件,先发的请求后返回,会覆盖掉后发请求的正确数据
javascript
// composables/useRequest.js V2 - 加防重和竞态处理
import { ref } from 'vue'
export function useRequest(apiFn, options = {}) {
const {
immediate = false,
initialData = null,
// 新增:是否在 loading 中时阻止重复调用(适用于提交类接口)
preventRepeat = false
} = options
const data = ref(initialData)
const loading = ref(false)
const error = ref(null)
// 用一个自增 id 来处理竞态
// 每次调用 run 时 id + 1,回调时检查 id 是否是最新的
// 如果不是最新的,说明在这次请求还没返回时,又发了新的请求
// 那这次的结果就应该被丢弃
let requestId = 0
async function run(...args) {
// 防重复:如果正在请求中,直接返回
if (preventRepeat && loading.value) {
return
}
const currentId = ++requestId
loading.value = true
error.value = null
try {
const res = await apiFn(...args)
// 竞态处理:只有最新一次请求的结果才会被赋值
if (currentId === requestId) {
data.value = res
}
return res
} catch (err) {
if (currentId === requestId) {
error.value = err
}
throw err
} finally {
if (currentId === requestId) {
loading.value = false
}
}
}
if (immediate) {
run()
}
return { data, loading, error, run }
}
竞态问题的具体场景,举个例子:
css
用户操作:选"北京" → 选"上海"(很快切换)
请求时序:
请求A(北京)发出 ──────────────────> 请求A返回(北京的数据)
请求B(上海)发出 ────> 请求B返回(上海的数据)
如果不处理竞态:
页面先显示上海数据(正确),然后被北京数据覆盖(错误!)
处理竞态后:
请求A返回时发现 currentId !== requestId,丢弃结果
页面始终显示上海数据(正确)
这个问题在实际开发中出现频率很高,但很多人意识不到。面试也经常问。
4.5 踩坑提醒
坑 1:忘了 finally 重置 loading
javascript
// ❌ 错误写法
async function run(...args) {
loading.value = true
try {
const res = await apiFn(...args)
data.value = res
loading.value = false // 如果上面报错了,这行不会执行!
} catch (err) {
error.value = err
// 忘了在这里也重置 loading → 页面永远转圈
}
}
// ✅ 正确写法:用 finally
async function run(...args) {
loading.value = true
try {
const res = await apiFn(...args)
data.value = res
} catch (err) {
error.value = err
} finally {
loading.value = false // 不管成功失败都会执行
}
}
坑 2:immediate: true 的时候传参
javascript
// ❌ 这样拿不到参数
const { data } = useRequest(
(id) => getDetail(id),
{ immediate: true }
)
// immediate 调用 run() 时没传 id,接口会报错
// ✅ 用闭包把参数包进去
const { data } = useRequest(
() => getDetail(route.params.id),
{ immediate: true }
)
五、实战封装二:useTable ------ 中后台的半壁江山
5.1 分析需求
中后台项目里,表格页面占了至少一半。一个标准的表格页面需要:
markdown
1. 表格数据(tableData)
2. 分页状态(currentPage、pageSize、total)
3. 加载状态(loading)
4. 搜索/筛选参数(searchParams)
5. 查询方法(搜索、重置、翻页、切换每页条数)
6. 进页面自动加载第一页
5.2 完整实现
javascript
// composables/useTable.js
import { ref, reactive, onMounted } from 'vue'
/**
* 表格逻辑封装
* @param {Function} apiFn - 列表接口函数
* 接收参数格式:apiFn({ page, size, ...searchParams })
* 返回格式约定:{ list: [], total: 0 }
* @param {Object} options - 配置项
*/
export function useTable(apiFn, options = {}) {
const {
defaultPageSize = 10,
immediate = true,
// 让调用方可以自定义如何从接口返回值中提取 list 和 total
// 因为每个项目的接口返回格式可能不同
formatResult = (res) => ({
list: res.data?.list ?? res.data?.records ?? [],
total: res.data?.total ?? 0
})
} = options
// ---- 状态定义 ----
const tableData = ref([])
const loading = ref(false)
const pagination = reactive({
currentPage: 1,
pageSize: defaultPageSize,
total: 0
})
// 搜索参数,用 reactive 方便直接 v-model 绑定表单
const searchParams = reactive({})
// ---- 核心方法 ----
/** 加载数据 */
async function fetchData() {
loading.value = true
try {
const params = {
page: pagination.currentPage,
size: pagination.pageSize,
...searchParams
}
const res = await apiFn(params)
const { list, total } = formatResult(res)
tableData.value = list
pagination.total = total
} catch (err) {
console.error('[useTable] fetchData error:', err)
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
/** 搜索(重置到第一页) */
function search() {
pagination.currentPage = 1
fetchData()
}
/** 重置搜索条件并查询 */
function reset() {
// 清空 searchParams 的所有字段
Object.keys(searchParams).forEach(key => {
searchParams[key] = undefined
})
pagination.currentPage = 1
fetchData()
}
/** 翻页 */
function onPageChange(page) {
pagination.currentPage = page
fetchData()
}
/** 切换每页条数 */
function onSizeChange(size) {
pagination.pageSize = size
pagination.currentPage = 1 // 切换条数要回到第一页
fetchData()
}
/** 刷新当前页(不改变任何条件) */
function refresh() {
fetchData()
}
// ---- 初始化 ----
if (immediate) {
onMounted(() => {
fetchData()
})
}
// ---- 返回 ----
return {
tableData,
loading,
pagination,
searchParams,
search,
reset,
refresh,
onPageChange,
onSizeChange,
fetchData
}
}
5.3 使用示例(完整页面)
html
<!-- views/UserList.vue -->
<template>
<div class="page-container">
<!-- 搜索区域 -->
<el-form inline @submit.prevent="search">
<el-form-item label="用户名">
<el-input v-model="searchParams.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchParams.status" placeholder="请选择" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" border>
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="onPageChange"
@size-change="onSizeChange"
style="margin-top: 16px; justify-content: flex-end;"
/>
</div>
</template>
<script setup>
import { getUserList } from '@/api/user'
import { useTable } from '@/composables/useTable'
const {
tableData,
loading,
pagination,
searchParams,
search,
reset,
refresh,
onPageChange,
onSizeChange
} = useTable(getUserList)
// 页面自身的业务逻辑
function handleEdit(row) {
// 打开编辑弹窗...
}
async function handleDelete(row) {
await ElMessageBox.confirm('确认删除?')
await deleteUser(row.id)
ElMessage.success('删除成功')
refresh() // 删除后刷新当前页
}
</script>
对比一下:如果不用 useTable,这个页面的 <script> 部分至少要 80+ 行。现在核心逻辑只有 20 行左右,而且每个列表页都是同样的模式。
5.4 踩坑提醒
坑 1:searchParams 用 reactive 还是 ref?
javascript
// 方案A:reactive(推荐)
const searchParams = reactive({})
// ✅ 优点:模板里直接 v-model="searchParams.xxx",不用 .value
// ✅ 优点:新增字段时直接 searchParams.newField = 'xxx' 就行
// ⚠️ 注意:不能整个替换 searchParams = {},要逐个清字段
// 方案B:ref
const searchParams = ref({})
// 模板里要写 searchParams.xxx(Vue 自动解 ref),看起来一样
// 但重置时可以直接 searchParams.value = {}
// 缺点:如果传给子组件,需要注意 .value 的问题
我推荐用 reactive,因为搜索参数一般不会整个替换,而是逐字段修改。
坑 2:onMounted 还是直接调用?
javascript
// ❌ 直接调用
if (immediate) {
fetchData() // 此时组件可能还没挂载,某些情况下会有问题
}
// ✅ 放在 onMounted 里
if (immediate) {
onMounted(() => {
fetchData()
})
}
useTable 一般在 setup 阶段调用,如果你在 fetchData 里有用到 DOM 相关的东西(比如获取表格容器高度来做自适应),直接调用就会出问题。养成好习惯,用 onMounted。
坑 3:切换 pageSize 时忘了重置页码
javascript
// ❌ 错误
function onSizeChange(size) {
pagination.pageSize = size
fetchData()
// 比如当前在第 5 页,每页 10 条,共 45 条
// 切换成每页 50 条后,第 5 页已经不存在了
// 接口可能返回空数据甚至报错
}
// ✅ 正确
function onSizeChange(size) {
pagination.pageSize = size
pagination.currentPage = 1 // 一定要回到第一页!
fetchData()
}
六、实战封装三:useForm ------ 弹窗表单的终结者
6.1 分析需求
中后台的另一个高频场景:弹窗表单(新增/编辑共用一个弹窗)。需要管理:
markdown
1. 弹窗显隐(visible)
2. 弹窗标题(根据新增/编辑动态变化)
3. 表单数据(formData)
4. 表单校验(rules + validate)
5. 提交逻辑(loading + 调接口 + 关弹窗 + 刷新列表)
6. 重置逻辑(关弹窗时清空表单 + 清除校验状态)
6.2 完整实现
javascript
// composables/useForm.js
import { ref, reactive, toRaw } from 'vue'
/**
* 弹窗表单逻辑封装
* @param {Object} options
* @param {Function} options.getInitialData - 返回表单初始值的函数(必须是函数,避免引用污染)
* @param {Function} options.submitApi - 提交接口函数,接收 formData 参数
* @param {Function} options.onSuccess - 提交成功后的回调
*/
export function useForm(options = {}) {
const {
getInitialData = () => ({}),
submitApi,
onSuccess
} = options
// ---- 状态 ----
const visible = ref(false)
const isEdit = ref(false)
const title = ref('')
const formData = reactive(getInitialData())
const formRef = ref(null) // el-form 的 ref
const submitLoading = ref(false)
// ---- 方法 ----
/** 打开弹窗 - 新增模式 */
function openAdd() {
isEdit.value = false
title.value = '新增'
resetFields()
visible.value = true
}
/**
* 打开弹窗 - 编辑模式
* @param {Object} row - 当前行数据,用于回填表单
*/
function openEdit(row) {
isEdit.value = true
title.value = '编辑'
resetFields()
// 回填数据:只填 formData 中存在的字段,避免多余字段
Object.keys(getInitialData()).forEach(key => {
if (row[key] !== undefined) {
formData[key] = row[key]
}
})
visible.value = true
}
/** 关闭弹窗 */
function close() {
visible.value = false
// 延迟重置,等弹窗关闭动画结束后再清空,避免用户看到闪烁
setTimeout(() => {
resetFields()
}, 300)
}
/** 重置表单字段到初始值 */
function resetFields() {
const initial = getInitialData()
Object.keys(initial).forEach(key => {
formData[key] = initial[key]
})
// 清除 el-form 的校验状态
formRef.value?.clearValidate?.()
}
/** 提交表单 */
async function submit() {
if (!submitApi) {
console.warn('[useForm] submitApi is not provided')
return
}
// 先校验
try {
await formRef.value?.validate()
} catch {
return // 校验不通过,直接返回
}
submitLoading.value = true
try {
// toRaw:把 reactive 对象转成普通对象再传给接口
// 避免接口层不小心修改了响应式对象
await submitApi(toRaw(formData))
close()
onSuccess?.()
} catch (err) {
console.error('[useForm] submit error:', err)
// 提交失败不关弹窗,让用户可以修改后重试
} finally {
submitLoading.value = false
}
}
return {
visible,
isEdit,
title,
formData,
formRef,
submitLoading,
openAdd,
openEdit,
close,
submit,
resetFields
}
}
6.3 使用示例(完整页面)
html
<!-- views/UserList.vue(在前面 useTable 的基础上加入 useForm) -->
<template>
<div class="page-container">
<!-- 搜索区域(省略,同前面 useTable 示例) -->
<!-- 新增按钮 -->
<el-button type="primary" @click="openAdd" style="margin-bottom: 16px;">
新增用户
</el-button>
<!-- 表格(省略,同前面 useTable 示例,编辑按钮绑定 openEdit) -->
<el-table :data="tableData" v-loading="tableLoading" border>
<!-- ...其他列... -->
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="visible" :title="title" width="500px" @close="close">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { useTable } from '@/composables/useTable'
import { useForm } from '@/composables/useForm'
import { getUserList, createUser, updateUser } from '@/api/user'
// ---- 表格逻辑 ----
const {
tableData,
loading: tableLoading,
pagination,
searchParams,
search,
reset,
refresh,
onPageChange,
onSizeChange
} = useTable(getUserList)
// ---- 表单逻辑 ----
const {
visible,
isEdit,
title,
formData,
formRef,
submitLoading,
openAdd,
openEdit,
close,
submit
} = useForm({
getInitialData: () => ({
id: undefined,
username: '',
email: '',
status: 1
}),
submitApi: (data) => {
// 根据 isEdit 判断调新增还是编辑接口
return isEdit.value ? updateUser(data) : createUser(data)
},
onSuccess: () => {
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
refresh() // 提交成功后刷新表格
}
})
// 表单校验规则
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
</script>
你看,整个页面的 <script> 部分非常清晰:
useTable管表格和分页useForm管弹窗和表单- 页面自身只需要定义校验规则和业务相关的 UI
逻辑分离、职责清晰、代码量大幅减少。
6.4 踩坑提醒
坑 1:getInitialData 为什么必须是函数?
javascript
// ❌ 错误:直接传对象
const initialData = { username: '', email: '', status: 1 }
useForm({ getInitialData: initialData })
// 问题:每次 resetFields 重置时,拿到的都是同一个对象引用
// 如果 initialData 被修改过(比如编辑回填时),重置就不是真正的"初始值"了
// ✅ 正确:传一个函数,每次调用都返回一个全新的对象
useForm({
getInitialData: () => ({ username: '', email: '', status: 1 })
})
这和 Vue2 里 data 必须是函数是同一个道理------避免引用污染。
坑 2:编辑回填时直接赋值整个对象
javascript
// ❌ 错误
function openEdit(row) {
Object.assign(formData, row)
// 问题:row 里可能有 createdAt、updatedAt 等表单不需要的字段
// 提交时这些多余字段会被传给接口,可能导致后端报错
}
// ✅ 正确:只回填 formData 中定义过的字段
function openEdit(row) {
Object.keys(getInitialData()).forEach(key => {
if (row[key] !== undefined) {
formData[key] = row[key]
}
})
}
坑 3:弹窗关闭时的视觉闪烁
javascript
// ❌ 立即重置
function close() {
visible.value = false
resetFields() // 弹窗还在做关闭动画,用户会看到表单内容突然清空
// ✅ 延迟重置,等动画结束
function close() {
visible.value = false
setTimeout(() => {
resetFields()
}, 300) // Element Plus 弹窗动画时长大约 300ms
}
七、封装的设计原则与规范
写了三个实战 composable 之后,我们总结一下通用的设计原则:
7.1 命名规范
javascript
// ✅ 文件名和函数名保持一致
// composables/useRequest.js → export function useRequest()
// composables/useTable.js → export function useTable()
// ✅ 返回值命名清晰
return {
data, // 名词:状态数据
loading, // 形容词:状态标记
error, // 名词:错误信息
run, // 动词:操作方法
search, // 动词:操作方法
reset, // 动词:操作方法
}
// ❌ 避免模糊命名
return {
result, // result 是什么?请求结果?搜索结果?
flag, // flag 是什么?
handle, // handle 什么?
doIt, // do what?
}
7.2 参数设计
javascript
// ✅ 推荐:必选参数放前面,可选配置用 options 对象
export function useTable(apiFn, options = {}) {}
// ❌ 不推荐:一堆位置参数,调用时要记顺序
export function useTable(apiFn, pageSize, immediate, formatFn) {}
// ✅ 提供合理的默认值,让最简单的用法零配置
const { tableData, loading } = useTable(getUserList)
// 不传 options 也能正常工作
7.3 单一职责
javascript
// ✅ 一个 composable 做一件事
useRequest → 只管请求状态
useTable → 只管表格 + 分页
useForm → 只管表单 + 弹窗
// ❌ 不要做一个"万能"composable
usePageHelper → 又管表格、又管表单、又管权限、又管路由......
// 这种东西最终会变成新的"屎山"
7.4 组合优于继承
Composable 之间可以互相组合。比如 useTable 可以内部使用 useRequest:
javascript
// composables/useTable.js(组合版)
import { useRequest } from './useRequest'
export function useTable(apiFn, options = {}) {
const { data, loading, run } = useRequest(apiFn)
async function fetchData() {
const params = { page: pagination.currentPage, size: pagination.pageSize }
const res = await run(params)
// 处理 res...
}
// ...
}
这就是组合的威力:小函数组成大函数,每一层都清晰可控。
7.5 统一导出
javascript
// composables/index.js
export { useRequest } from './useRequest'
export { useTable } from './useTable'
export { useForm } from './useForm'
export { useLoading } from './useLoading'
// ...
使用时一行搞定:
javascript
import { useTable, useForm } from '@/composables'
八、常见问题 FAQ
Q1:Composable 里能用生命周期钩子吗?
可以。 在 composable 内部调用 onMounted、onUnmounted 等是完全合法的,前提是这个 composable 是在 setup 阶段被调用的(而不是在某个异步回调里调用)。
javascript
// ✅ 合法
export function useWindowResize() {
const width = ref(window.innerWidth)
function handler() {
width.value = window.innerWidth
}
onMounted(() => window.addEventListener('resize', handler))
onUnmounted(() => window.removeEventListener('resize', handler))
return { width }
}
Q2:Composable 之间怎么共享状态?
如果你需要在多个组件之间共享同一份状态(比如全局用户信息),有两种方式:
javascript
// 方式1:把状态定义在函数外面(模块级别的单例)
const globalUser = ref(null)
export function useUser() {
async function fetchUser() {
globalUser.value = await getUserInfo()
}
return { user: globalUser, fetchUser }
}
// 所有组件拿到的都是同一个 globalUser
// 方式2:更复杂的全局状态,建议用 Pinia
// composable 适合组件级的有状态逻辑
// Pinia 适合跨组件/跨页面的全局状态
Q3:和 React Hooks 有什么区别?
最核心的区别:Vue composable 只在 setup 时执行一次,React Hook 每次渲染都会执行。
javascript
// Vue:setup 只跑一次,后续数据变化靠响应式系统自动追踪
export function useCounter() {
const count = ref(0) // 只创建一次
const double = computed(() => count.value * 2) // 自动追踪
return { count, double }
}
// React:每次渲染都会重新执行,需要 useMemo/useCallback 优化
function useCounter() {
const [count, setCount] = useState(0) // 每次渲染都执行
const double = useMemo(() => count * 2, [count]) // 手动声明依赖
return { count, double }
}
所以 Vue 的 composable 不需要担心"闭包陷阱"和"依赖数组"这些 React 特有的问题,心智负担更小。
九、总结
| 维度 | Vue2 Mixin | Vue3 Composable |
|---|---|---|
| 来源透明性 | ❌ 变量来源不明 | ✅ 显式导入解构 |
| 命名冲突 | ❌ 静默覆盖 | ✅ 解构重命名 |
| 多实例 | ❌ 不支持 | ✅ 调多次即多份 |
| TypeScript | ❌ 几乎无法推导 | ✅ 完美支持 |
| 组合能力 | ❌ 难以互相调用 | ✅ 函数随意组合 |
| 调试体验 | ❌ 不知道值从哪来 | ✅ 断点直接跟进函数 |
三个核心封装的适用场景速查:
useRequest:任何需要调接口的地方(基础设施,其他 composable 的地基)useTable:所有列表/表格页面(中后台的半壁江山)useForm:所有弹窗表单场景(新增/编辑/详情)
封装心法:
- 先想清楚要管理哪些状态、暴露哪些方法
- 参数设计:必选在前,可选用 options 对象 + 合理默认值
- 单一职责,小函数组合成大函数
- 统一命名(
useXxx),统一目录(composables/),统一导出(index.js)
最后想说的是:Composable 不是什么高深技术,它就是"把逻辑写成函数"------这是编程最古老、最朴素、最强大的抽象方式。
Vue3 的 Composition API 只是给了我们一个在 Vue 框架里优雅地使用这种方式的能力。把它用好,你的代码会变得更干净、更可维护、更有生命力。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~