Element UI Table分页跨页选中记忆与批量操作实战
后台列表页做批量操作,最怕遇到这种反馈:用户在第一页勾了三条,翻到第二页又勾了两条,点导出时却发现只有第二页的两条生效。看起来像 Element UI 的 Table 组件“吞了”选中状态,实际上多数时候是表格的选中池和业务层的数据源没有对齐。el-table默认只认当前data里的行,换页之后数据整体替换,上一页的勾选自然就没了。要把它做成“跨页记忆”,核心离不开三个东西:稳定的row-key、type="selection"列上的reserve-selection,以及业务层自己维护的选中集合。这篇内容围绕 Element UI 的 Table 分页选中记忆展开,适合正在做中后台列表、批量导出、批量审批、跨页勾选回显的 Vue2 开发者,也适合刚接手老项目、看到一堆selection-change就头大的人。下面从根因、最小实现、全选半选、删除清洗、mixin 封装几个角度,把这件事一次讲透。
1. 翻页丢勾选的根因:el-table 的选中状态到底存在哪里
1.1 选中状态不是跟着 data 走的
很多人第一次做跨页勾选,会下意识认为:只要我不清空selectedRows,表格翻页后应该还能记住。实际跑起来才发现,翻回第一页时勾选框全空了,但selectedRows数组里可能还有数据。这个现象说明一件事:表格的勾选状态和业务层保存的选中数组,是两套东西。el-table内部维护了一个 selection 池,这个池子默认只对当前渲染的data生效;当data被分页请求替换掉,组件会重新计算当前页的选中状态,上一页那些行对象已经不在当前数据里,勾选框自然不会再亮。
你可以把表格想成一张桌子,data是当前摆在桌上的文件,勾选就是给文件贴标签。翻页相当于把桌上的文件整批换掉,上一批文件被收走了,标签还在不在?默认是不在的。Element UI 这么设计不是偷懒,而是为了避免“脏选中”:如果用户搜索了新的条件,上一批数据里被选中的行还偷偷留在池子里,批量操作时就可能把用户看不见的数据一起提交,风险更大。所以默认行为是保守的,跨页记忆需要显式开启。
另一个常见误区是把selection-change当成存储。这个事件只是“当前选中发生变化”的通知,它返回的数组也不能直接当成业务数据的唯一来源。尤其是在分页场景下,如果你在selection-change里直接写this.selectedRows = rows,那么翻页时数据一换,事件一触发,selectedRows很可能被当前页的选中结果覆盖,上一页的选中就丢了。真正稳的做法是:表格负责当前页的勾选 UI,业务层用 key 集合负责跨页的记忆,两边通过row-key对齐。
1.2 reserve-selection 与 row-key 的配合关系
Element UI 的 Table 提供了一个专门解决跨页记忆的属性:在el-table-column type="selection"上设置:reserve-selection="true",同时给el-table设置row-key。这两个必须成对出现,缺一个都不行。row-key的作用是给每一行一个稳定的身份标识,reserve-selection的作用是告诉表格:数据更新时,不要直接把选中池清空,而是根据row-key把旧选中行保留下来,并在新数据里找到对应的行对象进行替换。
这里的关键词是“稳定”。row-key不能是数组下标,不能是当前页的序号,也不能是每次请求都会变化的临时字段。最稳妥的是数据库主键,比如id。如果主键可能重复,比如不同来源的数据合并到一个列表,那就用复合 key,比如${source}-${id}。如果实在没有唯一字段,也要在数据进入表格之前生成一个稳定的客户端 key,并且保证同一行在翻页、刷新、编辑后 key 不变。row-key不稳定,reserve-selection就会错位:第二页的勾选可能落到第一页某一行上,或者翻回来时勾选莫名其妙消失。
| row-key 取值方式 | 是否推荐 | 原因 |
|---|---|---|
数据库主键id | 推荐 | 稳定、唯一,翻页和刷新后不变 |
复合字段${type}-${id} | 推荐 | 解决多来源数据主键冲突 |
当前页索引index | 禁止 | 每页都从 0 开始,必然错位 |
JSON.stringify(row) | 不推荐 | 字段顺序、嵌套对象、时间格式变化都会导致 key 变 |
| 随机数或时间戳 | 禁止 | 每次请求都变,选中池无法匹配 |
还有一个小细节:row-key可以传字符串,也可以传函数。如果字段名就是id,直接写row-key="id"最省事;如果 key 需要拼装,就写:row-key="getRowKey",在方法里返回字符串或数字。不要在getRowKey里做太多计算,更不要返回对象。表格内部会频繁调用它,返回对象会导致比较失效。
1.3 三个容易混淆的 API:selection-change、toggleRowSelection、clearSelection
分页选中记忆的代码量不大,但 API 的触发时机很容易搞混。selection-change在用户勾选、取消勾选、表头全选、程序调用toggleRowSelection、调用clearSelection时都可能触发。它不是“用户点击事件”,而是“选中池变化事件”。所以你不能在里面无条件发请求,否则回填选中状态时也会触发一次请求,造成重复查询或者状态抖动。
toggleRowSelection(row, selected)用来程序化控制某一行的勾选状态,常用于编辑回显、删除已选标签后同步表格。它接收的是行对象,不是 key。也就是说,你想勾选某一行,必须拿到当前表格data里那一行的对象引用。如果那一行不在当前页,调用它是没有意义的,因为表格当前没有渲染这一行。这个限制决定了:跨页回显必须在翻到对应页、数据加载完成之后再做。
clearSelection会清空整个选中池,包括reserve-selection保留的其他页选中项。它也会触发selection-change。所以在“清空已选”按钮里调用它之后,不要再手动把selectedRows置空两次,虽然结果一样,但逻辑上容易乱。toggleAllSelection则是表头全选的程序化版本,它只作用于当前data里的行,不会去碰其他页的保留项。这一点在下一节会展开。
| API | 作用范围 | 常见用途 | 注意点 |
|---|---|---|---|
selection-change | 选中池变化时触发 | 同步业务选中集合 | 回填、清空时也会触发,需防重复处理 |
toggleRowSelection | 单行 | 编辑回显、删除后取消勾选 | 必须传当前页的行对象 |
clearSelection | 全部 | 关闭弹窗、切换搜索条件 | 会清空保留池,也会触发事件 |
toggleAllSelection | 当前页 | 程序化全选当前页 | 不影响其他页保留项 |
我自己的习惯是:业务层永远维护一份selectedMap,key 是row-key,value 是行数据或者只存 key。表格的selection-change只负责把变化合并进这份 map,不直接拿它当最终提交数据。这样即使表格内部池因为数据刷新出现短暂抖动,业务层的选中集合也不会被轻易覆盖。
2. 最小可用实现:row-key 与 reserve-selection 的配合方式
2.1 模板骨架:三个属性缺一不可
先看一个能跑起来的最小结构。关键点只有三个:el-table上的row-key、selection 列上的reserve-selection、以及selection-change事件。分页器单独放,切换页码时只改查询参数、重新请求数据,不需要手动去清空选中池。模板如下:
<template> <div class="order-table"> <div class="selected-bar"> 已选 {{ selectedRows.length }} 项 <el-button type="text" @click="clearAll">清空</el-button> </div> <el-table ref="table" :data="tableData" row-key="id" border @selection-change="handleSelectionChange" > <el-table-column type="selection" width="48" :reserve-selection="true" /> <el-table-column prop="orderNo" label="单号" min-width="160" /> <el-table-column prop="customerName" label="客户" min-width="120" /> <el-table-column prop="amount" label="金额" width="120" /> </el-table> <el-pagination :current-page="query.page" :page-size="query.size" :total="total" layout="total, sizes, prev, pager, next" @current-change="handlePageChange" @size-change="handleSizeChange" /> </div> </template>这段模板里,row-key="id"是身份标识,:reserve-selection="true"是开启跨页保留,@selection-change是同步业务层。分页器切换时,handlePageChange只做一件事:更新query.page,然后重新请求列表。不要在里面调用clearSelection,否则跨页记忆就被你亲手清掉了。只有用户主动点“清空”或者搜索条件变化时,才需要清空。
selected-bar里的已选数量建议一直显示。跨页勾选最大的体验问题是“用户看不见自己选了什么”,尤其是在第二页时,第一页的选中项是隐藏的。显示一个“已选 N 项”和“清空”按钮,能显著降低误操作。如果业务需要查看已选明细,可以再做一个抽屉或者标签列表,把selectedRows渲染出来。
2.2 业务数据层:selectedRows 用 Map 合并而不是直接赋值
selection-change拿到的rows在开启reserve-selection后,通常是选中池里的所有行,包含其他页保留的行。但我不建议直接this.selectedRows = rows,原因有两个:第一,行对象可能是旧数据,编辑后字段变了,直接提交会拿到旧值;第二,某些边界场景下事件触发顺序不完全可控,直接赋值容易把刚合并进来的其他页数据覆盖掉。更稳的做法是用Map按 key 合并,并且始终用最新行数据替换旧行。
data() { return { tableData: [], total: 0, query: { page: 1, size: 10 }, selectedRows: [], selectedMap: new Map() } }, methods: { getRowKey(row) { return row.id }, handleSelectionChange(rows) { const nextMap = new Map() rows.forEach(row => { const key = this.getRowKey(row) if (key !== undefined && key !== null) { nextMap.set(key, row) } }) this.selectedMap = nextMap this.selectedRows = Array.from(nextMap.values()) }, async fetchList() { const res = await api.getOrderList(this.query) this.tableData = res.list this.total = res.total }, handlePageChange(page) { this.query.page = page this.fetchList() }, handleSizeChange(size) { this.query.size = size this.query.page = 1 this.fetchList() }, clearAll() { this.$refs.table.clearSelection() this.selectedMap.clear() this.selectedRows = [] } }这里有一个经验点:handleSelectionChange里用nextMap重新构建,而不是在旧的selectedMap上增删,是为了避免“取消勾选后旧 key 还留在 map 里”。开启reserve-selection后,rows应该反映当前完整的选中池,重新构建最干净。如果你发现翻页时rows只包含当前页,那大概率是row-key没生效、reserve-selection写错了位置,或者行数据 key 重复了,先查这三个地方,不要急着怀疑组件版本。
另外,selectedMap里存的是行对象。如果列表数据更新后你希望提交最新的行数据,可以在fetchList之后用当前页数据刷新 map 中相同 key 的值。更简单的做法是:提交时只提交 key 数组,后端根据 id 再查最新数据。这样业务层只维护 key 集合,行对象只用于展示,能避免很多“选中后数据被编辑”的坑。
2.3 回显与清空:编辑场景和弹窗复用要手动介入
reserve-selection解决的是“用户手动勾选后跨页保留”,但解决不了“打开编辑弹窗时,根据后端返回的 ids 自动勾选”。回显必须手动做。做法是:拿到 ids 后,等表格数据加载完成,遍历当前页tableData,找到 key 在 ids 里的行,调用toggleRowSelection(row, true)。注意要在$nextTick里调用,确保表格已经渲染出当前页数据。
async openEdit(row) { this.dialogVisible = true this.query.page = 1 await this.fetchList() this.$nextTick(() => { const selectedIds = row.selectedIds || [] this.tableData.forEach(item => { if (selectedIds.includes(item.id)) { this.$refs.table.toggleRowSelection(item, true) } }) }) }这段代码只能回显当前页。如果选中的 id 分布在其他页,翻过去时不会自动勾选,因为那些行还没加载。要支持跨页回显,有两个选择:一是在每次fetchList之后都执行一遍“当前页匹配回填”,把已选 id 集合存起来;二是使用下一节会讲的自维护selectedKeys方案。前者适合简单场景,后者适合已选列表可以删除任意页的复杂场景。
清空同样要手动。弹窗关闭、路由离开、切换搜索条件时,如果不清空,下次打开可能还残留上一次的选中。clearSelection会触发selection-change,所以清空方法里先把表格清掉,再把业务层的selectedMap和selectedRows重置。不要在selection-change里做异步请求,否则清空时也会发一次无意义的请求。
注意:
clearSelection要在表格实例存在时调用。如果弹窗还没渲染、表格还没挂载,直接调$refs.table.clearSelection()会报错。稳妥写法是加一层判断:this.$refs.table && this.$refs.table.clearSelection()。
3. 全选、半选与跨页计数:交互层最容易误判的三个点
3.1 表头全选只作用于当前页,不会选中所有页
这是用户最容易误解的地方。用户看到表头全选框,会以为“全选”就是选中所有页的数据。实际上el-table的表头全选只遍历当前data,也就是当前页的行。开启reserve-selection后,全选当前页会把当前页的行加入保留池;取消全选当前页,只会把当前页的行从池子里移除,其他页已经选中的行不受影响。这个行为本身是合理的,因为其他页的数据还没加载,组件不可能替你去选。
但用户不知道这个逻辑,所以文案要跟上。已选栏里不要只写“已选 5 项”,最好写“已选 5 项,当前页 2 项”。如果业务允许跨页累计,可以在表头全选框旁边加一个提示,或者在全选后弹一个轻提示:“已选中当前页 10 项,翻页后可继续选择”。不要试图修改表头全选的默认行为去实现“全选所有页”,那会牵扯到未加载数据,前端硬做很容易出现“全选数量对不上”的问题。
| 用户操作 | 实际效果 | 建议提示 |
|---|---|---|
| 勾选表头全选 | 当前页全部加入选中池 | 提示“已选中当前页 N 项” |
| 取消表头全选 | 当前页全部移出选中池,其他页保留 | 已选数量实时减少 |
| 翻到第二页再全选 | 第二页加入选中池,第一页保留 | 已选数量累计 |
| 点“清空” | 所有页选中全部清空 | 二次确认可选 |
3.2 半选状态只反映当前页,不要拿它判断全局
el-table的表头半选状态,是根据当前页的选中行数算出来的。当前页选了一部分,表头显示半选;当前页全选了,表头显示全选;当前页一个都没选,表头就是未选。哪怕你在其他页选了十条,只要当前页没选,表头也不会半选。这是组件的预期行为,不是 bug。有些开发者想让表头反映“全局半选”,自己加 class 或者改图标,结果和组件内部的indeterminate打架,点击全选时状态错乱。
我的建议是不要在表头上做全局半选,而是把全局信息放到已选栏。比如“已选 12 项,分布于 3 页”,用户一眼就知道自己跨页选了多少。如果确实需要“全局全选/全局取消”,那应该做成独立按钮,并且明确它的语义是“按当前查询条件全选”,由后端支持,而不是让前端表格表头去承担。
3.3 “全选所有页”不要在前端硬做
有些需求会写“支持全选所有页”,这时候要先判断:所有页的数据前端有没有?如果没有,就不能真正全选。前端只能选中已加载的页,未加载的页无法生成行对象,也就无法勾选。可行的方案有两种:一是后端提供“按查询条件全选”的接口,前端只传查询参数,导出或批量操作时由后端根据条件取全量;二是只允许用户逐页勾选,已选数量累计,不做“全选所有页”的承诺。
| 需求描述 | 前端能否独立实现 | 推荐方案 | 风险 |
|---|---|---|---|
| 全选当前页 | 能 | 表头全选默认行为 | 无 |
| 跨页累计勾选 | 能 | reserve-selection或自维护 key | 注意 key 唯一 |
| 全选所有页 | 不能 | 后端按条件全选/导出 | 前端硬做会漏数据 |
| 全选搜索结果 | 不能 | 后端接口 | 需要明确查询条件 |
如果业务方坚持要“全选所有页”的按钮,可以这样实现:按钮只记录一个“全选模式”标记和当前查询条件,提交时告诉后端“按此条件全选”,前端表格的勾选状态只作为辅助展示。不要试图在前端循环请求所有页再逐行toggleRowSelection,数据量大时请求次数和渲染压力都扛不住,而且用户等待时间不可控。
4. 删除已选项与搜索切换:选中池的清洗策略
4.1 从已选标签里删除非当前页的行,原生方案有局限
开启reserve-selection后,表格内部池会保留其他页的选中行。如果你在“已选列表”里点删除,要取消某一行,常规做法是toggleRowSelection(row, false)。但这个方法要求传入当前页的行对象。如果被删除的行在第二页,而你正停留在第一页,tableData里找不到它,调用toggleRowSelection是无效的。更麻烦的是,你手动从selectedRows数组里删掉它,表格内部池里可能还留着,下次selection-change又把它带回来。
这个局限决定了:如果“已选列表”需要支持删除任意页的行,建议不要完全依赖reserve-selection的内部池,而是自己维护一份selectedKeys。表格只负责当前页的勾选展示,翻页时根据selectedKeys手动回填当前页。这样删除任意页的行,只需要从selectedKeys里删掉 key,翻到那一页时自然不会勾选。代码结构如下:
data() { return { tableData: [], selectedKeys: new Set(), selectedRows: [], selectedRowsMap: new Map(), isRestoring: false } }, methods: { getRowKey(row) { return row.id }, handleSelectionChange(rows) { if (this.isRestoring) return const currentKeys = this.tableData.map(row => this.getRowKey(row)) currentKeys.forEach(key => { this.selectedKeys.delete(key) }) rows.forEach(row => { const key = this.getRowKey(row) this.selectedKeys.add(key) this.selectedRowsMap.set(key, row) }) this.syncSelectedRows() }, syncSelectedRows() { this.selectedRows = Array.from(this.selectedKeys).map(key => { return this.selectedRowsMap.get(key) || { id: key } }) }, fillCurrentPageSelection() { this.isRestoring = true this.$nextTick(() => { this.tableData.forEach(row => { const key = this.getRowKey(row) const shouldSelect = this.selectedKeys.has(key) this.$refs.table.toggleRowSelection(row, shouldSelect) }) this.$nextTick(() => { this.isRestoring = false }) }) }, removeSelected(row) { const key = this.getRowKey(row) this.selectedKeys.delete(key) this.selectedRowsMap.delete(key) this.syncSelectedRows() const currentRow = this.tableData.find(item => this.getRowKey(item) === key) if (currentRow) { this.isRestoring = true this.$refs.table.toggleRowSelection(currentRow, false) this.$nextTick(() => { this.isRestoring = false }) } } }这段代码的核心是isRestoring标记。回填和删除时都会触发selection-change,如果不跳过,会把回填动作当成用户操作,导致selectedKeys被当前页结果覆盖。回填完成后,在nextTick里把标记置回 false。注意nextTick嵌套一次,是因为toggleRowSelection触发的selection-change可能在同一轮更新里连续触发多次,等 DOM 更新完再放开标记更稳。
采用这套方案后,每次fetchList成功后都要调用fillCurrentPageSelection()。这样翻到哪一页,哪一页的勾选状态就会根据selectedKeys回填。已选列表删除任意页的行,只需要改selectedKeys,不需要关心那一行在不在当前页。代价是代码量比reserve-selection多,但可控性更强,适合已选列表交互复杂的场景。
4.2 搜索条件变化时,建议清空选中
搜索条件变化后,列表数据整体换了一批,之前选中的行可能已经不在结果集里。如果继续保留选中,用户看到“已选 5 项”,但当前列表里一条都没勾,会非常困惑。更危险的是批量操作时,用户以为只操作当前搜索结果,实际把上一次搜索条件下选中的行也带上了。我的建议是:搜索、重置、切换 tab、切换数据范围时,默认清空选中。如果业务确实要保留,必须在界面上明确提示“已选包含其他筛选条件下的数据”,并提供查看已选明细的入口。
watch: { searchForm: { deep: true, handler() { this.clearAll() } } }这段 watch 只是示意,实际项目里不建议对完整表单深度监听,容易在输入过程中频繁触发。更稳的做法是在“查询”按钮的点击事件里清空,而不是输入框一变就清。用户点查询,才代表筛选条件生效,这时候清空选中符合预期。重置按钮同理。
| 场景 | 是否保留选中 | 理由 |
|---|---|---|
| 翻页 | 保留 | 跨页勾选的核心诉求 |
| 切换每页条数 | 保留 | 数据范围没变,只是展示数量变了 |
| 点查询/重置 | 清空 | 结果集变了,旧选中可能不可见 |
| 切换 tab | 清空 | 数据维度变了,混选容易误操作 |
| 弹窗关闭再打开 | 清空 | 避免上一次操作残留 |
4.3 弹窗关闭与组件销毁时的清理
弹窗复用是选中状态残留的高发区。用户打开弹窗选了三条,关闭,再打开另一个订单,发现上次的三条还勾着。原因是弹窗关闭时组件没有销毁,el-table的选中池还在。处理方式是在弹窗的closed事件或者visible变为 false 时清空。注意用closed而不是close,closed是动画结束后的回调,此时表格还在,调用clearSelection不会因为 DOM 被移除而报错。
handleDialogClosed() { if (this.$refs.table) { this.$refs.table.clearSelection() } this.selectedMap.clear() this.selectedRows = [] this.selectedKeys.clear() this.selectedRowsMap.clear() }如果是路由离开,在beforeDestroy里清空业务层集合,表格实例会随组件销毁,不需要额外调clearSelection。但如果你用了 keep-alive,组件不会销毁,下次进来状态还在,这时候要在activated里根据业务决定是否重置。我的经验是:列表页的批量选中一般不需要跨路由保留,activated里直接清空最省心,除非产品明确要求“从详情页返回后保留勾选”。
5. 抽成 mixin 后的接入清单与实战坑位
5.1 mixin 的职责边界:只做选中记忆,不碰请求
当项目里有多个列表页都要跨页选中,复制粘贴容易漏改。可以抽一个 mixin,但边界要清楚:mixin 只负责row-key的获取、选中集合的维护、当前页回填、清空、删除已选项,不负责发请求,也不负责分页参数。请求和分页仍然留在页面组件里,mixin 通过方法暴露给页面调用。这样页面可以自由决定什么时候请求、什么时候回填,不会因为 mixin 内部发请求导致时序问题。
export default { data() { return { selectedKeys: new Set(), selectedRows: [], selectedRowsMap: new Map(), isRestoring: false } }, methods: { getRowKey(row) { return row.id }, handleSelectionChange(rows) { if (this.isRestoring) return const currentKeys = this.tableData.map(row => this.getRowKey(row)) currentKeys.forEach(key => this.selectedKeys.delete(key)) rows.forEach(row => { const key = this.getRowKey(row) this.selectedKeys.add(key) this.selectedRowsMap.set(key, row) }) this.syncSelectedRows() }, syncSelectedRows() { this.selectedRows = Array.from(this.selectedKeys).map(key => { return this.selectedRowsMap.get(key) || { id: key } }) }, fillCurrentPageSelection() { this.isRestoring = true this.$nextTick(() => { this.tableData.forEach(row => { this.$refs.table.toggleRowSelection( row, this.selectedKeys.has(this.getRowKey(row)) ) }) this.$nextTick(() => { this.isRestoring = false }) }) }, clearSelection() { if (this.$refs.table) { this.$refs.table.clearSelection() } this.selectedKeys.clear() this.selectedRowsMap.clear() this.selectedRows = [] }, removeSelected(row) { const key = this.getRowKey(row) this.selectedKeys.delete(key) this.selectedRowsMap.delete(key) this.syncSelectedRows() const currentRow = this.tableData.find(item => this.getRowKey(item) === key) if (currentRow) { this.isRestoring = true this.$refs.table.toggleRowSelection(currentRow, false) this.$nextTick(() => { this.isRestoring = false }) } } } }页面接入时,在fetchList成功后调用this.fillCurrentPageSelection(),在查询、重置、弹窗关闭时调用this.clearSelection()。注意 mixin 里假设了tableData和$refs.table存在,如果页面结构不同,需要按实际情况调整。不要把这个 mixin 做成“万能列表 mixin”,把分页、请求、导出都塞进去,最后会变成一个没人敢改的黑盒。
5.2 接入检查清单:上线前逐项过一遍
跨页选中记忆的 bug 往往不是逻辑写错,而是某个边界漏了。下面这张清单可以照着过一遍,基本能覆盖八成问题。
| 检查项 | 正确做法 | 常见错误 |
|---|---|---|
row-key唯一性 | 用主键或复合 key | 用 index、随机数 |
reserve-selection位置 | 写在type="selection"列上 | 写在el-table上 |
selection-change合并 | 按 key 合并,不直接覆盖 | 直接this.selectedRows = rows |
| 翻页回填 | 数据加载后nextTick回填 | 在请求前回填 |
| 清空时机 | 查询、重置、弹窗关闭 | 翻页时清空 |
| 全选文案 | 提示当前页与跨页数量 | 让用户误以为全选所有页 |
| 每页条数切换 | 重置到第一页并重新请求 | 当前页越界导致空数据 |
| 编辑回显 | 当前页匹配后toggleRowSelection | 没数据时调用 |
| 重复 key 检查 | 开发期用 Set 校验 | 线上出现勾选错位才发现 |
重复 key 这个问题值得单独说。开发期可以在fetchList之后加一段校验:用Set统计当前页 key,如果数量小于数组长度,说明有重复,直接在控制台打警告。不要等到用户反馈“勾了第二页第一页也亮了”才去查。Element UI 在 key 重复时不一定报错,但内部匹配会错乱,表现非常隐蔽。
if (process.env.NODE_ENV !== 'production') { const keySet = new Set(this.tableData.map(row => this.getRowKey(row))) if (keySet.size !== this.tableData.length) { console.warn('当前页存在重复 row-key,请检查数据') } }5.3 两个隐蔽坑:用 index 做 key 和用 JSON 字符串做 key
早些年接手过一个后台列表,跨页勾选总是错位:在第一页勾第一条,翻到第二页,第二条自动亮了;再翻回第一页,第一条又灭了。查了半天,发现row-key用的是index。表格每页的索引都从 0 开始,第一页的 0 和第二页的 0 被当成同一行,保留池直接串了。改成数据库主键后问题消失。这个坑的教训是:只要涉及到跨页、排序、筛选,任何基于位置生成的标识都不可靠。
另一个坑是用JSON.stringify(row)做 key。表面上看每行内容不同,key 就不同,但行数据里如果有时间字段、嵌套对象、字段顺序变化,字符串就会变。用户编辑了某一行,或者后端返回的字段顺序调整了,同一行的 key 就变了,选中状态自然丢失。更隐蔽的是,如果行里包含null和undefined,JSON.stringify的结果可能不符合预期。这类 key 只适合临时调试,不要用在正式代码里。
还有一个小坑是数字和字符串混用。比如第一页的 id 是数字1,第二页回填时从 URL 或者本地存储拿到的是字符串"1",用Map或者Set比较时类型不同,匹配不上。解决方式是在getRowKey里统一转成字符串:return String(row.id)。这个细节在跨组件传参、从路由 query 回显时特别容易遇到。
最后再分享一个实际项目里的做法:如果列表页的批量操作很重要,我会在已选栏旁边加一个“查看已选”的链接,点开是一个抽屉,里面列出所有选中行的关键字段,支持单条移除。这样用户不用翻页去确认自己选了什么,也能直接删掉非当前页的误选。抽屉里的数据来自selectedRows,删除时调用 mixin 的removeSelected,表格和抽屉始终同步。这个交互比单纯显示“已选 N 项”更能减少批量操作的误操作,尤其是在跨页选择几十条的时候。