【Vue3+Element Plus】中后台表格实战:从统一状态设计到落地实操,彻底搞定批量操作、行内编辑、跨页选中,避开表格逻辑混乱高频坑!
📑 文章目录
- 一、开篇:为什么表格也要讲「规范」?
- 二、目标与整体设计思路
- 三、核心数据结构设计
- [3.1 要存哪些状态?](#3.1 要存哪些状态?)
- [3.2 为什么用 Set 存 selectedIds?](#3.2 为什么用 Set 存 selectedIds?)
- 四、跨页选中:实现思路与常见坑
- [4.1 需求拆解](#4.1 需求拆解)
- [4.2 实现示例(含注释)](#4.2 实现示例(含注释))
- [4.3 容易踩的坑](#4.3 容易踩的坑)
- [五、批量操作:基于 selectedIds 执行](#五、批量操作:基于 selectedIds 执行)
- [5.1 示例:批量删除](#5.1 示例:批量删除)
- [5.2 获取「跨页选中」的完整行数据](#5.2 获取「跨页选中」的完整行数据)
- [六、行内编辑:缓存 + 脏检查](#六、行内编辑:缓存 + 脏检查)
- [6.1 设计思路](#6.1 设计思路)
- [6.2 完整示例](#6.2 完整示例)
- [6.3 行内编辑常见问题](#6.3 行内编辑常见问题)
- 七、三者合一的完整示例
- 八、规范小结
- [九、延伸:可抽成 Composable](#九、延伸:可抽成 Composable)
- [🔍 系列模块导航](#🔍 系列模块导航)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
一、开篇:为什么表格也要讲「规范」?
表格是后台管理系统里用得最多的组件,但很多项目里会乱成一锅粥:
批量勾选、行内编辑、分页选中各自一套逻辑,维护成本高,容易踩坑。
这篇从可复用的设计思路 出发,把「批量操作、行内编辑、跨页选中」这三类常见需求串起来,用一套统一的逻辑搞定,方便新人理解和老手自查。
[⬆ 返回目录](#⬆ 返回目录)
二、目标与整体设计思路
我们要做到三点:
- 批量操作:勾选若干行,统一执行删除、导出、状态变更等
- 行内编辑:支持行内修改,保存前区分「已修改/未修改」
- 跨页选中:分页时,上一页的勾选状态要保留,全选只影响当前页
设计原则:选中状态统一管理,操作基于选中数据,而不是临时变量。
[⬆ 返回目录](#⬆ 返回目录)
三、核心数据结构设计
3.1 要存哪些状态?
js
// 统一的状态中心
const tableState = {
// 当前页展示的数据(接口返回的)
list: [],
// 跨页选中的行,用唯一 id 集合存储
// 为什么用 Set?去重 + 快速判断是否存在
selectedIds: new Set(),
// 行内编辑的缓存:{ rowId: 原始数据副本 }
// 只有「进入编辑」的行才有缓存,便于判断是否修改过
editCache: {},
// 分页信息
pagination: {
current: 1,
pageSize: 10,
total: 0
}
}
要点:
selectedIds用Set,便于增删和判断,也自然去重editCache只存「正在编辑」的行,用于对比和回滚
[⬆ 返回目录](#⬆ 返回目录)
3.2 为什么用 Set 存 selectedIds?
js
// ❌ 用数组:每次都要 indexOf/includes,数据量大时性能差
const ids = [1, 2, 3]
ids.includes(2) // O(n)
// ✅ 用 Set:判断、增删都是 O(1)
const ids = new Set([1, 2, 3])
ids.has(2) // O(1)
ids.add(4)
ids.delete(2)
表格行数多、跨页选中时,用 Set 会更合适。
[⬆ 返回目录](#⬆ 返回目录)
四、跨页选中:实现思路与常见坑
4.1 需求拆解
- 勾选某行 → 加入
selectedIds - 取消勾选 → 从
selectedIds移除 - 翻页 → 列表变了,但
selectedIds不变 - 全选当前页 → 当前页所有 id 加入
selectedIds - 取消全选当前页 → 只从
selectedIds中移除当前页的 id
[⬆ 返回目录](#⬆ 返回目录)
4.2 实现示例(含注释)
html
<template>
<div class="table-container">
<el-table
ref="tableRef"
:data="tableState.list"
@selection-change="onSelectionChange"
>
<!-- 注意:row-key 必填!跨页选中依赖每一行的唯一标识 -->
<el-table-column type="selection" width="55" :reserve-selection="true" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="status" label="状态" />
</el-table>
<el-pagination
v-model:current-page="tableState.pagination.current"
v-model:page-size="tableState.pagination.pageSize"
:total="tableState.pagination.total"
@current-change="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
// 表格 ref,用于手动设置勾选
const tableRef = ref(null)
const tableState = reactive({
list: [],
selectedIds: new Set(),
pagination: { current: 1, pageSize: 10, total: 0 }
})
// 获取列表数据
async function fetchList() {
const { current, pageSize } = tableState.pagination
const res = await api.getList({ page: current, size: pageSize })
tableState.list = res.data.list
tableState.pagination.total = res.data.total
// 关键:数据更新后,下一帧再恢复勾选状态(否则 DOM 还没渲染完)
await nextTick()
restoreSelection()
}
// 勾选变化时,同步到 selectedIds
function onSelectionChange(rows) {
const currentPageIds = new Set(tableState.list.map(item => item.id))
// 先移除当前页所有 id(因为 Element Plus 只告诉你「当前选中的行」)
currentPageIds.forEach(id => tableState.selectedIds.delete(id))
// 再把当前页选中的加回去
rows.forEach(row => tableState.selectedIds.add(row.id))
}
// 恢复勾选:根据 selectedIds 反向设置 el-table 的勾选
function restoreSelection() {
if (!tableRef.value) return
tableState.list.forEach(row => {
const isSelected = tableState.selectedIds.has(row.id)
tableRef.value.toggleRowSelection(row, isSelected)
})
}
// 翻页、刷新时都要恢复勾选
onMounted(fetchList)
</script>
[⬆ 返回目录](#⬆ 返回目录)
4.3 容易踩的坑
| 坑点 | 说明 | 正确做法 |
|---|---|---|
没设 row-key |
表格无法区分不同页的同位置行 | 给 el-table 加 row-key="id" |
reserve-selection 没开 |
翻页后勾选会被清空 | :reserve-selection="true" |
| 数据刚更新就恢复勾选 | DOM 未渲染,toggleRowSelection 无效 |
用 nextTick() 后再调用 |
| 全选逻辑只改本地数组 | 翻页后丢失 | 全选/取消全选都通过 onSelectionChange 更新 selectedIds |
[⬆ 返回目录](#⬆ 返回目录)
五、批量操作:基于 selectedIds 执行
批量操作的入口统一用 selectedIds,这样和是否跨页无关。
5.1 示例:批量删除
js
// 获取选中的完整行数据(用于展示、调用接口)
function getSelectedRows() {
// 如果接口需要完整行数据,需要从 list + 其他页缓存 中取
// 简单场景:只传 id 列表即可
return Array.from(tableState.selectedIds)
}
async function batchDelete() {
const ids = getSelectedRows()
if (ids.length === 0) {
ElMessage.warning('请先勾选要删除的数据')
return
}
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条数据?`)
await api.batchDelete(ids)
ElMessage.success('删除成功')
clearSelection()
fetchList() // 刷新列表
}
function clearSelection() {
tableState.selectedIds.clear()
tableRef.value?.clearSelection()
}
[⬆ 返回目录](#⬆ 返回目录)
5.2 获取「跨页选中」的完整行数据
若接口或展示需要完整行,而不仅仅是 id:
js
// 方案一:只传 id,后端根据 id 处理(最常见)
// 批量删除、批量导出等,一般用 id 即可
// 方案二:必须传完整行时,需要缓存历史页数据
const allSelectedRowsMap = ref(new Map()) // { id: row }
function onSelectionChange(rows) {
const currentPageIds = new Set(tableState.list.map(item => item.id))
currentPageIds.forEach(id => {
tableState.selectedIds.delete(id)
allSelectedRowsMap.value.delete(id)
})
rows.forEach(row => {
tableState.selectedIds.add(row.id)
allSelectedRowsMap.value.set(row.id, { ...row })
})
}
function getSelectedRows() {
return Array.from(allSelectedRowsMap.value.values())
}
实际项目中,优先用 id,只在确实需要完整行时再考虑缓存。
[⬆ 返回目录](#⬆ 返回目录)
六、行内编辑:缓存 + 脏检查
6.1 设计思路
- 点击「编辑」→ 把该行数据拷贝进
editCache - 修改输入框 → 只改
editCache里的副本,不动原始list - 保存 → 用
editCache调接口,成功后更新list并清缓存 - 取消 → 删除
editCache中该行,恢复原始展示
[⬆ 返回目录](#⬆ 返回目录)
6.2 完整示例
html
<template>
<el-table :data="tableState.list" row-key="id">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-input v-model="editCache[row.id].name" size="small" />
</template>
<template v-else>
{{ row.name }}
</template>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-select v-model="editCache[row.id].status" size="small">
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
</el-select>
</template>
<template v-else>
{{ row.status === 'active' ? '启用' : '禁用' }}
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-button type="primary" size="small" @click="saveRow(row)">保存</el-button>
<el-button size="small" @click="cancelEdit(row)">取消</el-button>
</template>
<template v-else>
<el-button type="primary" link size="small" @click="startEdit(row)">编辑</el-button>
</template>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import { reactive } from 'vue'
const tableState = reactive({
list: [
{ id: 1, name: '张三', status: 'active' },
{ id: 2, name: '李四', status: 'inactive' }
]
})
// 编辑缓存:{ rowId: 该行数据的深拷贝 }
const editCache = reactive({})
function isEditing(id) {
return id in editCache
}
// 进入编辑:深拷贝一份进缓存
function startEdit(row) {
editCache[row.id] = JSON.parse(JSON.stringify(row))
}
// 取消编辑:删除缓存,界面会自动恢复显示 list 中的原始数据
function cancelEdit(row) {
delete editCache[row.id]
}
// 判断是否有修改
function hasChange(row) {
const cached = editCache[row.id]
if (!cached) return false
return JSON.stringify(cached) !== JSON.stringify(row)
}
// 保存
async function saveRow(row) {
const cached = editCache[row.id]
if (!hasChange(row)) {
ElMessage.info('没有修改')
cancelEdit(row)
return
}
await api.updateRow(cached)
Object.assign(row, cached) // 写回 list
delete editCache[row.id]
ElMessage.success('保存成功')
}
</script>
[⬆ 返回目录](#⬆ 返回目录)
6.3 行内编辑常见问题
| 问题 | 原因 | 处理方式 |
|---|---|---|
| 修改后整行闪一下 | 直接改 list,又用 v-if 切换 |
用 editCache 副本,不改原数据 |
| 取消后数据没恢复 | 原数据已被污染 | 取消时从 editCache 删除即可,不改 list |
| 深拷贝丢失函数/Date | JSON.parse(JSON.stringify) 有局限 |
简单对象够用;复杂结构用 structuredClone 或 lodash cloneDeep |
[⬆ 返回目录](#⬆ 返回目录)
七、三者合一的完整示例
下面把跨页选中、批量操作、行内编辑放在一起,用同一套状态和逻辑。
html
<template>
<div class="table-page">
<!-- 批量操作栏 -->
<div v-if="tableState.selectedIds.size" class="batch-bar">
已选 {{ tableState.selectedIds.size }} 条
<el-button type="danger" size="small" @click="batchDelete">批量删除</el-button>
<el-button size="small" @click="clearSelection">清空选择</el-button>
</div>
<el-table
ref="tableRef"
:data="tableState.list"
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="55" :reserve-selection="true" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-input v-model="editCache[row.id].name" size="small" />
</template>
<span v-else>{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-select v-model="editCache[row.id].status" size="small">
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
</el-select>
</template>
<span v-else>{{ row.status === 'active' ? '启用' : '禁用' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="isEditing(row.id)">
<el-button type="primary" size="small" @click="saveRow(row)">保存</el-button>
<el-button size="small" @click="cancelEdit(row)">取消</el-button>
</template>
<el-button v-else type="primary" link size="small" @click="startEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="tableState.pagination.current"
v-model:page-size="tableState.pagination.pageSize"
:total="tableState.pagination.total"
layout="total, sizes, prev, pager, next"
@current-change="fetchList"
@size-change="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
const tableRef = ref(null)
const tableState = reactive({
list: [],
selectedIds: new Set(),
editCache: {},
pagination: { current: 1, pageSize: 10, total: 0 }
})
// ========== 跨页选中 ==========
function onSelectionChange(rows) {
const currentPageIds = new Set(tableState.list.map(item => item.id))
currentPageIds.forEach(id => tableState.selectedIds.delete(id))
rows.forEach(row => tableState.selectedIds.add(row.id))
}
function restoreSelection() {
if (!tableRef.value) return
tableState.list.forEach(row => {
tableRef.value.toggleRowSelection(row, tableState.selectedIds.has(row.id))
})
}
// ========== 批量操作 ==========
async function batchDelete() {
const ids = Array.from(tableState.selectedIds)
if (!ids.length) {
ElMessage.warning('请先勾选数据')
return
}
await ElMessageBox.confirm(`确定删除 ${ids.length} 条?`)
await api.batchDelete(ids)
clearSelection()
fetchList()
}
function clearSelection() {
tableState.selectedIds.clear()
tableRef.value?.clearSelection()
}
// ========== 行内编辑 ==========
function isEditing(id) {
return id in tableState.editCache
}
function startEdit(row) {
tableState.editCache[row.id] = JSON.parse(JSON.stringify(row))
}
function cancelEdit(row) {
delete tableState.editCache[row.id]
}
async function saveRow(row) {
const cached = tableState.editCache[row.id]
if (JSON.stringify(cached) === JSON.stringify(row)) {
delete tableState.editCache[row.id]
return
}
await api.updateRow(cached)
Object.assign(row, cached)
delete tableState.editCache[row.id]
ElMessage.success('保存成功')
}
// ========== 数据拉取 ==========
async function fetchList() {
const { current, pageSize } = tableState.pagination
const res = await api.getList({ page: current, size: pageSize })
tableState.list = res.data.list
tableState.pagination.total = res.data.total
await nextTick()
restoreSelection()
}
onMounted(fetchList)
</script>
<style scoped>
.batch-bar {
padding: 8px 16px;
margin-bottom: 12px;
background: #ecf5ff;
border-radius: 4px;
}
.batch-bar .el-button { margin-left: 8px; }
</style>
[⬆ 返回目录](#⬆ 返回目录)
八、规范小结
- 选中统一用
selectedIds(Set):跨页、全选、批量操作都围绕它 - 编辑用
editCache副本 :不改原始list,取消即删缓存 - 表格必须设
row-key:跨页选中和编辑都依赖唯一 id - 恢复勾选放
nextTick:等 DOM 更新后再toggleRowSelection - 批量操作统一通过
selectedIds或getSelectedRows():逻辑集中,易维护
[⬆ 返回目录](#⬆ 返回目录)
九、延伸:可抽成 Composable
逻辑稳定后,可以抽成 useTableSelection、useInlineEdit 等 composable,在多个表格页面复用,例如:
js
// useTableSelection.js
export function useTableSelection(tableRef, list) {
const selectedIds = reactive(new Set())
function onSelectionChange(rows) { /* ... */ }
function restoreSelection() { /* ... */ }
return { selectedIds, onSelectionChange, restoreSelection }
}
这样新页面只需要传入 tableRef 和 list,就能复用同一套选中逻辑。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 表单与表格规范
一、《Vue3 + Element Plus 表单开发实战:防重复提交、校验、重置、loading 统一|表单与表格规范篇》
二、《Vue3 + Element Plus 表单校验实战:规则复用、自定义校验、提示语统一,告别混乱避坑|表单与表格规范篇》
三、《Vue3 + Element Plus 表格查询规范:条件管理、分页联动 + 避坑,标准化写法|表单与表格规范篇》
四、《Vue3 + Element Plus 表格实战:批量操作、行内编辑、跨页选中逻辑统一|表单与表格规范篇》
五、《VXE-Table 4.x 实战规范:列配置 + 合并单元格 + 虚拟滚动,避坑卡顿 / 错乱 / 合并失效|表单与表格规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~
