十万级列表的跨页多选方案:el-table 踩坑与治理实践

十万级列表的跨页多选方案: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)

sequenceDiagram participant User as 用户/程序 participant Handler as handleSelectionChange participant State as selectedIds participant Queue as queueSelectionRestore participant Table as el-table Note over User,Table: 用户手动勾选 User->>Table: 点击 checkbox Table->>Handler: selection-change Handler->>Handler: selectionSyncing === false,放行 Handler->>State: 当前页合并进 selectedIds Note over User,Table: 翻页/搜索/切换视图 User->>State: 改分页/筛选/视图 Note over Handler: selectionSyncing = true User->>Queue: queueSelectionRestore() Queue->>Queue: $nextTick 等待 DOM 更新 Queue->>Table: restoreSelection() Table->>Table: clearSelection + toggleRowSelection Table->>Handler: selection-change(程序触发) Handler->>Handler: selectionSyncing === true,return Table->>Table: nextTick 后解锁

核心代码

跨页合并(仅用户操作时执行):

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));

注意:

  • 冻结后不能原地修改数组元素,需整体替换
  • 整体赋值仍会触发依赖 listcomputed 重算,过滤/分页能力不受影响
  • 搜索关键字在外层 normalizeKeyword 一次处理,避免 filter 内对每条记录重复 trim/toLowerCase

效果 :本地 Chrome 实测,弹窗可交互时间从 ~6s 降至 ~1.2s


坑三:reserve-selection 不能替代业务层状态

现象

开了 row-key + reserve-selection,翻页后勾选仍偶发丢失;或「已选」视图与「全部」视图切换后状态混乱。

原因

  1. reserve-selection 维护的是表格内部 selection store,不是业务层的 selectedIds
  2. :data 整体替换时,内部状态可能重置,业务层无感知
  3. 「已选」视图的 data 是全量列表的子集投影,与 reserve-selection 假设的「同一数据源翻页」场景不同

治理

用独立 selectedIds 做真相源,restoreSelection 在每次 data 变化后主动回显。reserve-selection 仅作辅助,不作为状态来源。


坑四:只有「全部」视图时难以复核已选项

现象

已选 50 条分散在 10+ 页,保存前要在全量列表里逐页核对,极易遗漏。

治理

增加「已选」视图:selectedIds 投影为 selectedList,独立搜索与分页。取消勾选后列表即时收缩,复核成本从逐页翻找降为单列表浏览。

双视图需隔离状态------views.allviews.selected 各维护 searchTextsearchKeywordpagination,避免共用一个列表硬切 filter 导致搜索词、页码互相污染。


坑五:加锁位置放错导致偶发丢选

现象(开发期)

仅在 restoreSelection 内加锁,翻页时偶现已选数量减少。

根因

时序如下:

text 复制代码
handlePageChange
  → pageIndex 同步变更,currentTableData 立即变化
  → el-table 因 data 更新触发 selection-change(此时锁未开)
  → $nextTick 后才执行 restoreSelection(锁才生效)

治理

改变表格数据源之前 于各入口显式 selectionSyncing = truequeueSelectionRestore 只负责 $nextTick 调度,不隐式承担加锁副作用------否则读代码时不易发现「这里会忽略 selection-change」。


4. 效果与复用

优化效果

指标 优化前 优化后
弹窗可交互(10 万条) ~6s 白屏 ~1.2s
跨页丢选 翻页/搜索可复现 0 复现
已选复核 在全量列表逐页翻找 「已选」视图集中展示

可复用模式

适用于一切「海量选项 + 弹窗多选 + 跨页保留」场景,可归纳为三件套:

  1. TruthselectedIds 为唯一业务状态
  2. LockselectionSyncing 区分程序事件与用户事件
  3. Timing$nextTick 后再 restoreSelection,对齐 DOM 与数据

Code Review 检查项

  • 改变 currentTableData 前,是否已 selectionSyncing = true
  • queueSelectionRestore 是否只负责 $nextTick 调度?
  • handleSelectionChange 入口是否有锁判断?
  • 大数组是否已 Object.freeze 或等价降 Observer 方案?
  • 已选接口只返回 id 时,是否有 id → 行对象的映射保障?

结语

跨页多选的本质,是在 UI 事件与业务状态之间划清边界selectionSyncing 只有一行判断,却挡住了 el-table 在数据刷新、程序回显时误触发的 selection-changeObject.freeze 一行代码,化解了 Vue 2 大数组的 Observer 风暴。两者都不花哨,但都是这个场景里真正管用的治理手段。


技术关键词Vue 2 · Element UI · el-table · 跨页多选 · selection-change · selectionSyncing · Object.freeze · 前端状态同步 · 大列表性能优化


本文基于真实项目经验整理,手工起草文章大纲,AI 辅助润色,于 2026-06-05

相关推荐
如果超人不会飞8 小时前
脉络清晰的业务演进:TinyVue Timeline 时间线组件全方位实战指南
vue.js
如果超人不会飞8 小时前
从扁平到立体:掌握 TinyVue Grid 树形表格的高级实战指南
vue.js
用户21366100357211 小时前
Vue2组件化开发与父子通信
前端·vue.js
用户21366100357212 小时前
Vue2事件系统与指令进阶
前端·vue.js
逸铭16 小时前
Day 5:三栏布局——左账号 / 中聊天 / 右工具
vue.js·electron
用户17335980753717 小时前
Vue 3 SPA 首屏优化:从 3s 到 1.2s 的 5 个实践
前端·vue.js
锋行天下1 天前
我试图优化 Vite 的拆包,结果首屏慢了 10 倍
前端·vue.js·架构
ZhengEnCi2 天前
Q02-Vue-React-index.html完全指南
vue.js·react.js·html
晴虹2 天前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js
Forever7_2 天前
尤雨溪转发:Vue-tui 0.1 发布!Vue 终于杀进终端!
vue.js