
做后台系统做久了会发现一个规律表格列的数量只会越来越多永远别指望产品经理会砍字段。我手上维护过的一个订单列表从最早的十二列涨到后来的四十三列横着滚三屏还看不完。用户反馈里出现频率最高的一句话不是什么“加载太慢”而是“我要的那列在哪”。后来我在表格右上角加了个小齿轮图标点开是一组勾选框勾上就显示、取消就隐藏反馈立刻少了一大半。这件事的技术实现核心就是vxe-table 控制列显示而入口就是那个右上角图标按钮背后对应的是组件自身的列对象和显隐 API。这篇内容讲的就是这套东西怎么搭起来从为什么用这个方案、到custom-config参数怎么配、再到自己画一个图标按钮去接管官方面板的完整代码最后是几个踩过的坑和排查手法。适合已经上手 vxe-table、正在做中后台表格交互的同行也适合刚接触这个组件、想搞清楚“列为什么能说没就没”的同学。1. 需求拆解从四十列表格说起为什么右上角要放一个图标按钮1.1 用户抱怨的从来不是表格慢而是找不到要看的那列先把这个需求的真实来源说清楚。列很多的时候团队第一反应往往是给表格加横向滚动或者干脆把次要字段删掉。这两种做法我都试过效果都不好。横向滚动的问题是用户找一个字段的注意力成本被拉长了滚动条拖到第三屏的时候前面的行上下文已经丢了删字段的问题是每个角色关心的列不一样财务要看金额和结算状态运营要看渠道和转化你删谁的都是得罪人。真正合理的做法是把“看哪几列”的决定权交还给用户自己。一个人日常只关心五列他就把其余的全关掉表格立刻变得清爽一屏能看二十行眼睛不用来回跳。而做这个功能最省事、最不容易打扰主流程的位置就是表格区域的右上角——那个位置原本就常驻着刷新、导出、密度设置这几个图标按钮用户的肌肉记忆已经形成了“表格操作在右上角”的预期再塞一个齿轮进去完全不违和。还有一层考虑是空间占用。如果不用图标按钮而是换成一行“列设置”的复选框平铺在表格上方四十个字段会直接把表头挤到首屏之外反而制造了新的问题。弹层方式则是零占位点开才出现关掉就干净对页面布局的侵入最小。1.2 三条实现路径的横向对比官方面板、自绘弹层、右侧抽屉vxe-table 本身就带了列自定义的能力所以这件事不是一个“从零造轮子”的需求而是“选哪条路”的需求。我在不同项目里三条路都走过把差异列出来给你参考。方案实现成本可控性适合场景主要短板官方custom-config 工具栏按钮最低几行配置中受限于官方面板结构需求标准、工期紧的中后台面板样式跟你自己的设计系统难对齐自绘图标按钮 自绘勾选面板中需要自己维护列状态高每一处都能改有统一 UI 规范、需要定制交互要自己处理持久化与多级表头右侧抽屉式列设置中偏高高列特别多、需要搜索与分组遮挡内容区交互更重我的经验是如果项目只有一两张表格需要这个功能直接用官方的custom-config综合成本最低如果全站有二十张表格、并且有统一的设计规范那就自绘面板因为你要保证这个齿轮图标和全站其他图标按钮的尺寸、圆角、hover 态完全一致官方面板很难做到像素级对齐右侧抽屉我只在字段超过六十个、且需要按业务模块分组的时候用过那种场景下弹层的滚动体验反而更差。1.3 动手之前先把几个关键 API 摸清楚在写第一行代码之前有几个东西必须先搞明白否则后面全是试错。第一个是全量列和可见列的区别。vxe-table 的表格实例上getTableColumn()返回的是一个包含多个数组的对象tableColumn是完整的列树visibleColumn只是当前可见的那部分。做列设置面板的时候你必须拿tableColumn拿到visibleColumn就永远只能看到已经显示出来的列隐藏掉的列根本没有勾选框用户一旦取消勾选就再也勾不回来了。这个坑我见人踩过不止一次。第二个是列的visible字段。列配置上写visible: false这一列就会默认隐藏但它依然存在于列树里随时可以被重新显示。注意这个visible: false和条件渲染v-if完全是两回事v-if是把列从配置里彻底删掉重新出现的时候列的顺序、宽度、固定状态全丢。第三个是切换显隐的方法。较新的 4.x 版本提供了setColumnVisible(field, visible)这类便捷方法老一些的版本则要先用getColumnByField或者getColumnById拿到列对象改掉它的visible再触发一次重算。不同小版本之间方法名确实存在差异我下面的代码会写成带兜底的判断你的环境跑起来是哪个分支就走哪个。提示升级 vxe-table 小版本之前把列显隐相关的调用点列个清单过一遍这块 API 在版本迭代中调整过别等上线才发现某个方法没了。2. 列显隐的底层逻辑visible 字段与列对象的响应式链路2.1 列配置上的 visible 到底作用在哪一层很多人以为列显隐是操作 DOM把某个td或者th的display改成none。真这么做会立刻翻车——表格的列宽是统一计算的你把某一列藏了剩下的列宽加起来对不上容器宽度表头和内容区立刻错位横向滚动条也会发疯。vxe-table 的做法是纯数据驱动。组件内部维护着一棵列树每个列节点上都有visible这个布尔值渲染阶段只把visible ! false的节点拿去生成表头单元格和内容单元格。你改了这个字段框架的响应式系统感知到变化重新跑一遍渲染列就消失了同时列宽会被重新分配。整个过程没有一行 DOM 操作所以你不需要手动去修表格布局。理解这一点之后很多行为就顺理成章了。比如为什么隐藏列之后行高不会变、为什么隐藏列的数据其实还在data里只是没渲染、为什么getTableData()拿到的还是完整数据。也正因为是数据驱动自绘面板才那么方便——你只要改列对象上的一个布尔值剩下的交给组件。再往深一层说列的visible是挂在列节点上的而列节点又分父列和子列。多级表头的情况下父列的visible为false时它的子列应该跟着一起消失但如果只把某个子列设为false父列那一格表头还在只是少了一个子标题。这个层级关系是后面做递归处理的原因。2.2 custom-config 里那几个不得不改的参数就算你打算自绘面板也建议先把官方的custom-config参数过一遍因为它暴露的那些选项恰好就是这个功能需要思考的全部维度。参数常见取值作用我的建议triggerclick/manual面板由点击触发还是手动控制自绘面板时设为manual避免官方面板抢事件modepopup/modal弹层形态还是模态框形态列数少于三十用popup多了用modal更好滚immediatetrue/false勾选后立即生效还是点确定才生效字段少用true顺手字段多建议false防误触showFootertrue/false是否显示底部按钮区只有immediate: false时才需要开storagetrue/ 配置对象是否自动记忆列状态单表格页面直接开多表格页面务必配独立 keycheckMethod函数指定哪些列允许被勾选用它把操作列、序号列排除掉最省事storage这个参数值得单独说一句。开了之后组件会把用户的列显隐状态记在浏览器本地下次进来还是他上次的配置体验确实好。但它默认的存储标识往往跟表格的id或者字段结构绑定同一个页面里如果放了两个表格、结构又很像就有互相覆盖的风险。我的做法是给每个表格显式配一个独立的存储标识命名规则用“页面名 表格用途”比如“订单页-明细表”这样永远不会撞。checkMethod则是另一个被低估的选项。它传入当前列对象返回true表示这列可以被用户勾选。序号列、复选框列、行操作列这些本来就不该出现在设置面板里的东西用它一行函数就能筛掉比自己在面板里写v-if判断优雅得多。2.3 图标按钮的挂载位置与交互容器图标按钮放哪看似是个样式问题其实牵扯到定位和层级。表格容器一般都有overflow: hidden或者自己管理滚动如果把按钮放在表格内部滚动的时候它会跟着跑。正确的做法是把按钮放在表格外层的卡片头部跟标题同一行右对齐表格本身作为它的兄弟节点。结构大致是这样外层一个相对定位的卡片容器头部一行左侧标题、右侧按钮组按钮组用display: flex排开齿轮按钮后面跟一个绝对定位的弹层。弹层要设z-index并且给一个合理的max-height加内部滚动否则列多的时候面板会顶出屏幕。还有一个特别容易被忽略的点弹层打开后点击页面其他区域要能自动关闭这个需要监听document的点击事件并且在组件卸载时移除监听不然会内存泄漏。如果你的项目里已经有一套基于某个 UI 库的弹层组件直接用它的popover或者dropdown会省掉很多定位和层级的心智负担我现在的项目基本都这么干自己写的绝对定位弹层在窗口 resize 和滚动容器嵌套的场景下很容易出问题。3. 落地实录两种实现从一键接管到自己掌控3.1 基础版官方 custom 面板最快接入方式先说最省事的路径。如果你能接受官方面板的默认样式接入成本真的只有几行。template div classgrid-card vxe-toolbar custom/vxe-toolbar vxe-table refxTableRef :datatableData :custom-configcustomConfig :column-config{ resizable: true } height600 vxe-column typeseq title序号 width60/vxe-column vxe-column fieldorderNo title订单号 width180/vxe-column vxe-column fieldchannel title渠道 width120/vxe-column vxe-column fieldamount title金额 width120/vxe-column vxe-column fieldstatus title状态 width100/vxe-column vxe-column fieldremark title备注 min-width200 visiblefalse/vxe-column vxe-column fieldaction title操作 width140 fixedright/vxe-column /vxe-table /div /template script setup import { ref, reactive } from vue const xTableRef ref(null) const tableData ref([]) const customConfig reactive({ trigger: click, mode: popup, immediate: true, showFooter: false, storage: true, checkMethod({ column }) { // 序号、复选框、操作列不参与勾选 if ([seq, checkbox, radio].includes(column.type)) return false if (column.field action) return false return true } }) /script这里有几个细节值得留意。vxe-toolbar上的custom属性会自动在工具栏里生成一个列自定义按钮点它就弹出面板位置就在表格上方右侧这其实已经满足大部分需求了。vxe-column上那个visiblefalse让备注列默认不显示但用户随时可以在面板里勾回来。checkMethod里的判断顺序很重要先按type排除再按field排除。因为typeseq这种列是不会有field的如果先判断field就可能漏掉。这个顺序看起来是小事但在有多级表头的表格里父列也可能没有field所以判断逻辑要写得稍微严谨一点。要注意的是vxe-toolbar必须放在vxe-table的前面而且它们要在同一个父容器里否则工具栏的位置会跑偏。这个不算坑但新手第一次接容易把工具栏写在表格后面然后疑惑按钮为什么跑到表下方去了。3.2 进阶版自绘图标按钮加勾选面板官方面板好是好但如果你有一整套自己的设计规范或者需要加“一键恢复默认”“搜索列名”这类增强功能那就得自己画。下面这套代码我在几个项目里复用过稳定。template div classgrid-card div classgrid-card__head span classgrid-card__title订单明细/span div classgrid-card__tools button classicon-btn :class{ is-active: panelVisible } click.stoptogglePanel i classiconfont icon-settings/i em v-ifhiddenCount 0 classicon-btn__badge{{ hiddenCount }}/em /button div v-ifpanelVisible classcolumn-panel click.stop div classcolumn-panel__head span列显示设置/span button classlink-btn clickresetColumns恢复默认/button /div div classcolumn-panel__body label v-foritem in columnList :keyitem.field classcolumn-panel__item :class{ is-child: item.level 0 } input typecheckbox :checkeditem.visible :disableditem.locked changehandleToggle(item, $event.target.checked) / span{{ item.title }}/span /label /div /div /div /div vxe-table refxTableRef :datatableData :column-config{ resizable: true } column-changesyncColumns !-- 列配置略 -- /vxe-table /div /template脚本部分的核心是三个函数抓全量列、切换显隐、算隐藏数量。import { ref, computed, onMounted, onBeforeUnmount, nextTick } from vue const xTableRef ref(null) const panelVisible ref(false) const columnList ref([]) // 这些类型的列不进入设置面板 const EXCLUDE_TYPES [seq, checkbox, radio, expand, html] // 这些字段不进入设置面板比如固定的行操作列 const LOCKED_FIELDS [action] function flattenColumns(columns, result [], level 0) { columns.forEach(col { if (EXCLUDE_TYPES.includes(col.type)) return if (!col.field) { // 没有 field 的父列继续往下找子列 if (col.children col.children.length) { flattenColumns(col.children, result, level 1) } return } result.push({ field: col.field, title: col.title, visible: col.visible ! false, level, locked: LOCKED_FIELDS.includes(col.field) }) if (col.children col.children.length) { flattenColumns(col.children, result, level 1) } }) return result } function syncColumns() { const table xTableRef.value if (!table) return const tableColumn table.getTableColumn()?.tableColumn || [] columnList.value flattenColumns(tableColumn) } const hiddenCount computed( () columnList.value.filter(c !c.visible !c.locked).length ) function togglePanel() { panelVisible.value !panelVisible.value } function handleToggle(item, checked) { if (item.locked) return const table xTableRef.value const visibleNum columnList.value.filter(c c.visible !c.locked).length // 兜底至少留一列可见 if (!checked visibleNum 1) return if (typeof table.setColumnVisible function) { table.setColumnVisible(item.field, checked) } else { const col (table.getColumnByField table.getColumnByField(item.field)) || (table.getColumnById table.getColumnById(item.field)) if (col) col.visible checked if (typeof table.recalculate function) table.recalculate() } item.visible checked saveState() } function handleDocClick() { panelVisible.value false } onMounted(async () { await nextTick() syncColumns() applyState() document.addEventListener(click, handleDocClick) }) onBeforeUnmount(() { document.removeEventListener(click, handleDocClick) })这里有两个点我想重点解释。第一是flattenColumns的写法。它接收的是完整的列树递归展开遇到没有field的父列时不下推条目但继续深入子列遇到排除类型直接跳过。这样做的结果是面板里展示的是“所有可以单独控制的叶子列”父列本身不出现在勾选列表里——因为多级表头下让用户单独勾一个父列语义会很怪父列一去掉子列挂在哪我的处理方式是让用户只能操作叶子列父列的显隐由子列状态推导。第二是保存状态的调用位置。我放在handleToggle里而不是监听某个事件是因为自绘面板没有官方的事件总线自己触发自己保存最直接。但要注意saveState()里读的必须是columnList的最新值而item.visible checked这行必须在保存之前执行顺序反了就会存下旧值。至于“至少留一列”的保护真的是被用户教育出来的。曾经有个用户把所有列都取消勾选然后页面变成一堆空白他还以为系统坏了。加了这个兜底之后最后一列的复选框直接置灰提示语也不用写用户自然就懂了。3.3 刷新不丢配置状态持久化怎么写才不互相污染用户勾好了列刷新一下全变回默认这是最伤体验的一种。持久化不难难在别存乱。const STORAGE_PREFIX vxe-columns const pageKey order-detail // 每个表格一个唯一标识 function getStorageKey() { return ${STORAGE_PREFIX}:${pageKey} } function saveState() { const state {} columnList.value.forEach(item { if (!item.locked) state[item.field] item.visible }) localStorage.setItem(getStorageKey(), JSON.stringify(state)) } function applyState() { let state {} try { state JSON.parse(localStorage.getItem(getStorageKey()) || {}) } catch (e) { return } const table xTableRef.value if (!table) return Object.keys(state).forEach(field { if (typeof table.setColumnVisible function) { table.setColumnVisible(field, state[field]) } }) syncColumns() } function resetColumns() { localStorage.removeItem(getStorageKey()) const table xTableRef.value // 用初始配置重新生成列 if (typeof table.resetCustom function) { table.resetCustom() } syncColumns() }存储的键名设计是这套东西稳不稳的关键。我的命名规则固定成“前缀 页面标识 表格标识”三段前缀用来做清理和检索页面标识防止不同页面的表格互相串表格标识应对一页多表的情况。曾经有个项目一页里放了主表和子表两个表格都用默认配置结果用户在主表调好的列刷新之后被应用到了子表上排查了半小时才发现是键名撞了。还有一点是容错。JSON.parse外面必须包try/catch因为用户本地存储可能存在旧版本留下的非法字符串也可能是手动改过。解析失败就当没有存储静默走默认配置别把整个页面搞崩。applyState的时机也要注意。它必须在表格完成首渲染、列树已经生成之后调用放在onMounted的nextTick里是个稳妥的选择。如果放到created阶段表格实例还没挂上setColumnVisible无从调用。3.4 多级表头与固定列的适配细节这两块是真正的深水区。多级表头的核心问题是父子联动。父列visible为false时所有子列都会消失这是组件的默认行为不用你操心。但反过来用户把某个父列下的所有子列都取消了那个父列的表头单元格会不会留下来变成空壳实测下来的表现取决于版本稳妥的做法是在切换之后主动检查一遍某个父列下的所有子列都不可见时把这个父列也设为不可见。这个逻辑要递归往下走从最深的层级往上算。另外checkMethod在多级表头下的行为要提前验证。有些版本只会把checkMethod作用到叶子列上父列不参与判断这时候你在checkMethod里写column.field action是拦不住父列的。我的做法是在自绘面板里直接把父列过滤掉只展示叶子列从源头规避这个问题。固定列的问题更隐蔽。当你把一列设置了fixed: right的列隐藏掉之后右侧固定区可能还残留着那条阴影线或者固定区的宽度没有重新计算导致表头和内容的横向滚动态对不上。解决方式很简单切换之后调用一次recalculate()让组件重算布局。如果表格有虚拟滚动或者用了scroll-y还要注意切换列之后滚到中间状态时的渲染个别版本会漏渲一屏触发一次refreshScroll或者轻微滚动一下就能恢复。还有一个经验尽量别让用户隐藏掉唯一的固定列。右侧固定列通常是操作列一旦被隐藏用户想恢复的时候找不到入口——因为入口在表格右上角而右上角可能已经被滚动到看不见了。所以我的处理是把操作列放进LOCKED_FIELDS不给勾选。这一条不是技术限制纯粹是产品层面的保护。4. 排查实录勾了没反应、错位、丢配置的常见现场4.1 勾选后视图没变化的五种典型原因这个现象是最常见的我按出现频率从高到低排一下。第一种拿错了列数组。面板里渲染的是visibleColumn而不是tableColumn导致隐藏掉的列根本不在列表里取消勾选之后再想勾回来发现没有那个选项用户以为“点了没用”其实是列表本身就少了东西。这个前面说过但值得再说一遍因为它太常见了。第二种改了副本。面板数据是从列树映射出来的一个新数组你在新数组的元素上改visible跟表格内部那个列对象没有任何引用关系视图当然不动。正确做法是不管面板数据直接调用表格实例上的方法去改真实列对象。我上面代码里handleToggle就是走实例方法的面板数据只用来渲染勾选态。第三种方法不存在。你的版本里没有setColumnVisible代码走到了兜底分支但兜底分支里getColumnByField也没拿到东西静默失败。这种情况建议在开发环境加一行console.warn上线前再删掉能省掉大量盲猜。第四种被checkMethod拦了。某些版本在运行时也会用checkMethod校验显隐操作返回false就直接忽略你的切换请求。如果你在checkMethod里写了比较宽泛的条件很容易误伤。第五种列被v-if控制。有些同事为了做权限控制在vxe-column上写了v-ifhasPermission。这种列在权限不通时压根不存在于列树里面板里自然也看不到等你把权限打开它又冒出来打乱顺序。权限控制列显隐的正确做法是用visible配合动态赋值而不是v-if。注意排查“点了没反应”之前先在控制台打印一下getTableColumn().tableColumn的长度跟你在面板里渲染出来的条目数对一下。数字不一致问题一定出在数据来源不用往组件渲染方向查。4.2 固定列与多级表头引发的布局异常布局类的异常比显隐失败更烦人因为它不影响功能但一眼就能看出别扭。表头与内容错位。表现是隐藏一列之后表头那一行比下面数据行窄了一点或者左侧固定区和主体区之间出现了一条缝。原因通常是列宽没有重新分配。处理方式是在切换之后调用recalculate()如果表格是自适应容器宽度的再加一次nextTick里的refreshColumn。横向滚动条消失或出现鬼影。列全部收窄到比容器还窄的时候滚动条理论上应该消失。但有些情况下它会保留一条空滚动条或者反过来内容明明超出去了却不出现滚动条。这和组件的内部测量时机有关我的做法是在切换后延迟一小段时间再触发一次重算用setTimeout包一下虽然不够优雅但很管用。多级表头最后一行对不齐。多级表头下每一行的单元格数量不一样是靠colspan和rowspan拼出来的。隐藏其中某个子列会改变父列跨的列数如果组件没重新计算表头就会错位。触发一次列重载能解决代价是用户会看到一瞬间的重绘。固定列全部隐藏后右侧留白。这是个边界情况用户把右侧固定区唯一的列也隐藏掉有些版本会留下一条固定宽度的空白带。解决办法就是前面说的把这类列锁住不允许隐藏从产品层面绕开技术问题比死磕组件内部逻辑划算得多。4.3 速查表与自查清单遇到问题的时候按这个表往下走大部分能定位到原因。现象优先怀疑处理动作勾选后表格没变化改的是副本数据改用表格实例方法操作真实列对象隐藏的列在面板里找不到数据源用了可见列换成getTableColumn().tableColumn刷新后配置丢失存储键冲突或未开启持久化检查键名唯一性补applyState调用表头与内容错位未触发布局重算调用recalculate()或重新加载列多级表头错行父列跨列数变化未重算切换后整体刷新列结构固定列隐藏后残留空白固定区未重新计算锁定关键固定列禁止隐藏切换后第一次滚动渲染异常虚拟滚动未刷新轻微滚动或调用滚动刷新方法面板打开后点别处不关闭未监听全局点击补document点击监听并在卸载时移除自查清单则是给 Code Review 用的。每次提交列显隐相关代码之前我会过一遍这几个问题数据源是不是全量列操作列和序号列有没有被排除有没有做“至少留一列”的兜底存储键是不是唯一组件卸载时监听有没有清掉固定列的场景有没有实测。4.4 几个能把体验拉满的细节功能跑通只是及格线真正让用户觉得顺手的是这些细节。角标提示隐藏数量。齿轮图标右上角挂一个小红点或者数字显示当前隐藏了多少列。用户看到“3”就知道有东西被藏起来了不至于翻遍面板也找不到原因。这个数字通过hiddenCount计算成本几乎为零效果立竿见影。列名搜索。字段超过三十个之后弹层里的勾选列表会变得很长从上翻到下找一列其实挺费劲。加一个输入框做模糊匹配输入两个字就筛出来交互负担直降。这个功能不难但要在列多的时候才加列少的时候反而多余多一个输入框就多一次注意力分配。一键恢复默认。用户把列调乱了想回到初始状态但又记不住原来是什么样。一个“恢复默认”按钮能解决百分之九十的售后问题。实现上就是清掉本地存储再重置列配置注意重置之后要同步刷新面板里的勾选态否则面板还显示着旧的勾选用户会再次困惑。父列的三态勾选。多级表头下父列的复选框应该支持“全选/半选/未选”三态半选表示子列部分可见。原生input[typecheckbox]设置indeterminate属性就能实现一行代码的事但专业感提升明显。宽度和顺序的记忆。vxe-table 的列除了显隐还能拖拽调宽、拖拽调序。用户辛苦调好的布局如果只记住了显隐而没记住宽度体验是割裂的。官方面板的storage通常会把这几样一起存下来自绘面板就得自己多存两个字段列宽和列顺序。列顺序建议存成field数组恢复的时候按数组顺序重排配置比存索引稳因为增删列的时候索引会漂移。以上就是我在几个中后台项目里做这套东西的全部经验。最后分享一个我自己常干的小事把这个齿轮按钮的埋点加上记录点击次数和最终的列组合。跑一段时间之后你会发现不同角色真正在看的列差异非常大这个数据拿去做默认列配置的优化比拍脑袋决定“默认显示哪十几列”靠谱多了。