【
Vue3+TypeScript+Element Plus】×【中后台列表页 / 表格页】:从「columns/actions/pagination三块配置抽象」到「ConfigTable统一渲染 + 页面级分页双向绑定」,彻底搞懂配置驱动表格 的工程化写法,避开行 key 不稳、formatter塞业务、改pageSize不回第一页、权限写死模板与配置散落等高频坑!

📑 文章目录
- 一、这篇文章解决什么问题?
- 二、先把概念讲人话:什么叫"配置驱动"?
- 三、最终效果(你会得到什么)
- 四、项目技术栈说明
- 五、核心类型设计(先把"规则"定清楚)
- 六、通用表格组件实现(重点)
- 七、页面如何使用(完整示例)
- 八、为什么这样设计?(你真正需要建立的习惯)
- [1)列配置里为什么有
prop、formatter、slot三层?](#1)列配置里为什么有 prop、formatter、slot 三层?) - [2)为什么操作配置要有
visible和disabled?](#2)为什么操作配置要有 visible 和 disabled?) - [3)分页为什么要统一
v-model:pagination?](#3)分页为什么要统一 v-model:pagination?)
- [1)列配置里为什么有
- 九、常见坑位(高频踩坑清单)
- [坑 1:
key用 index 导致行错乱](#坑 1:key 用 index 导致行错乱) - [坑 2:
formatter里写太重逻辑](#坑 2:formatter 里写太重逻辑) - [坑 3:分页改
pageSize不重置页码](#坑 3:分页改 pageSize 不重置页码) - [坑 4:操作列权限写死在模板里](#坑 4:操作列权限写死在模板里)
- [坑 5:配置对象散落各处](#坑 5:配置对象散落各处)
- [坑 1:
- 十、进阶建议(你可以继续演进)
- 十一、给前端的"习惯校准建议"
- 十二、总结
- [🔍 系列模块导航](#🔍 系列模块导航)
- [📝 配置驱动开发实战](#📝 配置驱动开发实战)
- [📚 系列总览](#📚 系列总览)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构。
面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维。
这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。
帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。
一、这篇文章解决什么问题?
很多项目里,表格页面都长这样:
- 页面 A 有表格、分页、操作按钮
- 页面 B 也有,基本一样
- 页面 C 还要再复制一份,然后改几个字段
最后就会出现:
- 重复代码越来越多
- 字段改名要改 10 个页面
- 新人接手成本高
- 功能迭代慢,bug 难查
配置驱动开发 要解决的就是:
把"写死在模板里的规则"提炼成"配置",让渲染逻辑统一、行为可控、维护成本更低。
这篇我们只讲实战,不讲玄学。核心是三块配置:
- 列配置(columns)
- 行操作配置(actions)
- 分页配置(pagination)
[⬆ 返回目录](#⬆ 返回目录)
二、先把概念讲人话:什么叫"配置驱动"?
传统写法(命令式):
- 我在模板里一个个写
<th>、<td>、按钮和分页 - 业务改了,我就改模板和方法
配置驱动写法(声明式):
- 我先定义"这张表长什么样":列有哪些、按钮有哪些、分页怎么走
- 表格组件只负责"读配置并渲染"
- 页面只管传数据和处理事件
一句话:把"怎么画"交给组件,把"画什么"交给配置。
[⬆ 返回目录](#⬆ 返回目录)
三、最终效果(你会得到什么)
我们将做一个 ConfigTable.vue,支持:
- 列配置:文本列、格式化列、插槽列
- 操作配置:查看/编辑/删除,支持显示条件和禁用条件
- 分页配置:页码/页大小/总数,统一
v-model双向绑定 - 事件透出:
action、page-change、size-change - 可读性优先:类型清晰、命名统一、默认值完整
[⬆ 返回目录](#⬆ 返回目录)
四、项目技术栈说明
- Vue 3 +
<script setup>+ TypeScript - UI 库:Element Plus(企业项目常用,学习成本低)
你也可以替换成 Ant Design Vue 或自己封装组件,思路一样。
[⬆ 返回目录](#⬆ 返回目录)
五、核心类型设计(先把"规则"定清楚)
新建 types/table.ts:
ts
import type { VNodeChild } from 'vue'
export interface TableColumnConfig<Row = Record<string, any>> {
key: string
label: string
width?: number | string
minWidth?: number | string
align?: 'left' | 'center' | 'right'
// 普通字段取值,比如 row.name
prop?: keyof Row | string
// 自定义渲染(优先级高于 prop)
formatter?: (row: Row, index: number) => VNodeChild | string | number
// 是否使用具名插槽渲染
slot?: string
}
export interface TableActionConfig<Row = Record<string, any>> {
key: string
label: string
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
text?: boolean
// 是否展示(例如:只有管理员可见)
visible?: (row: Row, index: number) => boolean
// 是否禁用(例如:已锁定不可删除)
disabled?: (row: Row, index: number) => boolean
// 二次确认文案(可选)
confirmText?: string
}
export interface TablePaginationConfig {
page: number
pageSize: number
total: number
pageSizes?: number[]
layout?: string
}
export interface ConfigTableProps<Row = Record<string, any>> {
loading?: boolean
columns: TableColumnConfig<Row>[]
data: Row[]
actions?: TableActionConfig<Row>[]
rowKey?: string | ((row: Row) => string | number)
pagination?: TablePaginationConfig
}
为什么先写类型?
因为类型就是你的"规范文档":
- 新人一看就知道这张表支持什么能力
- 配置传错,编辑器立即提示
- 避免"靠约定、靠记忆"导致线上踩坑
[⬆ 返回目录](#⬆ 返回目录)
六、通用表格组件实现(重点)
新建 components/ConfigTable.vue:
html
<template>
<div class="config-table">
<el-table
:data="data"
:loading="loading"
:row-key="rowKey"
border
style="width: 100%"
>
<!-- 动态列渲染 -->
<el-table-column
v-for="(col, colIndex) in columns"
:key="col.key || colIndex"
:label="col.label"
:prop="col.prop as string"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
>
<template #default="{ row, $index }">
<!-- 1) 插槽优先 -->
<slot
v-if="col.slot"
:name="col.slot"
:row="row"
:index="$index"
/>
<!-- 2) formatter 次之 -->
<template v-else-if="col.formatter">
{{ col.formatter(row, $index) }}
</template>
<!-- 3) 最后才是 prop -->
<template v-else>
{{ getCellValue(row, col.prop as string) }}
</template>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column
v-if="actions && actions.length"
label="操作"
fixed="right"
:width="actionColumnWidth"
align="center"
>
<template #default="{ row, $index }">
<template v-for="action in actions" :key="action.key">
<el-popconfirm
v-if="shouldShowAction(action, row, $index) && action.confirmText"
:title="action.confirmText"
@confirm="handleAction(action, row, $index)"
>
<template #reference>
<el-button
:type="action.type || 'primary'"
:text="action.text ?? true"
:disabled="isActionDisabled(action, row, $index)"
>
{{ action.label }}
</el-button>
</template>
</el-popconfirm>
<el-button
v-else-if="shouldShowAction(action, row, $index)"
:type="action.type || 'primary'"
:text="action.text ?? true"
:disabled="isActionDisabled(action, row, $index)"
@click="handleAction(action, row, $index)"
>
{{ action.label }}
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div v-if="pagination" class="pager-wrap">
<el-pagination
background
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="pagination.pageSizes || [10, 20, 50, 100]"
:layout="
pagination.layout ||
'total, sizes, prev, pager, next, jumper'
"
@current-change="onCurrentChange"
@size-change="onSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {
ConfigTableProps,
TableActionConfig
} from '@/types/table'
type RowData = Record<string, any>
const props = withDefaults(defineProps<ConfigTableProps<RowData>>(), {
loading: false,
actions: () => [],
rowKey: 'id'
})
const emit = defineEmits<{
(e: 'action', payload: { actionKey: string; row: RowData; index: number }): void
(e: 'update:pagination', val: {
page: number
pageSize: number
total: number
pageSizes?: number[]
layout?: string
}): void
(e: 'page-change', page: number): void
(e: 'size-change', pageSize: number): void
}>()
const actionColumnWidth = computed(() => {
// 简单估算宽度,避免按钮挤压换行
const count = props.actions?.length || 0
return Math.max(120, count * 70)
})
function getCellValue(row: RowData, prop?: string) {
if (!prop) return '--'
const value = row[prop]
return value === null || value === undefined || value === '' ? '--' : value
}
function shouldShowAction(
action: TableActionConfig<RowData>,
row: RowData,
index: number
) {
return action.visible ? action.visible(row, index) : true
}
function isActionDisabled(
action: TableActionConfig<RowData>,
row: RowData,
index: number
) {
return action.disabled ? action.disabled(row, index) : false
}
function handleAction(
action: TableActionConfig<RowData>,
row: RowData,
index: number
) {
emit('action', { actionKey: action.key, row, index })
}
function onCurrentChange(page: number) {
if (!props.pagination) return
emit('update:pagination', { ...props.pagination, page })
emit('page-change', page)
}
function onSizeChange(pageSize: number) {
if (!props.pagination) return
// 常见约定:修改 pageSize 后回到第一页
emit('update:pagination', { ...props.pagination, pageSize, page: 1 })
emit('size-change', pageSize)
}
</script>
<style scoped>
.config-table {
width: 100%;
}
.pager-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
[⬆ 返回目录](#⬆ 返回目录)
七、页面如何使用(完整示例)
新建 views/UserList.vue:
html
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="header">用户列表(配置驱动示例)</div>
</template>
<ConfigTable
v-model:pagination="pagination"
:loading="loading"
:columns="columns"
:data="tableData"
:actions="actions"
row-key="id"
@action="onTableAction"
@page-change="fetchList"
@size-change="fetchList"
>
<!-- 插槽列示例 -->
<template #statusSlot="{ row }">
<el-tag :type="row.status === 'enabled' ? 'success' : 'info'">
{{ row.status === 'enabled' ? '启用' : '禁用' }}
</el-tag>
</template>
</ConfigTable>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import ConfigTable from '@/components/ConfigTable.vue'
import type {
TableActionConfig,
TableColumnConfig,
TablePaginationConfig
} from '@/types/table'
import { ElMessage, ElMessageBox } from 'element-plus'
interface UserItem {
id: number
name: string
age: number
role: 'admin' | 'user'
status: 'enabled' | 'disabled'
createdAt: string
}
const loading = ref(false)
const tableData = ref<UserItem[]>([])
const pagination = ref<TablePaginationConfig>({
page: 1,
pageSize: 10,
total: 0
})
const columns: TableColumnConfig<UserItem>[] = [
{ key: 'name', label: '姓名', prop: 'name', minWidth: 120 },
{ key: 'age', label: '年龄', prop: 'age', width: 80, align: 'center' },
{
key: 'role',
label: '角色',
prop: 'role',
minWidth: 100,
formatter: (row) => (row.role === 'admin' ? '管理员' : '普通用户')
},
{
key: 'status',
label: '状态',
slot: 'statusSlot',
minWidth: 120,
align: 'center'
},
{ key: 'createdAt', label: '创建时间', prop: 'createdAt', minWidth: 180 }
]
const actions: TableActionConfig<UserItem>[] = [
{ key: 'view', label: '查看', type: 'primary' },
{ key: 'edit', label: '编辑', type: 'warning' },
{
key: 'delete',
label: '删除',
type: 'danger',
confirmText: '确定要删除该用户吗?',
// 管理员不允许删除,作为禁用示例
disabled: (row) => row.role === 'admin'
}
]
async function fetchList() {
loading.value = true
try {
// 这里模拟接口请求
const { page, pageSize } = pagination.value
const total = 35
const list: UserItem[] = Array.from({ length: pageSize }).map((_, i) => {
const id = (page - 1) * pageSize + i + 1
return {
id,
name: `用户${id}`,
age: 18 + (id % 10),
role: id % 7 === 0 ? 'admin' : 'user',
status: id % 3 === 0 ? 'disabled' : 'enabled',
createdAt: '2026-03-26 10:00:00'
}
})
tableData.value = list
pagination.value.total = total
} finally {
loading.value = false
}
}
async function onTableAction(payload: {
actionKey: string
row: UserItem
index: number
}) {
const { actionKey, row } = payload
if (actionKey === 'view') {
ElMessage.info(`查看用户:${row.name}`)
return
}
if (actionKey === 'edit') {
ElMessage.success(`编辑用户:${row.name}`)
return
}
if (actionKey === 'delete') {
await ElMessageBox.alert(`已删除用户:${row.name}`, '提示')
fetchList()
}
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
font-weight: 600;
}
</style>
[⬆ 返回目录](#⬆ 返回目录)
八、为什么这样设计?(你真正需要建立的习惯)
1)列配置里为什么有 prop、formatter、slot 三层?
prop:最基础、最快速,适合简单字段展示formatter:轻量格式化,适合"男女/状态文案/日期字符串"slot:复杂 UI,适合标签、头像、进度条、组合内容
推荐优先级 :prop > formatter > slot(按复杂度升级)
不要一上来全用 slot,会让模板过重。
[⬆ 返回目录](#⬆ 返回目录)
2)为什么操作配置要有 visible 和 disabled?
因为这两个语义不同:
visible:是否显示(权限维度)disabled:显示但不可点(状态维度)
很多项目把两者混在一起,最后业务逻辑很乱。分清语义,是可维护性的关键。
[⬆ 返回目录](#⬆ 返回目录)
3)分页为什么要统一 v-model:pagination?
因为分页状态通常是"页面级状态":
- 请求参数依赖它
- 搜索条件重置依赖它
- 路由缓存恢复依赖它
把它统一双向绑定,比组件内私有状态更可控,也更容易做"列表页状态记忆"。
[⬆ 返回目录](#⬆ 返回目录)
九、常见坑位(高频踩坑清单)
坑 1:key 用 index 导致行错乱
- 行
key必须稳定,优先后端id - 分页切换、排序、筛选时最容易出问题
[⬆ 返回目录](#⬆ 返回目录)
坑 2:formatter 里写太重逻辑
formatter只做展示转换,不做异步、不改状态- 复杂逻辑放到组合函数/composable 里
[⬆ 返回目录](#⬆ 返回目录)
坑 3:分页改 pageSize 不重置页码
- 常见 bug:从第 10 页改为 20 条后请求空数据
- 规范:改页大小后
page = 1
[⬆ 返回目录](#⬆ 返回目录)
坑 4:操作列权限写死在模板里
- 后续改权限体系时很痛苦
- 建议统一写在
actions配置中,逻辑集中可复用
[⬆ 返回目录](#⬆ 返回目录)
坑 5:配置对象散落各处
columns、actions最好按页面维度统一管理- 长期建议抽到
xxx.table-config.ts,方便复用和测试
[⬆ 返回目录](#⬆ 返回目录)
十、进阶建议(你可以继续演进)
- 支持列
sortable、filters,把筛选排序也配置化 - 支持"表格工具栏配置"(新增/导出/批量操作)
- 支持"远程分页 + 查询表单"一体化封装
- 配合路由 query 做分页与筛选状态回填
- 给配置加单元测试(特别是
visible/disabled条件函数)
[⬆ 返回目录](#⬆ 返回目录)
十一、给前端的"习惯校准建议"
如果你已经写了很多年业务代码,这里有三个非常实用的校准点:
- 少写重复模板,多写结构化配置
- 少靠"记忆约定",多靠类型约束
- 少在组件里塞业务细节,多做事件透出和职责分离
这不是为了"炫技",而是为了你半年后回来看代码,还能快速改、放心改。
[⬆ 返回目录](#⬆ 返回目录)
十二、总结
配置驱动并不神秘,本质就是一句话:
把变化点变成配置,把稳定逻辑变成组件。
当你把"列、操作、分页"这三块统一起来,表格页会从"复制粘贴地狱"走向"可维护工程化"。
先把这套用在 1~2 个真实页面上,你会马上感受到收益。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 配置驱动开发实战
持续更新中,敬请期待~
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
[⬆ 返回目录](#⬆ 返回目录)
📚 系列总览
前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试
四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力
- 前端基础实战系列 : 《前端基础实战:JS/TS与Vue体系化扫盲(47 篇完整目录 + 避坑)》
- 前端规范实战系列 : 《JS/TS/Vue 前端规范实战:从写对到写优,搞定中后台规范落地,打造可维护代码(40 篇全目录)》
- 前端架构实战系列:聚焦工程化、性能优化、可维护架构、中后台体系设计(持续更新中)
- 前端大厂面试系列:覆盖高频考点、手写题、项目深挖、简历与面试技巧(规划中)
每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。
全套内容持续更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
前端的成长路径很清晰:
会写代码 → 写规范代码 → 做可扩展架构。
每一步,都是职业晋升的关键台阶。
后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。
我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~