同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、开篇
如果你对 slot 或列配置还不熟,建议先看:
本篇会围绕「列配置 + slot 扩展 + 请求生命周期」三块,讲清楚:
- 表格组件的设计思路
- 如何用配置驱动列渲染
- 如何用 slot 做灵活扩展
- 如何把请求、loading、分页、错误统一收进组件
不追求底层原理,重点放在「怎么写、为什么这么写、容易踩什么坑」。
二、为什么要自己封装表格组件?
Element Plus、Ant Design Vue 的表格都很成熟,但业务里常遇到这些问题:
| 问题 | 痛点 |
|---|---|
每页都写 el-table-column | 代码重复,改一处要改很多地方 |
| 自定义列写法不统一 | 有的用 slot,有的写 formatter,风格混乱 |
| 请求逻辑散落各处 | loading、错误、分页分散在页面,难以复用 |
| 分页、筛选、刷新逻辑相似 | 每个列表页都要重写一套 |
封装一套表格组件,可以:
- 用列配置统一管理列,方便维护和扩展
- 用 slot 解决头像、操作按钮等自定义列
- 在组件内部统一处理 请求生命周期:loading、错误、分页、刷新
下面按「列配置 → slot 扩展 → 请求生命周期」的顺序展开。
三、第一步:列配置设计
3.1 为什么用配置数组,而不是写死 el-table-column?
硬编码写法:
<el-table :data="tableData">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="email" label="邮箱" min-width="180" />
</el-table>
问题在于:不同页面列不同、顺序不同,每次都要改模板,扩展性差。
用列配置的好处:
- 一份配置驱动多张表
- 列顺序、显隐、格式可以动态控制
- 容易和后台「列配置」或「动态列」对接
3.2 列配置的数据结构
定义列配置类型(TypeScript 项目可保留,纯 JS 可去掉类型):
/** 列配置项 */
export interface ColumnConfig {
prop: string // 数据字段名
label: string // 表头
width?: string // 列宽,如 '120px'
minWidth?: string // 最小宽度
slot?: string // 插槽名,有此字段则走 slot 渲染
formatter?: (row: any) => string | number // 格式化函数
align?: 'left' | 'center' | 'right'
fixed?: 'left' | 'right' // 固定列
}
使用示例:
const columns = [
{ prop: 'name', label: '姓名', width: '120px' },
{ prop: 'age', label: '年龄', width: '80px', align: 'center' },
{ prop: 'avatar', label: '头像', width: '80px', slot: 'avatar' },
{ prop: 'status', label: '状态', width: '100px', slot: 'status' },
{
prop: 'createdAt',
label: '创建时间',
width: '160px',
formatter: (row) => row.createdAt ? row.createdAt.slice(0, 10) : '-'
},
{ prop: 'action', label: '操作', width: '180px', slot: 'action', fixed: 'right' }
]
3.3 根据配置渲染列
在表格组件里用 v-for 遍历 columns:
<el-table :data="tableData" v-loading="loading" border>
<el-table-column
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
:fixed="col.fixed"
>
<!-- 情况1:配置了 slot,用插槽渲染 -->
<template v-if="col.slot" #default="{ row }">
<slot :name="col.slot" :row="row">
{{ row[col.prop] }}
</slot>
</template>
<!-- 情况2:配置了 formatter,用格式化结果 -->
<template v-else-if="col.formatter" #default="{ row }">
{{ col.formatter(row) }}
</template>
<!-- 情况3:默认直接显示 prop 对应字段 -->
</el-table-column>
</el-table>
要点:
v-for="col in columns":用配置驱动列col.slot时用<slot :name="col.slot">,实现按配置选择插槽- 兜底:没提供 slot 时显示
row[col.prop]
3.4 踩坑:为什么 slot 要写 :name 而不是 name?
错误写法:
<slot name="avatar" :row="row" />
这样只能匹配名为 avatar 的插槽,col.slot 可能是 avatar、action 等任意字符串。
正确写法:
<slot :name="col.slot" :row="row" />
:name 是动态绑定,col.slot 才能正确生效。
四、第二步:Slot 扩展
4.1 Slot 在这里的作用(简要回顾)
Slot 是父组件往子组件「占位符」里插入内容的方式。作用域插槽则允许子组件把数据(如当前行 row)传给父组件。
表格场景:某些列需要头像、标签、按钮等自定义内容,无法用 prop 或 formatter 完成,就需要 slot。
4.2 作用域插槽:如何把 row 传给父组件?
子组件(BaseTable)里:
<slot :name="col.slot" :row="row" />
父组件使用:
<BaseTable :columns="columns" :fetch="fetchList" :query="searchForm">
<template #avatar="{ row }">
<el-avatar :src="row.avatar" :size="32" />
</template>
<template #action="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</BaseTable>
#avatar 是 v-slot:avatar 的简写,{ row } 是子组件通过 :row="row" 传上来的插槽 props。
4.3 兜底:父组件没有提供 slot 时
如果列配置里写了 slot: 'avatar',但父组件没写 #avatar,默认会显示空白。可以在 slot 里加兜底内容:
<slot :name="col.slot" :row="row">
{{ row[col.prop] }}
</slot>
这样未提供插槽时,会显示原始字段值。
4.4 传递更多上下文:index、column 等
有些场景需要行下标、列配置等,可以一起传:
<slot
:name="col.slot"
:row="row"
:column="col"
:index="index"
/>
父组件:
<template #action="{ row, column, index }">
<el-button @click="handleEdit(row, index)">编辑</el-button>
</template>
五、第三步:请求生命周期
5.1 什么叫「请求生命周期」
这里指的是「发起请求 → loading → 成功/失败 → 刷新」的完整流程。我们希望:
- 进入页面自动请求
- 分页、筛选、排序时自动重新请求
- loading、错误、空状态统一处理
- 对外暴露
refresh(),支持手动刷新
5.2 设计思路
组件接收:
fetch(query, page, pageSize):返回Promise<{ list, total }>query:查询条件对象pageSize:每页条数(可选)
组件内部维护:
tableData、total、loading、errorcurrentPageloadData():执行请求refresh():重置页码并重新请求,通过defineExpose暴露
5.3 完整请求逻辑代码
// BaseTable.vue - script 部分
const props = defineProps({
columns: { type: Array, required: true },
fetch: { type: Function, required: true },
query: { type: Object, default: () => ({}) },
pageSize: { type: Number, default: 10 }
})
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)
const loadData = async () => {
loading.value = true
error.value = null
try {
const res = await props.fetch({
...props.query,
page: currentPage.value,
pageSize: props.pageSize
})
// 兼容 { list, total } 和 { data: { list, total } }
const result = res?.list !== undefined ? res : res?.data
tableData.value = result?.list ?? []
total.value = result?.total ?? 0
} catch (e) {
error.value = e?.message || '请求失败,请稍后重试'
tableData.value = []
} finally {
loading.value = false
}
}
const handlePageChange = (page) => {
currentPage.value = page
loadData()
}
const refresh = () => {
currentPage.value = 1
loadData()
}
defineExpose({ refresh })
// query 变化时重置页码并重新请求
watch(
() => props.query,
() => {
currentPage.value = 1
loadData()
},
{ deep: true }
)
onMounted(loadData)
5.4 常见坑与对策
| 坑 | 原因 | 建议 |
|---|---|---|
| 接口结构不统一 | 有的 { list, total },有的 { data: { list, total } } | 在 loadData 里做兼容,或约束接口规范 |
| query 变化不触发请求 | 对象引用未变,watch 不触发 | 用 watch(() => props.query, ..., { deep: true }) |
| 组件销毁后仍 setState | 异步返回时组件已卸载 | 用 onBeforeUnmount + 标记,或在请求里用 AbortController |
| 初始化请求两次 | watch 和 onMounted 同时触发 | 可用 immediate: false,或统一用 watch 初始化 |
六、完整组件代码
下面是基于 Element Plus 的完整表格组件(Vue 3 + Composition API):
<!-- BaseTable.vue -->
<template>
<div class="base-table">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
:fixed="col.fixed"
>
<template v-if="col.slot" #default="{ row, $index }">
<slot :name="col.slot" :row="row" :column="col" :index="$index">
{{ row[col.prop] }}
</slot>
</template>
<template v-else-if="col.formatter" #default="{ row }">
{{ col.formatter(row) }}
</template>
</el-table-column>
</el-table>
<div v-if="error" class="error-tip">
{{ error }}
</div>
<el-pagination
v-if="total > 0"
class="pagination"
:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next, jumper"
@current-change="handlePageChange"
/>
<el-empty
v-if="!loading && tableData.length === 0 && !error"
description="暂无数据"
:image-size="80"
/>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
const props = defineProps({
columns: { type: Array, required: true },
fetch: { type: Function, required: true },
query: { type: Object, default: () => ({}) },
pageSize: { type: Number, default: 10 }
})
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)
const loadData = async () => {
loading.value = true
error.value = null
try {
const res = await props.fetch({
...props.query,
page: currentPage.value,
pageSize: props.pageSize
})
const result = res?.list !== undefined ? res : res?.data
tableData.value = result?.list ?? []
total.value = result?.total ?? 0
} catch (e) {
error.value = e?.message || '请求失败,请稍后重试'
tableData.value = []
} finally {
loading.value = false
}
}
const handlePageChange = (page) => {
currentPage.value = page
loadData()
}
const refresh = () => {
currentPage.value = 1
loadData()
}
defineExpose({ refresh })
watch(
() => props.query,
() => {
currentPage.value = 1
loadData()
},
{ deep: true }
)
onMounted(loadData)
</script>
<style scoped>
.base-table {
padding: 16px 0;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.error-tip {
color: #f56c6c;
margin-top: 12px;
font-size: 14px;
}
</style>
七、使用示例
<!-- UserList.vue -->
<template>
<div class="user-list">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="姓名/邮箱" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<BaseTable
ref="tableRef"
:columns="columns"
:fetch="fetchUserList"
:query="searchForm"
>
<template #avatar="{ row }">
<el-avatar :src="row.avatar" :size="36">
{{ row.name?.charAt(0) }}
</el-avatar>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
<template #action="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</BaseTable>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import BaseTable from '@/components/BaseTable.vue'
const tableRef = ref(null)
const searchForm = reactive({
keyword: ''
})
const columns = [
{ prop: 'name', label: '姓名', width: '120px' },
{ prop: 'avatar', label: '头像', width: '80px', slot: 'avatar' },
{ prop: 'age', label: '年龄', width: '80px', align: 'center' },
{ prop: 'email', label: '邮箱', minWidth: '180px' },
{ prop: 'status', label: '状态', width: '100px', slot: 'status' },
{
prop: 'createdAt',
label: '创建时间',
width: '160px',
formatter: (row) => row.createdAt ? row.createdAt.slice(0, 10) : '-'
},
{ prop: 'action', label: '操作', width: '180px', slot: 'action', fixed: 'right' }
]
const fetchUserList = async (params) => {
const res = await axios.get('/api/users', { params })
return res.data // 约定返回 { list: [...], total: 100 }
}
const handleSearch = () => {
tableRef.value?.refresh()
}
const handleReset = () => {
searchForm.keyword = ''
tableRef.value?.refresh()
}
const handleEdit = (row) => {
// 编辑逻辑
}
const handleDelete = (row) => {
// 删除逻辑,成功后调用 tableRef.value?.refresh()
}
</script>
八、可扩展方向
在现有基础上可以继续做这些扩展:
| 扩展点 | 思路 |
|---|---|
| 多选 | 增加 el-table-column type="selection",用 v-model 或事件暴露选中行 |
| 排序 | 在列配置加 sortable,在 fetch 参数里传 sortField、sortOrder |
| 列显隐 | 列配置加 visible,用 computed 过滤再渲染 |
| 空状态插槽 | 用 <slot name="empty"> 替代固定 el-empty |
| 表格顶部工具栏 | 用 slot 预留 header,放刷新、导出等按钮 |
九、小结
- 列配置:用配置数组驱动列,支持动态列和统一维护。
- Slot 扩展:通过
slot字段 + 作用域插槽,把自定义列交给父组件,并处理好兜底。 - 请求生命周期:在组件内统一处理 loading、错误、分页和刷新,调用方只需提供
fetch和query。
按这个思路封装表格组件,既能复用逻辑,又能通过 slot 保持灵活性,适合作为中后台的基础表格组件。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~