做后台管理系统做久了,几乎每个业务方最后都会提出同一个需求:这个表格的列我不想看,能不能隐藏?这一列我想放到前面,行不行?最好我调好的列顺序、列宽,下次打开还保持原样。这种需求听着简单,真正动起手来才发现,el-table 的列一旦开始“动态”,就会连带出一整套问题:表头得跟着业务类型切换、单元格内容得跟着列配置变、大数据量下还容易卡、接口返回顺序又影响初始化表现……
这篇文章我准备一次性把这些东西聊透:动态表头怎么设计、表头和内容怎么联动、列显隐和状态保存怎么做、大数据量表格怎么优化、初始化阶段常见的坑怎么填。内容偏向实战,代码基于 Vue 2 + Element UI,Vue 3 + Element Plus 的差异我会在关键位置点出来。
适合正在做报表类、数据中台类页面、被业务方反复提“列要能配置”的开发者参考;已经在用 el-table,但性能和列状态管理上想进一步提升的朋友,也能在这里找到对应的处理思路。
1. 动态表头的底层思路:把“列”从代码变成数据
1.1 动态表头的本质:列配置数据化
很多人第一次接触“动态表头”会下意识想到:把一组<el-table-column>用v-if或v-show控制显隐不就行了?这个思路在列数量固定、只是偶尔隐藏一两列的时候确实够用,但一旦业务变成“后端返回什么列就展示什么列”“用户能随意调整列顺序和宽度”,这种写法就撑不住了——每一列都是一坨模板代码,你没法用数组去驱动它,也没法把这套配置持久化保存。
动态表头的本质,是把“列”的定义从 HTML 模板里拿出来,变成一份纯 JavaScript 数据,也就是列配置数组。每个数组元素对应一列,里面存prop、label、width、align、fixed、formatter这些信息。模板里只需要用一个v-for循环渲染el-table-column即可。
一个最简示例是这样的:
<template> <el-table :data="tableData" border size="mini"> <el-table-column v-for="col in columns" :key="col.key || col.prop" :prop="col.prop" :label="col.label" :width="col.width" :min-width="col.minWidth" :fixed="col.fixed" :align="col.align" :formatter="col.formatter" :show-overflow-tooltip="!!col.tooltip" /> </el-table> </template>export default { data() { return { columns: [ { key: 'date', prop: 'date', label: '日期', width: 120, align: 'center' }, { key: 'name', prop: 'name', label: '姓名', minWidth: 120 }, { key: 'amount', prop: 'amount', label: '金额', width: 140, align: 'right', formatter: (row, column, cellValue) => '¥' + Number(cellValue).toFixed(2), }, ], tableData: [], }; }, };代码量虽然不多,但有几个点必须注意。
第一,:key一定不要用index。动态表格的列经常会增删、排序,如果用 index 作为 key,Vue 复用组件时可能出现“列的数据和位置对不上”的诡异问题。最典型的现象是:拖拽调整列顺序后,某几列的内容串位,或者固定列抖动。用col.prop或者额外加一个稳定的key字段就能避免。
第二,列配置数组要整体替换,不要在原有数组上 push 然后修改某项属性。Element UI 内部有一套 TableStore 机制,它对列的变动感知并不完全依赖 Vue 的响应式,有时候你改了数组元素里某个字段,表格 UI 不刷新。直接赋值一个新数组,会在 Vue 响应式层面触发列组件的重新渲染,相对可控。
第三,formatter接收的参数顺序是(row, column, cellValue, index),不是先值后行。很多人在列配置里写 formatter 时习惯写成(value, row) => ...,结果渲染出来全是 undefined。文档里这四个参数按序背下来:行对象、列对象、单元格值、行索引。
1.2 三种实现路线的取舍:模板 v-for、具名插槽和动态渲染组件
把列配置数据化之后,接下去要处理的是“单元格内容怎么渲染”。我在项目里见过三种路线,适用场景完全不同,选错了后期维护很痛苦。
路线一:模板 v-for + el-table-column 属性。这是最轻量的方案,适合大部分“列是动态的,但单元格内容就是普通文本或简单 formatter”的场景。上面的示例就是这种。优点是无脑、直观、好维护,缺点是一旦单元格里要放按钮、标签、图片这类复杂内容,单纯靠 formatter 解决不了。
路线二:模板 v-for + 具名插槽判断。在同一个el-table-column内部,根据列配置的type字段,用多个<template>分支渲染不同内容:
<el-table-column v-for="col in visibleColumns" :key="col.key || col.prop" :prop="col.prop" :label="col.label"> <template v-if="col.type === 'status'" slot-scope="scope"> <el-tag :type="statusType(scope.row[col.prop])"> {{ statusText(scope.row[col.prop]) }} </el-tag> </template> <template v-else-if="col.type === 'op'" slot-scope="scope"> <el-button size="mini" type="text" @click="handleEdit(scope.row)">编辑</el-button> <el-button size="mini" type="text" class="danger" @click="handleDelete(scope.row)">删除</el-button> </template> <template v-else slot-scope="scope"> {{ col.formatter ? col.formatter(scope.row, scope.column, scope.row[col.prop], scope.$index) : scope.row[col.prop] }} </template> </el-table-column>这个方案能覆盖 80% 的业务场景,但有一个典型的维护痛点:列类型多了以后,模板里的v-if / v-else-if会越堆越长。我见过有人在一个表格里写了十几种列类型,模板看起来像一棵圣诞树。要是再叠加“某一列在某种权限下显示成 A 组件、另一种权限下显示成 B 组件”,分支判断就更难读了。
路线三:动态渲染组件。把单元格内容抽成一个独立组件,比如DynamicCell.vue,根据col.type内部自行分发:
<el-table-column v-for="col in visibleColumns" :key="col.key || col.prop" :prop="col.prop" :label="col.label"> <template slot-scope="scope"> <DynamicCell :column="col" :row="scope.row" :value="scope.row[col.prop]" :index="scope.$index" /> </template> </el-table-column>DynamicCell内部用动态组件<component :is="componentName">或者简单的v-if链去渲染具体内容。这样做的好处是:表格模板永远那么几行,复杂逻辑被隔离到独立组件里,列配置里只需多传一个type字段。后端如果返回了一堆自定义列类型,你只需要扩展这个组件就行。
三条路线不是互斥的。我在实际项目中通常混合使用:普通文本列走 formatter,状态列和操作列走插槽判断,极少数的富交互列抽成独立组件。这样表格模板不会失控,性能也可控。
2. 动态表头与动态内容的组合实现
2.1 场景:一个可以切换业务类型的动态报表
动态表头真正难的不是“列从数组渲染”,而是“表头变了,数据也跟着变”。比如一个报表页面,左侧是业务类型下拉框,选中不同业务类型,右侧表格的列完全不一样:A 类型要展示“渠道、曝光、点击、转化”,B 类型要展示“地区、销售额、退款率”。这种情况下,列配置和数据都不能写死。
我常用的模式是:把列配置和数据都放进一个initTable方法里,切换业务类型时重新拉取。列配置是常量也好、后端返回也好,关键是保证“列配置先就位,数据随后绑定”:
async switchBizType(type) { this.loading = true; try { const [colRes, dataRes] = await Promise.all([ fetchColumns(type), fetchTableData(type), ]); this.columns = normalizeColumns(colRes.data); this.tableData = dataRes.data; } finally { this.loading = false; } }这里有个非常容易被忽略的细节:Promise.all并发请求还是串行请求,取决于你是否需要“根据列配置决定请求哪些字段”。如果数据接口需要带上列对应的字段列表,就必须串行:
const colRes = await fetchColumns(type); this.columns = colRes.data; const dataRes = await fetchTableData(type, { fields: this.columns.map((col) => col.prop), }); this.tableData = dataRes.data;把columns.map出来的字段传给后端,通常是为了减少网络传输量,后端只返回表格真正需要展示的字段。这个优化在高频报表查询里效果很明显,但代价是多了一次请求往返。具体怎么选,看后端接口设计,没有标准答案。
另外,切换业务类型时,上一轮数据残留会造成表格里出现“旧数据配新表头”的闪屏。处理方式很简单:切换开始时先清空数据并开启 loading,数据回来后一并赋值:
async switchBizType(type) { this.loading = true; this.tableData = []; this.columns = []; try { // 拉取新配置和新数据 } finally { this.loading = false; } }2.2 多级表头:分组动态列怎么嵌套
动态表头的进阶场景是多级表头。比如“营业收入”下面拆“线上收入”“线下收入”,“线上收入”下面再拆“PC 端”“移动端”。Element UI 支持通过嵌套el-table-column实现分组,动态场景下只需要递归渲染。
模板里可以直接写两组循环,外层遍历分组,内层遍历子列:
<el-table :data="tableData" border> <el-table-column v-for="group in groupColumns" :key="group.key || group.label" :label="group.label" :align="'center'" > <el-table-column v-for="child in group.children" :key="child.key || child.prop" :prop="child.prop" :label="child.label" :width="child.width" :formatter="child.formatter" /> </el-table-column> </el-table>多级表头有几个容易踩的坑:
父级列不能设置prop。父列只是一个分组容器,一旦给了 prop,Element UI 会把它当成真实数据列处理,表格结构直接错乱。正确做法是父列只给label,子列才给prop。
子列的key必须在整张表范围内唯一。很多人父子列都叫“name”或者“amount”,一旦列有隐藏、排序操作,Vue 的 key 冲突会引发各种诡异现象。建议在拉取配置时就为每一列生成一个全局唯一 id,比如group.key + '-' + child.prop。
更复杂的多级表头需要任意层级的嵌套,写递归el-table-column组件会更优雅。做法是定义一个递归组件,自己调用自己渲染 children。不过大多数后台项目两到三层就封顶了,两层循环足够。
2.3 自定义单元格:状态、操作列和复杂内容
动态列里的单元格内容,90% 逃不开三类:状态标签、操作按钮、格式化文本。
状态标签最典型的是把后端返回的0/1/2映射成“待审核/已通过/已驳回”,并且用不同颜色的el-tag展示。我习惯在列配置里存一个映射表,插槽里直接用:
{ key: 'status', prop: 'status', label: '状态', type: 'status', statusMap: { 0: { text: '待审核', tagType: 'warning' }, 1: { text: '已通过', tagType: 'success' }, 2: { text: '已驳回', tagType: 'danger' }, }, }<template v-if="col.type === 'status'" slot-scope="scope"> <el-tag :type="(col.statusMap[scope.row[col.prop]] || {}).tagType || 'info'"> {{ (col.statusMap[scope.row[col.prop]] || {}).text || scope.row[col.prop] }} </el-tag> </template>这样做的收益是:列配置完全可序列化之后,状态映射也能跟着列配置走。后端如果返回了状态枚举值,前端只需要把枚举对应的文案和颜色配置好,就能渲染出一致的状态列。
操作列的动态化要小心权限问题。同一个表格,不同角色看到的操作按钮不一样。简单粗暴的做法是列配置里用一个数组存当前角色允许的操作按钮:
{ key: 'op', prop: 'op', label: '操作', type: 'op', width: 180, fixed: 'right', buttons: ['view', 'edit', 'delete'], }模板里根据col.buttons遍历生成按钮。这个方案胜在灵活,但要注意:操作按钮应该尽可能少。列宽是有限的,按钮一多横向滚动条就出来了。我一般限制在 3 个以内,更多操作收进“更多”下拉菜单。
3. 表格优化:性能与体验的双重提升
3.1 大数据量的三条出路
Element UI 的 el-table 在小数据量下表现很好,但数据量一旦过千,渲染和滚动都会开始卡。很多人遇到这个问题第一反应是“换组件”,其实完全可以把优化等级从低到高排一遍,很多场景根本不需要换。
第一档是服务端分页。表格一页显示 20 条,后端返回总条数和当前页,这是最稳妥的方案。代价是每次翻页都要请求接口,体验上稍微差点,但性能绝对没问题。适合单页数据量本身很大、但用户并不需要一次性看全的场景。
第二档是前端分页 + 懒加载。接口一次性把全量数据返回,前端用el-pagination做分页,只渲染当前页数据。适合几千条以内、且需要全量数据做前端统计的场景。这个方案等于把服务端该干的活揽到前端,内存占用会高,但交互流畅。
第三档是虚拟滚动。el-table 本身没有虚拟滚动,需要借助第三方库或者自己实现“只渲染可视区域行数”的逻辑。这里有个实战技巧:可以自己做一个简易版虚拟滚动,给表格外层容器绑定scroll事件,维护startIndex / endIndex,数据切片后传给表格,并设置一个足够高的占位元素保持滚动条高度。听起来复杂,但本质上就是“让表格只渲染它看得到的行”。
还有一个加分项:如果表格树形数据特别深,一定要用lazy懒加载子节点,不要一次性把整棵树塞进data。否则节点展开时渲染压力会直接拉满。
3.2 列配置的更新策略:数组替换、key、doLayout
动态列在运行过程中会不断变化,更新策略直接决定性能表现。我总结过一个优先级清单,从低到高:
第一步,轻微变化用doLayout。比如列宽调整、某一列的show-overflow-tooltip切换,这些改动只是让表格重新计算布局,调用this.$refs.table.doLayout()即可,成本最低。
第二步,列的显隐变化,且列结构基本不变时,用数组整体替换。给visibleColumns赋新值,让列组件重新渲染,然后在$nextTick里调用doLayout,避免列宽计算错位。
第三步,列结构“翻天覆地”的变化,比如切换业务类型后列全变了,这时最省心的办法是给表格加一个动态的key,强制整个表格重建:
<el-table :key="tableKey" :data="tableData" border v-loading="loading" > </el-table>computed: { tableKey() { return this.visibleColumns.map((col) => col.key || col.prop).join('-'); }, },这个tableKey只要列配置一变,值就会变,Vue 就会销毁旧表格、创建新表格。它能解决 90% 的“列变了但 UI 没变”问题,但副作用也很大:表格重建意味着当前排序、展开行、滚动位置全部丢失。所以它只适合列结构彻底更换的场景,比如切换业务类型;日常的列显隐调整,能不用就别用。
3.3 渲染性能的细节:少用插槽、合理使用 formatter
el-table 的单元格数量 = 行数 × 列数。一万行、二十列的表格,就是二十万个单元格。每个单元格如果都走 scoped slot,Vue 要为它创建一个 VNode,内存占用和渲染时间都是线性上涨的。
所以优化渲染性能的核心原则是:能用formatter的列,绝不用插槽。formatter是纯函数调用,开销远小于插槽。只有涉及按钮、标签、图片这类“非纯文本”内容,才值得用插槽。
另一个细节是show-overflow-tooltip。这个属性很好用,单元格内容超长时自动省略并显示 tooltip。但如果大量列同时开启,鼠标悬停时会重复创建 tooltip 实例,明显拖慢交互。建议只对文本确实可能超长的列开启,比如“备注”列;日期、金额这类有固定宽度的列不要开。
大数据量下还要慎用fixed固定列。固定列的实现原理是同时在左右两侧渲染遮罩层和独立的固定表格,列越多,浏览器需要同步更新的 DOM 就越多。固定左侧的操作列一般问题不大,但左侧固定两列、右侧固定两列的情况下,卡顿会成倍放大。
3.4 列宽拖拽、固定列与交互细节
动态表格的交互优化里,列宽拖拽保存是很容易被遗漏的一环。用户辛苦拖好了列宽,一刷新全回到默认值,体验很差。
Element UI 的表格列头支持拖拽,但没有提供拖拽结束后的保存事件。需要监听header-dragend事件:
<el-table :data="tableData" border @header-dragend="onHeaderDragend" > </el-table>onHeaderDragend(newWidth, oldWidth, column, event) { const col = this.allColumns.find((item) => item.key === column.key || item.prop === column.property); if (col) { col.width = newWidth; this.saveColumnConfig(); } }注意header-dragend的参数顺序:第一个是新宽度,第二个是旧宽度,第三个是列对象,第四个是原生事件。列对象上的property字段是当前列对应的 prop,key是列实例的 key,可以用来反查你的列配置。
固定列和动态列混用的时候,另一个高频问题是:列隐藏再显示之后,固定列的位置或者阴影会错乱。这种问题要么调用doLayout重新计算,要么给表格加 key 重建。我一般先试doLayout,不行再上 key。
4. 表格初始化:从接口到展示的正确姿势
4.1 先拿列配置还是先拿数据
表格初始化的时序问题,是动态表格里出现“表头空白但数据已经渲染”、“表头闪一下才出来”的根源。
如果列配置是后端接口返回的,建议初始化时统一走同一个方法,并且用 loading 盖住整个表格区域,避免用户看到中间状态:
async created() { await this.initTable(); }, async initTable() { this.loading = true; try { const colRes = await fetchColumns(this.bizType); this.columns = normalizeColumns(colRes.data); const dataRes = await fetchTableData(this.bizType); this.tableData = dataRes.data; } finally { this.loading = false; } }这段代码里,列配置先赋值,数据后赋值。表格组件在渲染时先拿到 columns 生成表头,再拿到数据渲染行,视觉上不会出现“数据先到、表头后补”的错位。
如果列配置来自本地常量,数据来自接口,初始化就简单多了:不用关心 columns 的加载,只需要注意数据返回之后表格能正确渲染。这里常见的问题是:接口返回的数据里字段名和列配置的 prop 对不上。比如后端返回create_time,列配置里 prop 写的是createTime。这种问题越早发现越好,可以在初始化时加一个字段校验,或者和后端协商统一命名。
4.2 默认列、默认排序与初始展开
表格初始化的时候,列配置不一定每次都要从后端全量拉取,很多项目的需求是“后端返回列定义,但用户有权配置自己关心的列”。这时初始化逻辑要分三步:拉取默认列配置、读取用户本地保存的列状态、合并生成最终列配置。
列配置合并完成之后,初始排序状态可以通过 el-table 的default-sort属性指定:
<el-table :data="tableData" :default-sort="{ prop: 'date', order: 'descending' }" @sort-change="onSortChange" > </el-table>:default-sort只负责初始状态,用户点击排序后触发sort-change,排序状态就由组件内部管理了。如果你需要在初始化时就按照某个字段排序数据,可以在获取数据后手动排一次序,再赋值给tableData。注意sort-change回调里拿到的order可能是ascending / descending / null,null 表示取消排序,需要做判空。
表格初始展开行也有两个入口:一是default-expand-all,适合数据量小、默认全展开的场景;二是expand-row-keys配合row-key指定要展开的行。后者在树形表格和跨页保持展开状态时更实用,但要注意row-key对应的字段必须全局唯一,否则展开状态会错乱。
4.3 loading、空数据与首次渲染闪屏
初始化表格时,我一般都会做三件事:开启全局 loading、清空旧数据、在数据返回前不让表格渲染出“空表头 + 旧数据”的中间态。
一个很实用的做法是用v-if控制“列表区域”和“空数据状态”:
<el-table v-if="!loading" :data="tableData" border v-loading="loading" > </el-table> <el-empty v-else-if="!tableData.length" description="暂无数据" />v-if="!loading"能确保 loading 期间表格整体不渲染,避免用户看到表头闪现。不过要注意:v-if切换会重新创建表格,如果频繁刷新,性能反而下降。对于数据量大、列复杂的大表格,我更倾向于始终渲染表格,用v-loading盖层,而不是v-if销毁重建。
空数据状态不要直接依赖tableData.length来判断。在接口返回之前,tableData可能是空数组,这时显示“暂无数据”会误导用户。正确的做法是:用loading区分加载中和无数据两种情况,加载中显示 loading,加载完成且数据为空才显示空状态。
5. 隐藏功能:列显隐、状态记忆与隐藏技巧
5.1 列设置面板:抽屉 + 复选框
列显隐功能最标准的做法是做一个“列设置”面板,用el-drawer或el-popover承载,内部放一组复选框,勾选哪些列就显示哪些列。
我习惯用抽屉,因为列多的时候弹层不够地方放。核心逻辑很简单:
<el-drawer title="列设置" :visible.sync="columnSettingVisible" size="320px"> <el-checkbox-group v-model="checkedProps"> <el-checkbox v-for="col in allColumns" :key="col.key || col.prop" :label="col.prop"> {{ col.label }} </el-checkbox> </el-checkbox-group> <div style="margin-top: 16px; text-align: right"> <el-button @click="resetColumnSettings">重置</el-button> <el-button type="primary" @click="columnSettingVisible = false">确定</el-button> </div> </el-drawer>computed: { visibleColumns() { return this.allColumns.filter((col) => this.checkedProps.includes(col.prop)); }, },这个方案里有几个容易踩坑的细节:
el-checkbox-group的v-model是值数组,el-checkbox的label就是值本身。如果你的列 prop 里有数字(比如id),checkedProps数组会是数字类型,不要和字符串混淆。
列设置面板打开时,应该显示当前可见状态,所以每次打开抽屉时都需要同步checkedProps的初始值:
openColumnSetting() { this.checkedProps = this.visibleColumns.map((col) => col.prop); this.columnSettingVisible = true; },如果用户在面板里取消勾选某列,表格会立即响应。有时列太多,用户想快速全选/全不选,面板顶部加两个按钮会更友好。
5.2 列顺序调整与本地持久化
列显隐只是第一步,用户通常还希望自己调整列的顺序。Element UI 的表格本身不支持拖拽列,但我们可以用简单的“上移/下移”按钮实现列排序,比引入拖拽库轻量得多。
moveColumn(index, offset) { const targetIndex = index + offset; if (targetIndex < 0 || targetIndex >= this.visibleColumns.length) return; const columns = [...this.visibleColumns]; const temp = columns[index]; columns[index] = columns[targetIndex]; columns[targetIndex] = temp; this.visibleColumns = columns; this.$nextTick(() => this.$refs.table.doLayout()); }模板里在每一列的表头渲染排序按钮比较麻烦,因为 el-table-column 的 header 插槽需要作用域插槽动态渲染。简单方案是:在列设置抽屉里给每列显示一对上下箭头按钮。这样既避免了表头复杂化,也让用户明确知道“列设置”入口是集中管理列配置的地方。
列配置持久化到 localStorage 是最常见的做法。保存的时候要注意一个陷阱:只保存“用户勾选了哪些列”是错的。用户隐藏了某列之后刷新,如果只保存可见列,被隐藏列的信息就丢了,下次打开它不会再出现在“全部列”里,用户想恢复也恢复不了。
正确做法是保存“被隐藏列的 prop 集合”:
saveColumnConfig() { const hiddenProps = this.allColumns .filter((col) => !this.checkedProps.includes(col.prop)) .map((col) => col.prop); localStorage.setItem(this.storageKey, JSON.stringify(hiddenProps)); }, loadColumnConfig() { const hiddenProps = JSON.parse(localStorage.getItem(this.storageKey) || '[]'); this.checkedProps = this.allColumns .filter((col) => !hiddenProps.includes(col.prop)) .map((col) => col.prop); },这样即使以后新增了列,默认也会显示出来,不会被旧的隐藏列表误伤。存储键名建议加上模块前缀,比如report_table_columns,避免和其他表格配置互相覆盖。
5.3 更多“隐藏”的玩法:表头、边框、行与单元格
除了列显隐,表格还有不少“隐藏”相关的操作,有些属于视觉层,有些属于数据层,混在一起容易搞混。
隐藏表头和边框最简单,两个属性搞定:
<el-table :data="tableData" :show-header="false" :border="false"></el-table>隐藏行有两条路线。一条是用row-class-name给某些行加类名,配合 CSS 如display: none实现视觉隐藏。但这条路不推荐,因为行虽然看不见,仍然占着表格的行数,分页、统计、事件绑定都可能出问题。更推荐在数据层面过滤:把不需要展示的行直接从tableData里筛掉。
隐藏单元格内容经常出现在权限控制场景。某个字段不能给低权限用户看,但又不能整列去掉。这时可以用formatter返回***或者空字符串:
{ prop: 'salary', label: '薪资', formatter: (row, column, cellValue) => { if (!this.hasSalaryPermission) return '***'; return cellValue; }, },隐藏整个表格区域,比如“高级搜索打开时才显示筛选结果表格”,用v-show控制即可。注意v-show和v-if的区别:v-if会销毁重建表格,性能开销大;v-show只是 CSS 隐藏,表格 DOM 还在,性能开销小但初始渲染成本不会省。
还有一个容易被忽略的隐藏需求:隐藏分页器。当数据总量不足一页时,分页器没必要显示。可以用v-if="total > pageSize"控制分页器显示,这个小细节能明显提升界面整洁度。
6. 常见问题与排查实录
6.1 表头不更新、列串位的排查顺序
动态表头最常见的问题就是“列变了,表格没变”或者“列的位置和内容对不上”。我自己的排查顺序通常是这样:
先看 key。el-table-column的 key 是不是用了 index?如果是,先改成prop或唯一 id。这是最普遍的引发列串位的原因,尤其是列顺序调整之后。
再看列配置数组是不是被直接修改了。this.columns.push(...)或者this.columns[0].label = 'xxx'这种写法,在动态表格里很容易无效。改成整体赋值新数组。
再看有没有调用doLayout。col 的显示隐藏、宽度变化之后,表格的布局计算可能停留在旧状态。在$nextTick里调用doLayout,大多数卡住的问题能解决。
最后才考虑用表格 key 重建。列结构确实大变时,用:key="tableKey"强制重建最省心。但要注意这个操作会让表格状态重置,所以只作为兜底手段。
6.2 动态插槽、合并单元格与列索引的坑
动态插槽的问题多出在作用域插槽的变量解析上。一个典型表现是:表格正常渲染,但插槽里scope.row拿到的值是 undefined。这时先检查该列有没有设置prop,如果列配置里 prop 丢了,插槽就拿不到当前行对应字段的值,因为 Element UI 的默认插槽作用域依赖列 prop 去索引数据。
合并单元格span-method在动态列下容易出错,主要原因是列索引不固定。表格列可以隐藏、排序,你写死的columnIndex === 0可能根本不是你想合并的那一列。
正确的做法是在span-method里先通过columnIndex拿到当前列的配置对象,再根据配置决定是否合并:
objectSpanMethod({ row, column, rowIndex, columnIndex }) { const col = this.visibleColumns[columnIndex]; if (!col || !col.mergeBy) { return [1, 1]; } // 根据 col.mergeKey 做相同值合并 const mergeKey = col.mergeKey || col.prop; const rows = this.tableData; if (rowIndex === 0 || rows[rowIndex][mergeKey] !== rows[rowIndex - 1][mergeKey]) { let rowspan = 1; for (let i = rowIndex + 1; i < rows.length; i++) { if (rows[i][mergeKey] === rows[rowIndex][mergeKey]) rowspan++; else break; } return [rowspan, 1]; } return [0, 0]; },这里我用col.mergeKey指定要按哪个字段合并,不用写死列索引,列隐藏后索引变化也能正确匹配到配置项。
6.3 固定列、树形表格与动态列混用
固定列和动态列混用,最大的问题是列切换后阴影和布局错乱。比如左固定列在列切换后出现多余阴影,或者固定列和滚动区域之间出现白边。这个问题的根源是表格重新计算布局时,固定列的信息没有同步更新。
处理方式还是老三样:doLayout()优先,不行重建。固定列数量越多、列结构越复杂,越倾向于直接重建。
树形表格和动态列混用,要注意row-key的稳定性。树形结构里每一行必须有唯一标识,否则展开、折叠操作会混乱。如果数据里有重复值(比如行的 id 都是空),展开时会报“Duplicate keys”警告。这个问题的解决方案是在获取数据后给每行补一个唯一标识,比如row.id = row.id || generateUid()。
树形表格的懒加载和动态列的配合也要小心。lazy模式要求每一行都知道children是否是懒加载节点,动态列切换不会重新触发懒加载逻辑,所以切换列配置时最好把已展开节点清空,否则表格内部状态可能残留。
6.4 localStorage 读取异常的容错
本地存储保存列配置,最容易被忽略的是容错问题。用户清空浏览器缓存后旧数据还在,或者两个项目共用同一个 key,导致读取到的 JSON 结构对不上,页面直接白屏。
所以每次读取本地存储,都必须是“解析失败就回退默认值”的写法:
loadColumnConfig() { let hiddenProps = []; try { const raw = localStorage.getItem(this.storageKey); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { hiddenProps = parsed; } } } catch (e) { hiddenProps = []; } this.checkedProps = this.allColumns .filter((col) => !hiddenProps.includes(col.prop)) .map((col) => col.prop); },另外,列配置持久化时不需要把整个列对象都存进去,只存 prop、宽度、是否可见这类用户可控字段。后端一旦更新了列定义,比如新增了一个字段,本地旧配置才不会阻碍新字段的展示。
写到这里,做一个最后的小提醒:动态表头本质上不是“炫技”,它解决的是“页面结构由数据驱动”的真实业务问题。无论你最后选了哪种方案,都要把“列配置数据化”贯穿始终——列是数据,表头是数据的镜像,那么一切显隐、排序、持久化都只是数据操作,不再需要碰模板结构。
我个人的体会是:面对动态表格需求时,先和业务方确认“动态”的边界到底在哪里,是列显隐、列排序、还是整表结构都会变?边界越清楚,代码方案就越简单。另一个实战小技巧是:在页面上放一个“重置表格布局”的按钮,一键清除本地存储并恢复默认列配置。这个按钮表面上不起眼,但它能解决大量由于调试列配置导致的状态错乱问题,也省掉了很多“我明明设置过但刷新后没了”的客服式沟通成本。