接手过一个中后台项目,用户角色多达几十种,每个角色打开同一张订单列表,看到的列完全不一样。有人要金额列有权限看,有人只需要订单号和处理状态;产品说今天先支持这个,下个迭代还要支持自定义列拖拽。那段时间我每天面对el-table的columns配置都快整出阴影了,踩过动态表头、动态内容、初始化闪烁、大数据渲染一堆坑之后,总算沉淀出一套能直接拿来用的方案。这篇就把这些实战里得出的经验全部摊开讲清楚,从动态表头和动态内容的实现,到初始化表格容易踩的坑,再到表格性能优化和隐藏功能怎么做一个更全面的总结。
这篇文章适合正在用Element UI(也包括Element Plus)开发后台管理系统的前端同学,尤其是被动态列、异步表头、大数据渲染这类需求折磨过的人。我会尽量讲清楚每一步为什么要这么做,而不是只丢给你一段能跑的代码。
1. 动态表头本质上是"列配置"问题,而不是渲染问题
很多人一听"动态表头"就想到写一堆v-if去控制每个列的显示隐藏,结果页面越来越乱,加一个列要改三个地方,删一个列还要担心哪里报错。实际上,el-table的列渲染完全由columns数据驱动,你把表头当成普通业务数据处理,问题就简单了一大半。
1.1 先把列配置设计成纯数据
我习惯把所有可能的列定义成一个数组,每一项包含展示所需的信息。Element UI里最常见的列属性就是label(表头文字)、prop(字段名)、width或minWidth(列宽),这些已经能覆盖大部分场景,但实际项目里肯定不止这么简单。我通常会在列配置里额外挂一个visible字段表示该列是否展示,有时还要用到fixed锁定列、align控制对齐、sortable开启排序、formatter格式化单元格内容。
// column.config.js const orderColumns = [ { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left', visible: true }, { prop: 'customer', label: '客户名称', minWidth: 140, visible: true }, { prop: 'amount', label: '订单金额', width: 120, align: 'right', sortable: true, visible: true, formatter: formatAmount }, { prop: 'status', label: '处理状态', width: 110, visible: true }, { prop: 'operator', label: '操作', width: 220, fixed: 'right', visible: true } ]这里有个值得注意的点:fixed: 'left' 和 fixed: 'right' 在纯数据里是真实存在的,动态控制的时候必须保留,否则操作列滚到屏幕外面找不到了。列配置里的visible字段是运营和后端下发的核心依据,前端拿到权限数据后直接对这个数组做过滤。
写到这里可能有人问,为什么不直接把不可见列硬编码在表格模板里用v-if控制?因为这种做法一旦列多起来,模板里的v-if会膨胀到没法看,而且每次权限调整都要改前端代码重新发版。把列定义为数据后,后端完全可以通过接口下发当前角色可见的列字段列表,前端只需要做一次映射过滤,新增列不再需要发版。
1.2 computed驱动动态表头的推荐姿势
动态表头最常用、最稳的一种做法,是用computed计算属性对列配置数组做过滤,然后直接v-for生成el-table-column。这里我没有选择把过滤后的columns放在data里,而是用computed,原因在于computed有缓存,只有依赖的权限数据或原始列配置变化时才会重新计算,既省性能又不会出现手动维护同步的尴尬。
<template> <el-table :data="tableData" border style="width: 100%"> <el-table-column v-for="col in visibleColumns" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" :min-width="col.minWidth" :fixed="col.fixed" :align="col.align" :sortable="col.sortable" > <template v-if="col.slotName" #default="scope"> <slot :name="col.slotName" :row="scope.row"></slot> </template> </el-table-column> </el-table> </template> <script> export default { computed: { visibleColumns() { return this.allColumns.filter(col => col.visible !== false) } } } </script>这里有几个容易踩的坑要提醒一下。key一定要用prop或唯一id,不能用index。el-table-column复用和diff的时候,index做key会导致列状态错乱,比如某列的排序状态、筛选条件被带到另一列上,排查起来极其痛苦。另外,width和minWidth不能同时设置,否则在某些宽度下表现会特别奇怪。固定列数量增多后,渲染性能也会下降,这个后面优化部分细说。
1.3 后端权限驱动时的列顺序问题
动态表头不只是"显示/隐藏",很多系统里还要求列顺序可调。比如后端返回一个JSON数组,按顺序存放当前角色可见的列字段名,前端要按这个顺序渲染表格列,这时候单纯的filter就不够用了。
我当时的处理方式是,后端返回的字段名数组作为基准顺序,本地全量列配置建立以prop为key的Map,然后按顺序取出列配置。如果后端下发了一个本地没有的字段名,要设计降级策略,不然表格会渲染出未知列。
const columnMap = new Map(orderColumns.map(col => [col.prop, col])) const buildVisibleColumns = (permissionFields) => { return permissionFields .map(field => columnMap.get(field)) .filter(Boolean) }为什么用Map而不是直接在数组里find?列配置可能有几十列,每次过滤都遍历一遍全量数组,性能其实没那么敏感,但代码维护上Map语义更清晰。真实项目里权限字段来自接口,通常是一个数组,接口返回顺序代表了产品希望呈现的顺序,用这套逻辑处理起来非常自然。
2. 动态内容才是真正花时间的地方
标题里"动态表头及内容"中的"内容",指的是单元格里面不只有纯文本,可能是状态标签、操作按钮、图片、自定义组件,甚至要根据用户权限显示不同内容。这部分比表头复杂得多,因为el-table-column默认只渲染文本,动态内容需要借助插槽或render函数来做。
2.1 插槽方案维护成本最低
如果你只是在列配置里加个slotName字段,模板里再写几个具名插槽,维护起来最直观。业务上常见的就是状态列转标签、操作列放按钮这两大类,用插槽反而比用render函数更容易让别人接手。
<el-table-column :prop="col.prop" :label="col.label" v-bind="col.attrs"> <template #default="{ row }"> <template v-if="col.prop === 'status'"> <el-tag :type="statusMap[row.status].type">{{ statusMap[row.status].label }}</el-tag> </template> <template v-else-if="col.prop === 'operator'"> <el-button size="mini" type="text" @click="handleView(row)">查看</el-button> <el-button size="mini" type="text" @click="handleEdit(row)">编辑</el-button> </template> </template> </el-table-column>这种做法的问题在于,写死在模板里的列内容还是不够"动态"。列一多,模板里挤满了v-if判断。更好的思路是把单元格渲染逻辑也抽象成配置化的:每个单元格配置项里有一个render函数,或者直接关联一个组件名,FrontEnd动态解析组件。
其实Element UI本身支持在el-table-column里通过formatter属性做简单的单元格格式化,但formatter适合纯文本格式化,比如金额加千分位、时间戳转日期。一旦涉及复杂交互(比如单元格内放一个下拉框、一个开关、一个进度条),就必须用插槽或render。
2.2 render函数的适用边界
有的团队喜欢全程用render函数渲染单元格,把整个表格配置化推到极致。我见过类似做法,所有列配置里带着render函数,组件库自己的覆盖逻辑全部忽略,统一走render。灵活度确实高,但维护成本也不低。
我用render函数处理过一个需求:状态列要根据后端返回的多状态值展示不同颜色和提示文字,操作列要根据每行数据的流程节点动态展示不同按钮组。这种逻辑用插槽写,模板里全是条件渲染;纯粹用render函数写,代码又会变得不太直观。
{ prop: 'status', label: '流程状态', render: (h, { row }) => { const statusInfo = workflowStatusMap[row.workflowNode] return h('el-tag', { props: { type: statusInfo.type, size: 'mini' } }, statusInfo.label) } }我个人建议是,把render函数作为插槽的补充,而不是替代。列配置里同时允许slotName和render两个字段,使用优先级是render优先于slotName还是反过来,由项目自己定。一旦定下来就不要变,不然同事接手时会很容易混乱。
2.3 动态组件方案的工程化思路
真正到了几十列、几十种单元格组件的大后台,就需要往组件化方向走了。我的做法是,在列配置里加一个component字段,值是组件的注册名,加一个config字段,值是传给该组件的props。渲染单元格时用component标签动态解析出来。
<template #default="{ row }"> <component :is="col.component" v-if="col.component" :row="row" v-bind="col.componentConfig" /> </template>这样新增一种单元格类型时,只需要写一个组件、在列配置里指定component名就行,表格模板完全不用动。图片列、标签列、操作列、自定义进度列全是组件,维护起来特别清爽。这个方案的代价是需要维护一个组件注册表,并在config层面做类型校验,但这些成本在复杂项目里是值得的。
3. 初始化表格的三个坑:闪烁、错位、数据丢失
标题里的"初始化表格"是很容易被轻视但实际很重要的部分。我们在项目里遇到的最典型问题是:表格组件先以空columns渲染了一遍,接口返回列配置后再次渲染,导致页面闪烁;或者因为异步拿到的列配置没有保持对象引用,表格复用了旧的表头。
3.1 用isTableReady控制渲染时机
最简单的杜绝闪烁方案,是在column配置没有就绪之前,不渲染el-table。用一个布尔值isTableReady来控制,请求返回后再设为true。这也意味着页面会先展示一段空白区域,视觉上可以配合骨架屏使用。
<el-table v-if="isTableReady" :data="tableData" :columns="visibleColumns" />我知道有人担心这样会不会晚展示表格、影响体验。实际上动态表头场景下,后端接口返回列配置一般都会伴随列表数据一起返回,等待时间是可控的,总比表头闪烁、列错位要强得多。骨架屏可以完美弥补这个空窗期。
3.2 el-table的columns更新机制:prepare是幕后功臣
Element UI的el-table监听到columns变化后,会通过一个prepare方法重新计算表头布局和列宽,这时如果你的columns数组是异步获取后直接替换的,理论上它也会重新prepare。但在某些边界情况下(比如列配置变化后,表格宽度没有重新计算),会出现列宽不对的情况。
我在某项目里遇到过:从接口拉回列配置后,所有列宽都变成默认宽度,像挤在一起一样。排查了半天,发现是因为组件内部对columns变化做了浅比较,也就是只比较数组的length,我替换的是同一个长度的数组,但它没能触发必要的重新计算逻辑。
解决方法是每次拿到新列配置时,用扩展运算符创建新数组:
this.visibleColumns = [...buildVisibleColumns(permissionFields)]创建新数组可以保证引用变化,el-table才会重新走prepare流程。如果项目里实在没办法保证引用变化,也可以给el-table加一个动态key,比如把列配置的版本号拼进去:
<el-table :key="columnVersion" :data="tableData">加key等于强制销毁重建表格,是最后的兜底手段,除非必要,否则不要依赖,因为重建表格会让滚动位置、展开状态全部丢失。
3.3 分页切换时动态内容的恢复问题
初始化表格还有一个容易忽略的场景:表格分页从第3页切到第4页,或者数据刷新后,之前某些行的展开状态、选中状态会丢失。这实际上和表格初始化关系很大,因为el-table内部的状态管理依赖唯一性标识。
如果行数据里没有稳定唯一的row-key,选中、展开、排序这些操作都会出问题。我建议在el-table上一定要配row-key,最好用数据里的业务主键:
<el-table :data="tableData" row-key="id">有些接口返回的数据没有主键,可以前端生成一个唯一标识塞进去。虽然会给数据处理增加一点开销,但换来的是初始化之后各种表格状态的可控性。我踩过这个坑,之前没配row-key,分页回来之后选中的行全部丢了,用户反复投诉。
4. 表格优化:性能问题往往出现在你意想不到的地方
优化是标题里明确提到的,也是中后台最容易被人忽视的部分。表格数据量一旦上去,性能问题就会爆发。但性能优化不能无脑上,要搞清楚瓶颈在哪儿。我总结下来,el-table性能问题主要出现在三个层面:渲染体积、复杂计算、事件绑定。
4.1 数据量不大但卡,问题大概率在列配置和插槽
很多人以为数据量大才会卡,其实几十条数据、几十列的时候也可能非常卡,卡点往往在列配置里的render函数和插槽被大量执行。比如状态列的render里每次做复杂的字段映射、操作列渲染了十几个按钮,都会拖慢渲染。
我处理过一个"订单列表只有30条但页面卡得没法用"的问题,排查发现每一行的操作列渲染了8个按钮,按钮还绑定了各自的点击事件。改成按钮组下拉菜单后,渲染量和事件绑定数量大幅减少,页面瞬间流畅了。
如果你的动态表头里有大量列,优先给比较宽的列设置minWidth,让列宽自适应内容,而不是所有列都固定width,这样可以减少横向滚动条频繁出现的概率。同时,明确不需要tooltip展示的列不要开启show-overflow-tooltip,这个功能开销不低。
4.2 大数据量下再谈虚拟滚动和分页
真正成千上万行的表格,Element UI默认渲染完全扛不住。这时候有几个方案:
- 后端分页是首选,不要把所有数据都拉下来;
- 前端开启el-table的纵向虚拟滚动,但是需要配合固定列使用;
- 自己实现虚拟列表组件。
Element UI 2.x的el-table其实没有官方虚拟滚动,需要引入第三方或者自己封装。Element Plus的el-table在2.0版本里支持了虚拟滚动,可以用virtual-scroll属性开启。这块要是展开讲又是一大篇文章,这里提个醒:虚拟滚动和fixed列同时使用时,会出现固定列表格体和滚动列表格体滚动态不同步的问题,要么自己在scroll事件里同步,要么就尽量减少固定列的数量。
实际场景里,中后台表格数据量一般控制在几百条以内,完全用不上虚拟滚动,只要做好分页就非常流畅。为了性能去强上虚拟滚动,反而会把简单的问题搞复杂。
4.3 单元格内容展示用预处理代替实时计算
另一个优化点是,把能在数据层面预处理的逻辑挪到数据进表格之前做掉。比如状态映射、金额格式化,不要每个单元格渲染的时候才去计算,可以在拿到后端数据后先map一遍,把展示字段预先算好存起来。
const rows = rawData.map(item => ({ ...item, statusLabel: statusMap[item.status]?.label || item.status, statusType: statusMap[item.status]?.type || 'info', amountText: formatMoney(item.amount) }))这么做的好处是,表格渲染只做最轻量的文本输出,性能会明显提升。代价是数据里多了几个展示字段,内存占用会略微增加,但对前端来说是划算的。特别是动态表头场景下,列配置经常变化,把展示逻辑从渲染层抽离到数据层,动态切换时负担更小。
5. 隐藏功能:列隐藏、行隐藏与敏感信息脱敏
标题里的"隐藏功能"实际上是个很大的概念。我在项目里至少把它拆成了三个场景:一是表格列级隐藏,二是行级过滤隐藏,三是单元格信息脱敏。三者思路完全不同,容易混在一起。
5.1 列隐藏还要考虑"隐藏后列宽释放"问题
前面提到过滤列配置实现列隐藏,这其实是最常见的做法。但隐藏列之后有一个视觉细节很多人忽略:某些列设置了固定宽度,隐藏后极右操作列会向左移动,视觉上可能跳变;另外有些列隐藏后,剩余的列不会自动将空间填满,表格右侧会出现一大块空白。
处理思路有两个。一是用minWidth代替width,让隐藏列之后剩余列自动撑开;二是用强制布局,在最后一列设置为弹性宽度(宽度设为一个较大值),尽量让表格充满容器。实际项目中更多是两种方案结合,核心列用minWidth保证内容完整展示,操作列用fixed固定在右侧。
5.2 行隐藏不等于删除,要区分业务需求
行级的隐藏往往是根据搜索条件或权限过滤,直接把这部分数据从列表里去掉即可。但有一种行隐藏很特殊:某行数据对当前用户可见,但内部某些字段或操作按钮需要隐藏。这种场景用el-table的row-class-name、cell-class-name或者自定义插槽来实现。
我之前遇到一个需求,普通用户能看到订单列表里的某个敏感行,但行尾的"编辑"按钮必须隐藏,改成"申请权限"按钮。这时我在列配置里给操作列组件传入当前用户权限,组件根据权限判断渲染哪种按钮。这比在表格外层统一做拦截要细得多,也更灵活。
5.3 信息脱敏:真正的"隐藏"是在源头做
手机号、身份证、银行账户这类敏感信息,在中后台表格里展示时按规范需要做脱敏处理。好的做法不是前端渲染时再隐藏,而是在接口返回前就处理掉,或者后端直接返回脱敏后的字符串,这样在浏览器端就不存在明文。
如果前端必须拿到完整数据再脱敏展示,我建议统一封装一个脱敏工具函数,在数据预处理阶段把展示字段替换掉,不要在单元格渲染层边算边展示,否则源数据一旦被调试工具看到就存在风险。这属于安全层面的习惯问题,比UI效果重要得多。
列隐藏和数据脱敏在业务含义上不同,一个是不可见,一个可见但部分打码。不要把这两件事混在一个配置字段里控制。我当下的列配置里,visible表示列是否展示,maskFields表示需要脱敏的字段列表,两者各管各的,协作起来很清晰。
6. 一组封装建议:把动态表格沉淀为通用组件
做的项目多了以后,你会发现动态表头、动态内容、列隐藏这些能力其实可以沉淀成一个通用组件,避免每个页面都写一遍v-for和插槽逻辑。我把这个组件命名为SmartTable,严格意义上不属于某个具体库,但它对el-table做了一层薄封装。
6.1 SmartTable组件的最小结构
组件的核心思路是:接收一个columnConfig数组和一个data数组,内部维护visibleColumns计算属性,负责v-for渲染所有列。所有自定义单元格内容通过插槽透传,外部不需要每次写重复的列渲染逻辑。
<template> <el-table :data="data" v-bind="$attrs" v-on="$listeners"> <el-table-column v-for="col in visibleColumns" :key="col.prop" v-bind="col.attrs || {}" :prop="col.prop" :label="col.label" > <template v-if="col.slotName" #default="scope"> <slot :name="col.slotName" v-bind="scope"></slot> </template> <template v-else-if="col.render" #default="scope"> <RenderCell :render="col.render" :scope="scope" /> </template> </el-table-column> <template #empty> <slot name="empty"></slot> </template> </el-table> </template>这里用了一个小的RenderCell辅助组件来执行render函数,避免直接在模板里调用。组件内部通过provide/inject把外层注入了权限信息,这样所有列配置里的组件和render函数都能拿到当前权限。
6.2 插槽穿透的具体玩法
SmartTable对外暴露插槽,使用方在某个页面就可能这样用:
<SmartTable :columns="orderColumns" :data="orderList"> <template #status="{ row }"> <el-tag :type="statusMap[row.status].type">{{ statusMap[row.status].label }}</el-tag> </template> <template #operator="{ row }"> <el-button @click="handleEdit(row)">编辑</el-button> </template> </SmartTable>页面里只需关注业务插槽的实现,列配置、隐藏逻辑、权限过滤全部收敛到SmartTable内部。新来的同事看代码,只需要看当前页面的插槽内容,不需要理解表格内部的全套动态渲染逻辑,上手成本降低很多。
这种封装方式不是说一定要用在所有项目里,但一旦你的系统里有五六个页面都要做动态列配置,这个收益就非常明显了。等于把最麻烦的部分一次解决,后续页面只需要配置数据和一个插槽。
6.3 通用化之前要先想清楚的边界
不是所有表格都适合塞进同一个通用组件里。比如需要行合并、列合并、树形表格这类特殊能力的页面,强行套SmartTable反而会牺牲灵活性。我踩过这个坑,硬把树形表格塞进通用组件里,内部逻辑膨胀到难以维护,最终又拆出来单独实现。
我的建议是,通用组件覆盖80%的常规动态列表场景,剩下20%的特殊需求单独实现页面。通用组件内部要留好扩展出口,比如透传$attrs,暴露headerCellClassName、getRowClassName这类覆盖接口。边界想清楚了,封装才不变成负担。
7. 实测后的一些体会
动态表头和动态内容这套方案,我在几个中后台项目里反复打磨过,最终最大的感受是:不要为了动态而动态,也不要为了配置化而配置化。前端把列配置数据化以后,确实能应对后端无所不在的权限变化需求,但这也意味着你的数据层和展示层的解耦设计必须到位。
我最后悔的是在第一版里把所有单元格交互全部用render函数实现,确实很灵活,但排查问题的时候真的费劲,尤其别人接手时,看到一堆render函数根本没有排查欲望。后来改成插槽+组件的模式,代码可读性提升了一整个台阶,新人也能快速看懂。
另外一个很重要的习惯是,把列配置独立成一个文件或模块,不要散落在各个页面组件里。这样权限后端返回的字段列表发生变化时,前端改一处配置映射就行了。真到了需要做列自定义拖拽的时候,也是对同一个列配置数组做排序操作,整个思路完全一致。
动态表头这个东西,说难吧,理解了列配置驱动渲染就通了;说简单吧,真要做得顺手、做得让团队其他人也能接手,还是需要沉淀。我希望这篇文章里讲到的列配置设计、动态内容渲染、初始化规避、性能优化、隐藏功能处理这些经验,能帮你少走几条弯路。