十万级列表的跨页多选方案:el-table 踩坑与治理实践
技术洞见 :在 Element UI 表格里,UI 状态与业务状态一旦分离,
selection-change就不再只是「用户点了勾选框」------它也可能是数据刷新、分页切换、程序回显的副作用。区分这两类事件,是跨页多选能否稳定运行的分水岭。
目录
- [1. 场景背景](#1. 场景背景 "#1-%E5%9C%BA%E6%99%AF%E8%83%8C%E6%99%AF")
- [2. 方案概览](#2. 方案概览 "#2-%E6%96%B9%E6%A1%88%E6%A6%82%E8%A7%88")
- [3. 踩坑与治理](#3. 踩坑与治理 "#3-%E8%B8%A9%E5%9D%91%E4%B8%8E%E6%B2%BB%E7%90%86")
- [4. 效果与复用](#4. 效果与复用 "#4-%E6%95%88%E6%9E%9C%E4%B8%8E%E5%A4%8D%E7%94%A8")
1. 场景背景
弹窗内需要从十万级选项列表中勾选若干条目,支持搜索、前端分页、跨页保留勾选,最终提交 id 数组。
初始形态的问题
第一版只有「全部」列表,没有「已选」视图。已勾选项分散在多页中,复核时只能在全量列表里逐页翻找,找一项已选项的成本很高。
为什么一次加载 10 万条、不做服务端分页
已选接口只返回 id 列表 ,不附带名称等展示字段。若列表接口做服务端分页,已选 id 很可能不在当前页,表格行缺少 projectCode / projectName,只能显示占位符或空白。
因此采用:全量列表一次拉取 + 前端过滤分页 ,用完整数据做 id → 行对象的映射;已选视图通过 selectedIds 投影,保证每条已选项都有可读展示。
需要解决的核心问题
| 问题 | 表现 |
|---|---|
| 翻页丢选 | 翻页、搜索后,其他页已勾选项从 selectedIds 中消失 |
| 主线程卡死 | 10 万条数据赋给 Vue 2 data,弹窗白屏数秒 |
| UI 与状态不一致 | 表格勾选与 selectedIds 计数对不上 |
2. 方案概览
架构:业务状态与表格 UI 分离
不把 el-table 内部勾选状态当作唯一数据源,而是 selectedIds 为真相源,表格负责展示与回显。
text
┌─────────────────────────────────────────────────────────┐
│ selectedIds │
│ (唯一业务状态,跨页持久) │
└──────────────────────────┬──────────────────────────────┘
│ applySelectedState / restoreSelection
▼
┌─────────────────────────────────────────────────────────┐
│ computed: currentTableData(过滤 + 分页后的当前页数据) │
└──────────────────────────┬──────────────────────────────┘
│ :data 绑定
▼
┌─────────────────────────────────────────────────────────┐
│ el-table(展示层,触发 selection-change) │
│ row-key="id" + reserve-selection="true" │
└─────────────────────────────────────────────────────────┘
模块划分
| 模块 | 做法 |
|---|---|
| 选中状态 | selectedIds: number[],独立于表格内部 selection |
| 大列表 | Object.freeze(list),避免 Vue 2 深度 Observer |
| 分页/搜索 | 前端 computed + slice |
| 双视图 | views.all / views.selected 各自维护搜索词与分页 |
| 事件隔离 | selectionSyncing 标志位,挡住程序触发的 selection-change |
时序(Mermaid)
核心代码
跨页合并(仅用户操作时执行):
javascript
handleSelectionChange(selectedRows) {
if (this.selectionSyncing) return;
const currentPageIds = this.currentTableData.map((item) => item.id);
const selectedSet = new Set(this.selectedIds);
currentPageIds.forEach((id) => selectedSet.delete(id));
selectedRows.forEach((row) => {
const id = toId(row);
if (id !== null) selectedSet.add(id);
});
this.applySelectedState(Array.from(selectedSet));
}
程序回显:
javascript
restoreSelection() {
const table = this.$refs.table;
if (!table) {
this.selectionSyncing = false;
return;
}
this.selectionSyncing = true;
table.clearSelection();
const selected = new Set(this.selectedIds);
this.currentTableData.forEach((item) => {
if (selected.has(item.id)) {
table.toggleRowSelection(item, true);
}
});
this.$nextTick(() => {
this.selectionSyncing = false;
});
}
3. 踩坑与治理
坑一:selection-change 不只来自用户点击
现象
翻页或搜索后,已选数量突然变少。例如从 87 条变成 12 条。
原因
el-table 在以下情况都会触发 selection-change:
- 用户点击 checkbox
:data变化(翻页、筛选、切换视图)- 调用
clearSelection()/toggleRowSelection()
跨页合并逻辑是「先移除当前页所有 id,再加回当前勾选行」。程序执行 clearSelection() 时 selectedRows 为空,等价于用户取消了当前页全部勾选,其他页的 id 被误删。
治理:selectionSyncing 锁机制
在 handleSelectionChange 入口判断标志位,程序同步期间直接 return:
javascript
// 跨页多选锁:程序化回显勾选时,el-table 仍会触发 selection-change;
// 此期间忽略该事件,避免误改 selectedIds。
selectionSyncing: false,
需要覆盖两个时间窗:
| 时间窗 | 何时发生 | 加锁位置 |
|---|---|---|
| 窗 1:数据变更 → restore 之前 | currentTableData 变了,表格重渲染 |
翻页/搜索/切视图入口显式 selectionSyncing = true |
| 窗 2:restore 执行期间 | clearSelection / toggleRowSelection |
restoreSelection 内部 selectionSyncing = true |
单靠 restoreSelection 内部的锁不够:入口改完分页后,restoreSelection 在 $nextTick 才执行,中间窗口里 data 更新触发的 selection-change 锁还没打开。
入口写法(语义清晰,推荐):
javascript
handlePageChange(page) {
this.selectionSyncing = true;
this.currentView.pagination.pageIndex = page;
this.queueSelectionRestore();
}
queueSelectionRestore() {
// 只负责等 DOM 更新后再回显,不承担隐式加锁
this.$nextTick(() => {
this.restoreSelection();
});
}
queueSelectionRestore 使用 $nextTick 的原因:分页/筛选变更后,currentTableData 需先传到 el-table 并完成渲染,再对新页数据 执行 toggleRowSelection,否则会操作到旧页 DOM。
坑二:Vue 2 大数组赋值导致主线程卡死
现象
接口返回 10 万+ 条记录,this.list = res 之后弹窗白屏 3~8s。
原因
Vue 2 对 data 中的数组递归 defineProperty,10 万对象 × 多字段 ≈ 数十万次属性劫持,主线程长时间阻塞。
治理:Object.freeze
javascript
// 冻结后 Vue 2 不建立深度 Observer,避免 defineProperty 风暴
this.list = Object.freeze(normalizeList(res));
注意:
- 冻结后不能原地修改数组元素,需整体替换
- 整体赋值仍会触发依赖
list的computed重算,过滤/分页能力不受影响 - 搜索关键字在外层
normalizeKeyword一次处理,避免filter内对每条记录重复trim/toLowerCase
效果 :本地 Chrome 实测,弹窗可交互时间从 ~6s 降至 ~1.2s。
坑三:reserve-selection 不能替代业务层状态
现象
开了 row-key + reserve-selection,翻页后勾选仍偶发丢失;或「已选」视图与「全部」视图切换后状态混乱。
原因
reserve-selection维护的是表格内部 selection store,不是业务层的selectedIds:data整体替换时,内部状态可能重置,业务层无感知- 「已选」视图的
data是全量列表的子集投影,与reserve-selection假设的「同一数据源翻页」场景不同
治理
用独立 selectedIds 做真相源,restoreSelection 在每次 data 变化后主动回显。reserve-selection 仅作辅助,不作为状态来源。
坑四:只有「全部」视图时难以复核已选项
现象
已选 50 条分散在 10+ 页,保存前要在全量列表里逐页核对,极易遗漏。
治理
增加「已选」视图:selectedIds 投影为 selectedList,独立搜索与分页。取消勾选后列表即时收缩,复核成本从逐页翻找降为单列表浏览。
双视图需隔离状态------views.all 与 views.selected 各维护 searchText、searchKeyword、pagination,避免共用一个列表硬切 filter 导致搜索词、页码互相污染。
坑五:加锁位置放错导致偶发丢选
现象(开发期)
仅在 restoreSelection 内加锁,翻页时偶现已选数量减少。
根因
时序如下:
text
handlePageChange
→ pageIndex 同步变更,currentTableData 立即变化
→ el-table 因 data 更新触发 selection-change(此时锁未开)
→ $nextTick 后才执行 restoreSelection(锁才生效)
治理
在改变表格数据源之前 于各入口显式 selectionSyncing = true;queueSelectionRestore 只负责 $nextTick 调度,不隐式承担加锁副作用------否则读代码时不易发现「这里会忽略 selection-change」。
4. 效果与复用
优化效果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 弹窗可交互(10 万条) | ~6s 白屏 | ~1.2s |
| 跨页丢选 | 翻页/搜索可复现 | 0 复现 |
| 已选复核 | 在全量列表逐页翻找 | 「已选」视图集中展示 |
可复用模式
适用于一切「海量选项 + 弹窗多选 + 跨页保留」场景,可归纳为三件套:
- Truth :
selectedIds为唯一业务状态 - Lock :
selectionSyncing区分程序事件与用户事件 - Timing :
$nextTick后再restoreSelection,对齐 DOM 与数据
Code Review 检查项
- 改变
currentTableData前,是否已selectionSyncing = true? -
queueSelectionRestore是否只负责$nextTick调度? -
handleSelectionChange入口是否有锁判断? - 大数组是否已
Object.freeze或等价降 Observer 方案? - 已选接口只返回 id 时,是否有 id → 行对象的映射保障?
结语
跨页多选的本质,是在 UI 事件与业务状态之间划清边界 。selectionSyncing 只有一行判断,却挡住了 el-table 在数据刷新、程序回显时误触发的 selection-change;Object.freeze 一行代码,化解了 Vue 2 大数组的 Observer 风暴。两者都不花哨,但都是这个场景里真正管用的治理手段。
技术关键词 :Vue 2 · Element UI · el-table · 跨页多选 · selection-change · selectionSyncing · Object.freeze · 前端状态同步 · 大列表性能优化
本文基于真实项目经验整理,手工起草文章大纲,AI 辅助润色,于 2026-06-05