ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

el-table 操作列宽度动态计算:从 Canvas 测量到权限自适应

el-table 操作列宽度动态计算:从 Canvas 测量到权限自适应 el-table 的操作列宽度是后台管理系统里最容易被写死、也最容易在验收前夜翻车的一行代码。绝大多数人的做法是先拍一个 200px跑通了就提交等到权限模块上线、不同角色看到的操作项从 2 个变成 5 个或者文案从「编辑」改成「查看详情」这一列要么空出一大片浪费宽度要么把按钮挤成两行、把整行行高顶起来。这篇想解决的就是这一件事让 el-table 操作列的宽度跟着操作项个数自己长出来少的时候收窄多的时候撑开同时把滚动条、固定列、合并单元格这些会来抢空间的角色一起算进去。前端刚上手表格组件的同学可以照着抄代码已经在维护中后台项目的同学建议重点看第三、第六部分那里是我踩坑最密集的地方。1. 宽度写死之后麻烦会从哪几个方向冒出来1.1 同一个操作列三种角色看到三种内容先摆一个在真实项目里天天发生的场景。一张订单列表操作列的渲染逻辑大概是这样普通客服只看到「查看」运营角色看到「查看」「编辑」「导出」管理员角色看到「查看」「编辑」「审核」「导出」「删除」。再加上状态耦合——草稿状态多一个「提交」待审核状态多一个「撤回」已完成状态只剩「查看」。这时候你写width200就一定会出现下面两种难看的结果按管理员的 5 个按钮算宽度客服登录时右边空出一大块表格整体显得很松散视觉上是「操作列比数据列还宽」反过来按客服的 1 个按钮算宽度管理员那行按钮直接换行或者被.cell的overflow: hidden裁掉最后一个按钮用户点不到。更隐蔽的一层是文案长度。中文按钮两字、三字、四字很常见「查看」和「查看详情」差了两个字符在 12px 字号下大约差 24px。如果操作列宽度是写死的这种文案调整每次都要人工回去改一遍列的宽度改完还得让测试重新截图确认属于典型的「低价值重复劳动」。所以这件事的本质不是「宽度设多少」而是「宽度由谁来定」。只要宽度还由人手写在模板里它就和操作项个数这个变量脱钩了脱钩就意味着迟早对不上。1.2 el-table 到底是怎么把你的 width 变成实际列宽的想动态算宽度得先知道你交给 el-table 的那个数字最终去了哪里。el-table 内部用的是table-layout: fixed渲染时会维护一份列配置集合把每一列上声明的width和min-width收集起来最后生成一个colgroup里面每个col元素对应一列样式上写width或min-width。表格的最终布局由浏览器按这个 colgroup 去分配。这里有两个非常容易踩的细节值得单独拎出来说。第一个是单位解析。组件库在读取width/min-width时走的是类似parseInt的逻辑也就是说它只认数字部分。写width120和width120px效果完全一样都能正常识别这一点很多人反而不知道总以为必须带 px。但反过来width12rem会被解析成12width50%会被解析成50——不是 50%也不是 12rem而是 50px还是 12px。这种写法不会报错只会静默地得到一列窄得看不见的操作列排查起来很折磨人。结论很简单el-table 的 width / min-width 只接受纯数字或「数字 px」不要用百分比和 rem。第二个是width和min-width的语义差异。width是硬约束列宽就是它所有列都有width时如果总和小于容器宽度表格右侧会留白总和大于容器宽度就出现横向滚动条。min-width是软约束把列宽当成一个下限剩余空间由所有min-width列按比例瓜分。这就带来一个选择操作列到底该用width还是min-width我的答案是操作列用width数据列用min-width。原因很直接数据列的内容长度不可控交给浏览器按比例分配更省心而操作列的宽度是我们自己算出来的是一个有明确上下限的确定值用min-width反而会让它被撑到超出预期。当然如果你希望窄屏下操作列被压缩那就得另做一套降级方案这一点放在第六部分讲。1.3 滚动条和固定列会二次瓜分你的那点空间还有两个经常被忽略的「空间黑洞」。一是垂直滚动条。el-table 的 body 区域滚动条宽度在 Windows 版 Chrome 下通常是 15px 左右在 macOS 的浮层滚动条模式下可能是 0。它不占列宽但它占容器宽度——当所有列宽之和刚好接近容器宽度时数据一多出现纵向滚动条可用横向空间立刻少 15px如果这时候操作列是min-width它就会被压下去按钮换行。所以判断「宽度够不够」的时候必须把滚动条宽度减掉。组件库里一般有个工具方法可以拿到这个值原理是创建一个overflow: scroll的临时元素量差值也可以自己算window.innerWidth - document.documentElement.clientWidth。二是固定列。在 Element UI 的 Vue 2 版本里fixedright的操作列会被渲染成一份独立的 DOM 副本形如.el-table__fixed-right通过绝对定位贴在右侧原表格里对应的列则通过 padding 或者隐藏来处理。这意味着两件事你在页面上用querySelector去抓操作列里的按钮量宽度很可能抓到两套以及固定列的宽度会直接取自原列的widthmin-width在固定列上的表现不稳定官方文档也建议固定列给明确的width。较新的 Element Plus 改用 sticky 定位不再复制 DOM这个坑少了一半但列宽计算逻辑的差异依然存在。2. 从「操作项个数」反推宽度三条路线各有各的代价2.1 路线一查表法一行配置解决八成的表格最省事的做法是维护一张映射表1 个操作项给 80px2 个给 140px3 个给 200px4 个给 260px5 个及以上给 320px。const WIDTH_MAP { 1: 80, 2: 140, 3: 200, 4: 260 } const width WIDTH_MAP[count] || 320这套方案的优点是零成本、零副作用、不需要等渲染、不会引起任何布局抖动非常适合操作项文案固定、样式统一的内部系统。我在一些客户侧的报表页面里就用这套一个下午就能铺完十几张表。它的硬伤是「不跟文案走」。一旦按钮文案从两字变四字或者项目要支持多语言同一个按钮在英文环境下从「查看」变成「View」还好变成「View Details」就长了映射表里的数字就集体失效。另外它还依赖你对按钮样式的假设——如果某天设计师把文字按钮改成了带边框的小按钮左右内边距从 0 变成 15px整张表又会差出一大截。2.2 路线二实测法把按钮渲染出来量 offsetWidth更精准的做法是先把按钮渲染到一个隐藏容器里然后用offsetWidth直接量出真实宽度。思路是把每个操作项对应的按钮节点造出来挂到一个position: absolute; visibility: hidden; white-space: nowrap;的容器上量完再移除。它拿到的是浏览器真实布局后的宽度包含了内边距、边框、图标、字体渲染差异理论上最准。代价也很明显。首先它需要一次真实的 DOM 回流虽然容器是隐藏的但对offsetWidth的读取依然会强制布局计算量十几个按钮累计起来在低端设备上是有感知的。其次绝对不能用display: none的容器去量隐藏元素的offsetWidth恒为 0这是新手最常犯的错误。第三它依赖字体已经加载完成如果页面上用了自定义字体而字体还在加载量出来的是回退字体的宽度。最后这套逻辑必须放在数据渲染之后的时机里执行代码复杂度比查表法高一个量级。2.3 路线三混合法文本用 Canvas 量盒模型用常量我最终在项目里落地的是第三种把按钮宽度拆成「文字宽度 盒模型常量」两部分文字宽度用 Canvas 的measureText来量盒模型的各个部分单元格内边距、按钮内边距、按钮间距用一组常量表达。Canvas测量不产生任何 DOM 节点、不触发回流速度极快量出来的文字宽度和真实渲染的误差通常在 1px 以内再加一个安全余量就完全够用。而盒模型部分本来就是固定值用常量表示反而比实测更可控——你可以清楚地知道那 20px 是从哪来的改版时也只需改一个数字。这套方案唯一的「不精确」来自字体必须与真实渲染一致所以常量里要写死一份字体字符串并且保证它和 CSS 里的字体族顺序完全一致。方案适用场景精度性能主要风险查表法文案固定、样式统一的内部系统低极高文案或样式一改就失效实测法高度自定义按钮、样式复杂高中强制回流、字体未加载、display:none 量出 0混合法中后台通用表格需要长期维护较高高字体字符串必须与 CSS 严格一致3. 量出真实宽度文字测量和按钮盒模型的细节拆解3.1 字体没加载完量出来的数字是假的measureText的结果完全取决于你给它设置的那串 font 字符串。这串字符串必须同时包含字号、字体族、以及字体族的排列顺序任何一个字符不一致结果都会有偏差。举个我实际遇到的例子CSS 里写的是font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serifJS 里我图省事只写了12px sans-serif在 macOS 上量出来的「查看详情」比实际窄了大约 4px单看不多但四个操作项累计下来差了将近 20px最后那个「删除」被切掉了一半。改法很简单把字体字符串抽成一个常量CSS 和 JS 共用同一份定义。另一个更隐蔽的问题是字体加载时序。中文 Web 字体文件动辄几 MB如果用到了自定义字体首屏很可能出现「字体还在加载、页面已经渲染」的窗口期。这期间 Canvas 量到的是回退字体的宽度。稳妥的处理是在document.fonts.ready之后再触发一次宽度重算if (document.fonts document.fonts.ready) { document.fonts.ready.then(() { this.refreshActionWidth() }) }还有一个跨平台的现实Windows 默认「微软雅黑」macOS 默认「苹方」两个字体对同一个汉字的字宽就有细微差异。所以严格来说「一次算出的宽度在所有平台都刚刚好」是不存在的。我的做法是留一个 6 到 10px 的安全余量宁可列宽富余几像素也不要出现裁字。3.2 一个文字按钮的宽度由哪几块拼起来把操作列的宽度拆开从左到右依次是单元格左内边距 第一个按钮 按钮间距 第二个按钮 …… 最后一个按钮 单元格右内边距。用公式表达就是列宽 cellPadding Σ(按钮宽度) gap × (按钮个数 - 1) safe其中几个常量的取值需要按你实际使用的组件库版本来确定不能凭记忆抄。el-table 的单元格内边距来自.cell的左右各 10px合计 20px文字按钮Element UI 的typetext、Element Plus 的link左右内边距为 0而普通小号按钮sizesmall且带边框在 Element UI 下的内边距是左右各 15px也就是额外 30px。按钮之间的间距是最容易算错的一块。它来自兄弟选择器.el-button .el-button { margin-left: 10px }Element UI或12pxElement Plus也就是说间距数量永远等于「按钮个数减一」。很多人写成gap × count结果每多一个按钮就多算一段间距四个按钮就多出 10px 的富余五个按钮多 10px越宽越离谱。如果你担心不同版本的这个值不一致有个很实用的技巧在操作列上定义一个 CSS 变量按钮间距直接引用它JS 里读同一份数值。.action-cell .el-button .el-button { margin-left: var(--action-gap, 10px); }不过要注意el-table 的单元格内容是渲染在.cell内部的选择器需要写成.action-cell .cell .el-button .el-button或者干脆给操作列的class-name配一条自定义样式避免影响页面上其他地方的按钮。3.3 「更多」下拉、带图标按钮、带徽标的按钮怎么算当操作项超过 3 个时我的第一建议不是把列撑宽而是收进一个「更多」下拉里。这时候宽度计算的目标就变了只需要按「最长的那个直接显示的按钮 一个带箭头图标的下拉触发器」来算。箭头图标的宽度是个变数Element UI 的下拉箭头用的是一个 12px 左右的图标字体或 SVG稳一点的做法是给它单独留 16px 的常量不要试图用文字测量的方式去量它——图标不是文字measureText完全量不出来。带图标的按钮比如「搅拌」图标 文字同理图标占的宽度必须单独加常量。我的经验值是14px 的图标加它在按钮内的间距大约占 22px 到 26px具体取决于图标和文字的margin。这种按钮如果不能保证样式完全一致就没有必要追求精确——留 8px 余量让它看起来对齐就够了。真正需要警惕的是状态类元素。比如某个操作按钮上带一个红点角标角标是绝对定位的不占布局宽度但如果它伸出了按钮的右边界视觉上就会和下一个按钮挤在一起看起来像没对齐。这类元素不会影响宽度计算但会影响观感建议通过overflow: visible加上给按钮之间留出额外 4px 来缓解。4. 完整落地Vue 2 Element UI 的实现代码4.1 先把操作项从模板里抽出来这一步是整件事的前提也是最容易被跳过的一步。很多人的模板里直接写了一串v-if的按钮template slot-scope{ row } el-button v-ifrow.status draft typetext sizesmall提交/el-button el-button typetext sizesmall查看/el-button el-button v-ifperms.edit typetext sizesmall编辑/el-button /template这种写法下你没有任何地方能拿到「这一行到底有几个操作项」只能靠人去数v-if的分支改一次逻辑就要重新数一遍。正确的做法是抽一个纯函数模板和宽度计算共用它export function getRowActions (row, perms) { const actions [{ key: view, label: 查看 }] if (perms.edit row.status draft) actions.push({ key: edit, label: 编辑 }) if (perms.audit row.status pending) actions.push({ key: audit, label: 审核 }) if (perms.remove) actions.push({ key: remove, label: 删除 }) return actions }这个函数是整个方案的地基。它保证了一件事只要模板按它渲染宽度就按它计算两边永远不可能对不上。后面无论加多少个操作项、加多少条件判断宽度都会自动跟上。4.2 计算函数与文本测量缓存前面说过文字测量很便宜但如果每次组件 re-render 都重新量一遍几百行数据累计起来依然有开销。加一个 Map 缓存key 用「字体 文本」基本上一个页面里同样的按钮文字只会被量一次。const widthCache new Map() let ctx null const FONT 12px -apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif function measureText (text) { const key FONT | text if (widthCache.has(key)) return widthCache.get(key) if (!ctx) ctx document.createElement(canvas).getContext(2d) ctx.font FONT const w Math.ceil(ctx.measureText(String(text)).width) widthCache.set(key, w) return w } export const BOX { cellPadding: 20, // .el-table .cell 左右各 10px btnGap: 10, // .el-button .el-button 的 margin-left safe: 8 // 字体渲染误差与安全余量 } export function calcActionWidth (rows, getActions) { let maxOuter 0 rows.forEach(row { const actions getActions(row) if (!actions.length) return let inner 0 actions.forEach((a, i) { inner measureText(a.label) if (i 0) inner BOX.btnGap }) if (inner maxOuter) maxOuter inner }) if (!maxOuter) return 0 return Math.ceil(maxOuter BOX.cellPadding BOX.safe) }注意这里的maxOuter取的是所有行里的最大值而不是求和也不是平均数。列宽必须能容纳「最宽的那一行」平均值再好看也没用。4.3 绑定到列上并在数据变化后强制重排组件里把它挂到计算属性上再给一个上下限computed: { actionWidth () { const w calcActionWidth(this.tableData, row getRowActions(row, this.perms)) if (!w) return 90 // 空数据兜底 return Math.min(Math.max(w, 90), 320) } }, watch: { tableData () { this.$nextTick(() { this.$refs.table this.$refs.table.doLayout() }) } }模板部分el-table reftable :datatableData border el-table-column propname label名称 min-width180 / el-table-column label操作 :widthactionWidth class-nameaction-cell fixedright template slot-scope{ row } el-button v-fora in getRowActions(row, perms) :keya.key typetext sizesmall {{ a.label }}/el-button /template /el-table-column /el-table关于doLayout()要不要手动调这里有个实际情况给:width绑一个会变化的计算属性组件库在多数版本里能感知到并触发重排但触发时机和内部缓存策略在不同小版本之间有差异我在 2.13 和 2.15 上就遇到过表现不一致的情况。所以我的习惯是不去赌它自己会重排数据变化后统一在nextTick里手动调一次成本几乎为零能避免大量「翻页之后列宽没跟上」的诡异反馈。5. 换成 Element Plus 之后这三处必须重新量一遍5.1 默认间距和内边距的数值变了Element Plus 里.el-button .el-button的margin-left是 12px 而不是 10px小号按钮的内边距也不同。如果你的项目是从 Vue 2 迁移过来的直接把BOX常量表复制过去宽度就会系统地偏窄 4px 到 10px表现为最后一个按钮偶尔被切一半。我处理这件事的方式是给常量表加一列注释写清楚「这组值量自哪个版本」迁移时强制自己用开发者工具重新量一遍。具体怎么量在页面上选中按钮看 computed 面板里的padding-left、padding-right、margin-left把三个数字加起来除以按钮个数减一就能反推出btnGap。整个过程两分钟能省掉后面几轮的截图对不上。5.2 doLayout 的时机要避开同一个 tick 里的重复调用Element Plus 的doLayout内部带了一层防抖连续在同一个 tick 里调用多次只有最后一次生效。这不影响结果但如果你在watch里同时监听了三四个数据源比如tableData、actionWidth、perms并且每个都调一次代码会很乱。我的写法是把重排抽成一个方法让所有需要的地方都调它并加一个简单的开关避免同一帧内重复执行methods: { refreshLayout () { if (this._layoutPending) return this._layoutPending true this.$nextTick(() { this._layoutPending false this.$refs.table this.$refs.table.doLayout() }) } }5.3 固定列不再复制 DOM测量策略要跟着换前面提过Element Plus 较新版本用 sticky 实现固定列页面上不再有.el-table__fixed-right这层副本。这带来一个正面效果你用querySelector去量操作列按钮宽度时不会量到两套。但也带来一个新问题——sticky 单元格和原单元格共享同一份布局宽度如果操作列的width计算结果偏小sticky 的那一列视觉上会盖住左边数据列的最后一个字且不会出现横向滚动条提示你「这里挤了」。所以 Element Plus 下我更建议把安全余量从 8px 提到 12px并且在自测时切换一次窄屏窗口比如把浏览器窗口拖到 1100px 宽确认固定列和数据列的分界线是干净的。另外要提醒一句Element Plus 从 2.2 开始用link属性代替了typetext如果还用旧写法按钮会变成普通带边框按钮宽度差距是 30px 级别的这在动态计算的场景下是致命的——因为你算出来的是文字按钮的宽度实际渲染出来的是带边框按钮。6. 几个把我熬到凌晨的边界场景与排查链路6.1 分页翻页导致列宽忽宽忽窄这是我最先踩到的坑。第一页数据里正好有一行是管理员视角的操作项算了 5 个按钮宽度 260px翻到第二页全是普通数据每行只有 2 个操作项宽度掉到 140px。用户在翻页的时候会看到操作列「跳」了一下主观感受就是页面在抖。我把当时列的排查清单贴出来按这个顺序走基本能定位第一步确认宽度计算的数据源是当前页还是全量。如果是this.tableData当前页那抖动的根因就找到了。第二步确认操作项个数到底由什么决定。如果由行数据的状态决定那它天然是「每页都可能不同」的。第三步判断这个差异是否可接受。如果差异在 1 个按钮以内加个下限就够了如果差异很大就得换策略。最终我采用的策略是宽度跟着权限走不跟着数据走。也就是说先算出当前用户在所有状态下可能出现的操作项并集用它来定宽度。这样同一个用户在同一张表里翻多少页列宽都是恒定的。代价是列宽会按「最坏情况」算可能略宽但换来的是绝对稳定。6.2 出现横向滚动条之后宽度被二次压缩这个坑的现象很迷惑单独刷新页面时列宽正常一旦搜索条件变多、页面出现纵向滚动条操作列的按钮就换行了。排查链路是这样的先量表格容器的clientWidth再量 colgroup 里所有 col 的宽度之和两者相减。如果差值恰好等于滚动条宽度Windows 下 15px 左右那就确认是滚动条吃掉了横向空间。这时候有两种处理一是把操作列的宽度计算基准从「容器宽度」改成「容器宽度减去滚动条宽度」二是干脆给操作列设一个不依赖容器的绝对宽度让它自己撑开超出部分交给横向滚动条。我一般选后者因为前者的实现需要在渲染前后各量一次容器宽度多了一次强制回流还会引入「滚动条出现导致宽度变化、宽度变化又导致滚动条消失」的循环风险。6.3 合并单元格和操作列撞在一起span-method用在数据列上通常没问题但如果操作列本身也被合并宽度计算就要特别小心。被合并的那一格要容纳的是「合并范围内所有行操作项的总和」——因为你没办法把多个按钮横向摊到不同行里它们必须挤在同一个单元格。这种情况下我一般有两种处理要么把合并范围内的操作项减少到只剩通用操作要么给这一格用「更多」下拉收纳。还有一种情况是数据列合并改变了行高间接影响了操作列的垂直居中和换行表现。这类问题不属于宽度计算范畴但排查时很容易被误判成宽度不够建议先在浏览器里把操作列width手动改成 400px 试一下如果按钮不换行了那就是宽度问题如果还是挤在一起那就是单元格结构的问题。6.4 低分辨率设备和窄屏的降级窗口拖到 1024px 甚至更窄的时候所有列的min-width之和已经超过容器宽度横向滚动条必然出现这时候操作列的固定宽度会变得很突兀——用户要横向滚动才能点到操作按钮体验很差。我的降级策略是给操作项分级把「查看」这类高频且必用的操作留在外面其余全部收进「更多」下拉宽度从 260px 压到 120px 左右。判断触发条件也很简单在 resize 里算一次可用宽度即可const isNarrow this.containerWidth 1200 const actions isNarrow ? row getRowActions(row, perms).slice(0, 1) : row getRowActions(row, perms)注意这里的actions函数同时供模板和宽度计算使用所以切换时两边会一起变不会出现「模板收了但宽度没收」的错位。6.5 首屏没有数据时算出来是 0表格初始tableData是空数组calcActionWidth返回 0如果不做兜底操作列会变成一条几乎看不见的细线数据回来之后又跳成正常宽度。这个跳变在弱网环境下非常明显。处理方式就是前面代码里的那行if (!w) return 90——给一个合理的最小宽度。90px 大致能容纳两个两字按钮加安全余量作为空态占位是够的。等数据回来之后宽度会平滑地涨到真实值用户几乎感知不到。7. 让这套逻辑能长期活下去的三个约束7.1 上下限必须同时卡而且要有业务含义Math.min(Math.max(w, 90), 320)这行代码里的两个数字不是随便写的。下限 90 对应的是「至少能放两个两字按钮」上限 320 对应的是「不超过容器宽度的三分之一」。上限这个约束很容易被忽略。我见过一个项目因为某个角色的操作项有 8 个算出来的操作列宽度接近 700px把数据列全挤成了竖排文字。所以上限不只是美观问题它是保护数据列可用性的底线。我的经验值是操作列不超过表格容器宽度的 30% 到 35%超过就强制走「更多」下拉。7.2 tooltip 是兜底不是补救show-overflow-tooltip这个属性很多人只把它当成「内容太长时显示省略号加提示」的功能。在动态宽度的场景下它的价值是兜底万一某个版本的字体渲染差异导致实际宽度比计算值多了 2px按钮文字被裁掉一点点至少用户 hover 上去还能看到完整文案。但要注意这个属性对操作列里的按钮本身没有效果——它作用在单元格的文本上。如果你希望被裁掉的按钮也有提示得自己在按钮外面包一层el-tooltip或者控制住上限、宁可换行也不要裁。7.3 把操作项配置和权限配置放到同一份数据里最后一条是我做了几个中后台项目之后最大的体会宽度计算的复杂度不来自公式来自操作项散落在各处。只要操作项的定义分散在不同的v-if、不同的组件、不同的权限判断里宽度就永远算不准。我的做法是把操作项的 key、label、权限标识、显隐条件写成一份配置配置的消费方有两个渲染模板和宽度计算函数。这样当产品说「审核这个操作要加一个确认提示」的时候你改的是配置宽度会自动跟着走。const ACTION_DEFS [ { key: view, label: 查看, perm: null }, { key: edit, label: 编辑, perm: order.edit }, { key: export, label: 导出, perm: order.export }, { key: remove, label: 删除, perm: order.remove } ]这份配置还有一个副作用好处它可以被单元测试覆盖。写个测试断言「给定 perms 和 row 状态返回的操作项个数与预期的宽度一致」以后有人改了权限逻辑导致宽度变化测试会直接拦住不用等到测试同学截图才发现。我在实际项目里跑了差不多半年这套混合计量的方案没有出过宽度相关的线上反馈。唯一一次调整是把安全余量从 6px 提到了 8px原因是设计换了一套思源黑体的字重中文渲染宽度多了不到 1px五个按钮累计起来刚好让最后一个字的右侧贴边。所以如果你的项目中途换过字体或者字号记得回来把余量重新量一遍——这件事没有一劳永逸的解只有「改了什么就重算什么」的纪律。
返回列表