Vue3 + Element Plus 表格实战:批量操作、行内编辑、跨页选中逻辑统一|表单与表格规范篇

【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/,大家怎么顺口怎么叫就好)

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。


一、开篇:为什么表格也要讲「规范」?

表格是后台管理系统里用得最多的组件,但很多项目里会乱成一锅粥:

批量勾选、行内编辑、分页选中各自一套逻辑,维护成本高,容易踩坑。

这篇从可复用的设计思路 出发,把「批量操作、行内编辑、跨页选中」这三类常见需求串起来,用一套统一的逻辑搞定,方便新人理解和老手自查。

[⬆ 返回目录](#⬆ 返回目录)

二、目标与整体设计思路

我们要做到三点:

  1. 批量操作:勾选若干行,统一执行删除、导出、状态变更等
  2. 行内编辑:支持行内修改,保存前区分「已修改/未修改」
  3. 跨页选中:分页时,上一页的勾选状态要保留,全选只影响当前页

设计原则:选中状态统一管理,操作基于选中数据,而不是临时变量

[⬆ 返回目录](#⬆ 返回目录)

三、核心数据结构设计

3.1 要存哪些状态?

js 复制代码
// 统一的状态中心
const tableState = {
  // 当前页展示的数据(接口返回的)
  list: [],
  
  // 跨页选中的行,用唯一 id 集合存储
  // 为什么用 Set?去重 + 快速判断是否存在
  selectedIds: new Set(),
  
  // 行内编辑的缓存:{ rowId: 原始数据副本 }
  // 只有「进入编辑」的行才有缓存,便于判断是否修改过
  editCache: {},
  
  // 分页信息
  pagination: {
    current: 1,
    pageSize: 10,
    total: 0
  }
}

要点:

  • selectedIdsSet,便于增删和判断,也自然去重
  • 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-tablerow-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>

[⬆ 返回目录](#⬆ 返回目录)

八、规范小结

  1. 选中统一用 selectedIds (Set):跨页、全选、批量操作都围绕它
  2. 编辑用 editCache 副本 :不改原始 list,取消即删缓存
  3. 表格必须设 row-key:跨页选中和编辑都依赖唯一 id
  4. 恢复勾选放 nextTick :等 DOM 更新后再 toggleRowSelection
  5. 批量操作统一通过 selectedIds getSelectedRows():逻辑集中,易维护

[⬆ 返回目录](#⬆ 返回目录)

九、延伸:可抽成 Composable

逻辑稳定后,可以抽成 useTableSelectionuseInlineEdit 等 composable,在多个表格页面复用,例如:

js 复制代码
// useTableSelection.js
export function useTableSelection(tableRef, list) {
  const selectedIds = reactive(new Set())
  function onSelectionChange(rows) { /* ... */ }
  function restoreSelection() { /* ... */ }
  return { selectedIds, onSelectionChange, restoreSelection }
}

这样新页面只需要传入 tableReflist,就能复用同一套选中逻辑。

[⬆ 返回目录](#⬆ 返回目录)

🔍 系列模块导航

📝 表单与表格规范

一、《Vue3 + Element Plus 表单开发实战:防重复提交、校验、重置、loading 统一|表单与表格规范篇》
二、《Vue3 + Element Plus 表单校验实战:规则复用、自定义校验、提示语统一,告别混乱避坑|表单与表格规范篇》
三、《Vue3 + Element Plus 表格查询规范:条件管理、分页联动 + 避坑,标准化写法|表单与表格规范篇》

四、《Vue3 + Element Plus 表格实战:批量操作、行内编辑、跨页选中逻辑统一|表单与表格规范篇》
五、《VXE-Table 4.x 实战规范:列配置 + 合并单元格 + 虚拟滚动,避坑卡顿 / 错乱 / 合并失效|表单与表格规范篇》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

📚 系列总览

前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。

更新中,敬请期待~

[⬆ 返回目录](#⬆ 返回目录)


技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
2501_924952692 小时前
C++模块化编程指南
开发语言·c++·算法
2401_831920742 小时前
基于C++的爬虫框架
开发语言·c++·算法
1104.北光c°2 小时前
深入浅出 Elasticsearch:从搜索框到精准排序的架构实战
java·开发语言·elasticsearch·缓存·架构·全文检索·es
weixin_421922692 小时前
模板元编程性能分析
开发语言·c++·算法
2401_851272992 小时前
C++中的类型擦除技术
开发语言·c++·算法
左左右右左右摇晃2 小时前
Java并发——并发编程底层原理
java·开发语言
Liu628882 小时前
C++命名空间使用规范
开发语言·c++·算法
2501_945424802 小时前
模板代码模块化设计
开发语言·c++·算法
!停2 小时前
C++入门基础—类和对象(1)
开发语言·c++