ARTICLE DETAIL

资讯详情

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

el-table 列宽自适应与内容换行:原理、方案与封装

el-table 列宽自适应与内容换行:原理、方案与封装 做 el-table 的列宽自适应、让表格列根据内容自动撑满、再顺手解决内容换行这三件事凑在一起基本就是一个前端在后台管理系统里绕不开的一道坎。我做过不下十个中后台项目几乎每个项目都会有人在群里问这列怎么这么窄、为什么右边空了一块、能不能让文字换行别省略号。表面上看是三个独立的小需求实际上它们背后是同一件事——Element UI 的表格用的是table-layout: fixed列的宽度由colgroup里的col决定跟内容没关系。你写多少 CSS 去让.cell撑开都撑不开列本身。这篇文章我想把这套东西从头捋一遍先讲清楚 el-table 的宽度分配规则到底是什么再说三种把内容宽度变成列宽的可行方案然后是换行相关的坑、合并单元格场景的例外、滚动条和容器缩放的翻车点最后给一个可以直接抄走的封装。适合已经用过 el-table 但被列宽折磨过的同学也适合刚接手一个中后台项目、正在被产品追着改表格的人。1. el-table 的列宽从来不是内容说了算很多人第一次调 el-table 列宽直觉是去改 CSS给.cell加个width: auto或者给td加min-width。改完发现毫无变化于是开始怀疑人生。要理解为什么得先看一眼 el-table 到底渲染出了什么。1.1 从 table-layout: fixed 说起el-table 最终渲染出来的结构简化后大概是这样div classel-table__header-wrapper table styletable-layout: fixed colgroup col width120 col width200 col width80 /colgroup thead.../thead /table /div div classel-table__body-wrapper table styletable-layout: fixed colgroup同上/colgroup tbody.../tbody /table /div关键就是table-layout: fixed和col上的width。在 fixed 布局下浏览器完全忽略单元格内容的宽度只看第一行表头行和各列的col声明剩下的等分。这就是为什么你在.cell上写什么宽度都白搭——.cell只是td里的一个 div它的宽度被td限制死而td的宽度又被col限制死。所以所有内容撑开列宽的需求本质上只有一个解法把内容算出来的宽度通过width属性喂回给el-table-column。没有第二种办法。那 el-table 自己又怎么决定col上写多少这就是下面的分配逻辑。1.2 width、min-width、什么都不写差别到底在哪Element UI 内部的updateColumnsWidth方法大致做了这么几件事先遍历所有列累加一个bodyMinWidth如果这列写了width把width加进去否则如果写了min-width把min-width加进去否则按一个默认值 80 加进去。同时把所有没有写 width的列收集到一个flexColumns数组里。然后比较bodyMinWidth和表格容器的实际宽度容器够宽剩余宽度容器宽 - bodyMinWidth - 竖向滚动条宽度按各列min-width的比例分给flexColumns里的列。如果flexColumns只有一列这一列直接吃掉全部剩余宽度。容器不够宽所有列收缩到各自的min-width超出的部分交给横向滚动条。这个逻辑解释了几个常见现象。第一只写min-width不写width的列永远会跟着容器长大因为它是弹性列。第二写了width的列无论容器多宽多窄它就是那么宽既不长大也不缩小。第三如果所有列都写了固定 widthflexColumns是空的剩余空间没有任何列来承接表格右边就会空出一块——很多人遇到的表格右侧留白就是这个原因。这里还有两个容易忽略的细节。第一个是默认值 80一列什么都不写它参与计算的基础宽度是 80px不是 0。你可以把它理解成隐式的min-width80。第二个是show-overflow-tooltip会给单元格的.cell加一个min-width: 50px在列特别窄的时候会影响实际表现。1.3 width 可以不带 px但千万别写百分比热词里有个el-table width min-width 可以不带px这个是对的。Element UI 内部有个parseWidthconst parseWidth (width) { if (width ! undefined) { width parseInt(width, 10); if (isNaN(width)) width null; } return width; };所以width120和width120px效果完全一样。:width120也一样。这个设计挺方便写起来省事。但这里有个大坑parseInt会把50%解析成50。也就是说你写了width50%el-table 不会理解成占一半而是当成 50px。表格会变得又窄又难看而且不报错、不警告特别难排查。所以列宽只能用像素值或者不写百分比方案在 el-table 上是行不通的。注意如果你是从别的表格库比如 Ant Design 的 Table迁过来的那边width支持百分比语义直接搬配置过来会踩这个坑。2. 把内容宽度变成列宽的三种做法前面说了结论想按内容自适应就得自己算宽度。算的方式有三种精度和性能各不相同我按推荐程度从低到高讲。2.1 方案一什么都不写只在最后加一列弹性列这是最省事的做法虽然它不叫内容自适应但能解决 60% 的右边留白和列太挤的问题。具体操作是把最重要的、内容最长的列比如备注、描述、名称设成min-width其余列写固定width。这样容器宽的时候剩余空间全给这一列容器窄的时候这一列收缩到 min-width其他列不变。el-table :datarows stylewidth: 100% el-table-column propid labelID width80 / el-table-column propname label名称 width160 / el-table-column propdesc label描述 min-width240 / el-table-column proptime label时间 width180 / /el-table这套配置的好处是零成本、无副作用、不会因为数据变化抖动。缺点是它只保证撑满不保证按内容如果那一列的内容普遍很短你依然会觉得它太宽。所以这招适合内容长度分布比较均匀的场景比如描述类文本。2.2 方案二Canvas measureText 预计算快且够用真要让列宽贴合内容最实用的是 Canvas 的measureText。它的原理是创建一个离屏 canvas设置好和目标文本一致的字体让浏览器按同样的字体度量算出这段文本的宽度。这个计算不涉及 DOM速度极快。const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const FONT_NORMAL 14px -apple-system, PingFang SC, Microsoft YaHei, Arial, sans-serif; const FONT_BOLD bold 14px -apple-system, PingFang SC, Microsoft YaHei, Arial, sans-serif; function measureText(text, font FONT_NORMAL) { ctx.font font; return ctx.measureText(text null ? : String(text)).width; }拿到宽度之后还要加上各种额外占用否则算出来的列永远比实际需要的窄一点点占用项典型值说明单元格左右 padding20 ~ 24pxElement UI 默认.cell左右各 10px部分版本是 12px以 F12 实测为准单元格右边框1pxborder-right排序图标约 24px列开了sortable才需要筛选图标约 24px列开了filters才需要省略号预留约 12px列可能被截断时留一点余量安全余量5% ~ 10%应对浏览器缩放、字体回退导致的偏差然后把表头文字用 bold 字体和整列所有单元格文字用 normal 字体分别量一遍取最大值function calcColumnWidth(col, rows, opts) { const { maxWidth 400, extra 24 } opts; const headerW measureText(col.label, FONT_BOLD); let bodyW 0; for (const row of rows) { const w measureText(row[col.prop]); if (w bodyW) bodyW w; } const raw Math.max(headerW, bodyW) extra; return Math.min(maxWidth, Math.ceil(raw * 1.05)); }这里有几个必须处理的边界我一个个说。第一是字体必须对齐。canvas 里写的字体串要和表格实际渲染的一致包括字号、字重、字体族顺序。表头默认是粗体th的浏览器默认样式Element UI 没有重置所以表头要用 bold 测。如果你的项目里全局改过字体比如引入了思源黑体记得同步。第二是必须设上限。一列备注里如果有一条 800 字的长文本按内容算出来的宽度能到 3000px直接把整个表格撑爆。所以一定要有maxWidth超出就交给换行或者 tooltip。我一般设 320 ~ 400px具体看业务列表页设小一点详情页可以放宽。第三是采样。如果表格有 5000 行、12 列那就是 6 万次measureText。实测在主流机器上大概 30 ~ 80ms能接受但不算无感。真要优化就做两层一是采样每列只测前 100 行加随机 100 行二是缓存用一个Map把${prop}::${value}作为 key 缓存测量结果同一份数据反复渲染时就不会重复算。第四是measureText的精度受页面缩放影响。如果用户把浏览器缩放调到 125%canvas 量出来的值和实际渲染会有几个像素的偏差。这就是为什么我建议乘一个 1.05 的系数。想更精确也不是不行但那点精度不值得花这个代价。2.3 方案三DOM 测量准但慢DOM 测量的思路是把文本塞进一个隐藏的span读它的offsetWidthlet ghost null; function measureByDom(text, font) { if (!ghost) { ghost document.createElement(span); ghost.style.cssText position:absolute;left:-9999px;top:-9999px;visibility:hidden;white-space:nowrap;pointer-events:none;; document.body.appendChild(ghost); } ghost.style.font font; ghost.textContent text null ? : String(text); return ghost.offsetWidth; }它的优势是所见即所得——浏览器怎么渲染文本量出来就是多宽不用担心字体回退、字距调整、连字这些 canvas 度量对不上的细节。对于中文混排英文、数字特别多的表格DOM 测量的结果往往比 canvas 更贴合。代价也很明显每次读offsetWidth都会强制浏览器进行一次同步布局forced reflow。如果你在一个循环里写文本 → 读宽度 → 写文本 → 读宽度那就是典型的 layout thrashing。我做过的实测是同样 5000 行 × 10 列DOM 方案比 canvas 方案慢 5 到 8 倍在低端机上能明显感觉到卡顿。所以 DOM 测量只适合两种场景列数和数据量都不大比如 20 行以内或者你需要点一下按钮手动触发一次精确自适应。真要在列表页用记得把读写分离开——先批量设置所有文本再批量读一遍宽度。2.4 方案四读渲染后的真实宽度但有前提还有一种做法是等表格渲染完直接去量已经渲染出来的.cell。这条路能走通但有很多前提。首先table-layout: fixed下td的宽度是被col锁死的所以.cell的offsetWidth等于列宽没有意义。有意义的是.cell的scrollWidth——它反映的是内容在不受限情况下的自然宽度。只有当.cell的overflow是visible或者内容处于nowrap状态时scrollWidth才准确。await nextTick(); const cells document.querySelectorAll(.my-table .el-table__body tr:first-child .cell); const widths Array.from(cells).map(el el.scrollWidth);这个方案的适用场景很窄表格已经渲染完、内容不再变化、你只需要一次性对齐。我一般把它用在导出前预览或者用户手动点击自适应按钮这种低频操作上。日常渲染用 canvas 就好。综合下来我的选择是默认用 canvas 测量 采样 上限 缓存特殊场景保留一个手动触发 DOM 精确测量的入口。3. show-overflow-tooltip 和内容换行只能二选一换行这件事我见过最多的错误做法是既开了show-overflow-tooltip又写了一堆 CSS 想让内容换行。结果就是文字确实换行了但鼠标悬浮上去还会弹一个 tooltiptooltip 里显示的是完整的单行文本跟换行后的多行内容完全重复用户看了很懵。3.1 show-overflow-tooltip 到底做了什么当你在列上写了show-overflow-tooltipElement UI 会给这个单元格渲染出来的.cell加上el-tooltip这个 class对应的样式是.el-table .cell.el-tooltip { white-space: nowrap; min-width: 50px; }再加上.cell本身的overflow: hidden; text-overflow: ellipsis;就形成了单行、超出省略、悬浮看全文的效果。注意white-space: nowrap是加在 class 上的不是内联样式理论上你写一条权重更高的 CSS 就能覆盖。但覆盖之后 tooltip 的监听还在悬浮照样弹。所以正确的结论是要换行就把show-overflow-tooltip关掉别指望两者共存。3.2 单元格换行和表头换行的分开处理如果你的诉求是内容太长就换行显示把行高撑开配置和样式是这样el-table :datarows classwrap-table el-table-column propdesc label描述 min-width260 / /el-table.wrap-table .el-table__body-wrapper .cell { white-space: normal; word-break: break-word; overflow-wrap: anywhere; line-height: 20px; }word-break和overflow-wrap这两个属性的差别很多人搞不清楚我列个表属性值行为适用场景word-break: normal只在空格和连字符处断行英文为主的普通文本word-break: break-all任意字符处都能断长串 ID、哈希值、无空格 URLoverflow-wrap: break-word优先正常断行实在放不下才断单词中英混排的通用选择overflow-wrap: anywhere类似 break-word但断行后的最小内容宽度会更小与min-width配合时更稳实际项目中如果列宽是我们自己算的、并且算的时候已经把最长内容考虑进去了其实很少触发断词。真正需要anywhere的是那些用户自己输入的备注或者拼接出来的长 ID。表头换行是另一回事。表头的.cell默认也是不换行的而且表头文字普遍比较短容易忽略。如果你有一列叫最近一次登录时间含时区它在窄列里会被截断。加一条.wrap-table .el-table__header-wrapper .cell { white-space: normal; word-break: break-word; }但表头换行有个副作用表头行会变高而且表头的th高度是独立计算的如果和 body 的行高对不齐在固定列场景下会错位。这块放到后面讲。还有一种更可控的表头换行方式是直接用render-header或者 header slot 手写断行renderHeader(h, { column }) { return h( div, { style: line-height: 18px }, column.label.split(\n).flatMap((part, i) (i 0 ? [part] : [h(br), part])) ); }用\n在 label 里标好断点这样表头什么时候换行完全由你控制不用依赖 CSS 的自动断行。我比较推荐这种方式因为它的结果稳定不会因为容器宽度变化而跳来跳去。3.3 换行之后紧接着的三个连锁问题第一个是固定列错位。Element UI 2.x 里的fixed列是把整个表格又渲染了一份用绝对定位盖在原表格左边或者右边的。el-table__fixed里面有独立的 header wrapper 和 body wrapper行高需要和主表同步。内容换行之后行高变了主表先重排固定列如果没跟上就会出现左边固定列的行和右边内容行错开半行的经典现象。解决办法是换行相关的样式改完之后调一次this.$refs.table.doLayout()。第二个是行高不一致导致的 hover 背景错乱。.el-table__row的高度本来是靠内容撑的但如果你在row-style里返回了固定height换行后的内容就会溢出被裁掉。所以用换行方案时row-style里别写height和line-height。第三个是垂直对齐。默认情况下td的vertical-align是middle还是top取决于 Element UI 版本和你的全局样式。换行之后一行高、一行矮如果对齐方式是 top短内容会贴着顶部视觉上不居中。可以在.cell上加display: flex; align-items: center;但要小心——这会破坏text-overflow: ellipsis的省略号因为 flex 容器不产生文本溢出。所以只有在确定不需要省略号的表格上再用这招。4. 合并单元格场景下前面所有结论都要重新想一遍span-method是 el-table 里最容易和列宽打架的功能。我见过一个需求左侧一列分类要纵向合并右侧一列子项要横向合并三列。做出来的效果是合并后的内容全部挤在最左边那一列里被截得只剩三个字。4.1 span-method 为什么不改变列宽因为span-method返回的[rowspan, colspan]只影响td上的属性它不参与colgroup的计算也不在updateColumnsWidth的考虑范围之内。一个colspan3的单元格它的宽度就是这 3 列宽度之和。你没办法通过让这一格内容变宽来让组成它的某一列变宽。更微妙的一点是被合并掉的那些td不再渲染但它们在colgroup里的列还在。所以视觉上会出现那三列里只有第一列有内容后面两列空着的情况。这也是为什么合并表头看起来总是有点别扭。4.2 合并列的宽度该怎么定我的做法是给合并的起始列一个足够大的min-width让它能容纳合并区域内最长的那条内容。比如横跨三列的那一格最长的内容是华东区域-上海-浦东新区量出来大约 180px。如果这格要跨 3 列那么这 3 列的min-width之和必须大于 180 padding。分配的时候我会把大头放在第一列比如min-width设成 120、40、40而不是均分。如果是分组表头嵌套的el-table-column还有个规则要记住父列的width会被忽略只有叶子列生效。也就是说el-table-column label地址 width600 el-table-column propprovince label省 width200 / el-table-column propcity label市 width200 / el-table-column propdetail label详址 width200 / /el-table-column这里父列的width600是没用的实际宽度是 200200200。父列的min-width同样不生效。所以分组表头要控制总宽度得去调每个子列。还有一个和换行有关的坑纵向合并rowspan之后这一格的高度是所有被合并行的高度之和。如果其中某一行因为内容换行变得特别高这一格就会出现大量空白垂直居中的时候看起来像内容飘在中间。这种时候可以考虑给这一格单独设置vertical-align: top或者干脆控制一下被合并行的内容长度。5. 滚动条、容器缩放、tab 切换这三个地方最容易翻车前面讲的都是静态计算实际项目里真正让人抓狂的是动态场景。5.1 那 15px 的滚动条到底吃了谁的宽度Element UI 在初始化时会通过getScrollBarWidth算出一个滚动条宽度Chrome/Edge 在 Windows 上通常是 15px 或 17pxmacOS 上如果系统开启了自动隐藏滚动条就是 0。这个值存在this.gutterWidth里。当表格出现了竖向滚动条也就是你设置了height或max-height且内容超出了el-table 会在表头那一层的colgroup末尾额外加一个gutter列宽度就是gutterWidth。作用是让表头的总宽度比内容区多出一个滚动条的宽度这样表头和内容才能左右对齐。这套机制在正常情况下工作得很好。但只要你在外面多包一层overflow: auto的 div或者自己改了.el-table__body-wrapper的overflow就可能出问题竖向滚动条出现在了外层容器上而 el-table 认为自己没有滚动条于是表头不留 gutter。结果就是表头比内容宽出 15px最右边一列的文字和表头错开。排查方法很简单F12 选中.el-table__body-wrapper看clientWidth和offsetWidth的差值。如果差值是 15 或者 17说明滚动条在它身上如果是 0说明滚动条在别的地方。const el document.querySelector(.el-table__body-wrapper); console.log(scroll width:, el.offsetWidth - el.clientWidth);解决方式就是别在外面套滚动容器把高度交给 el-table 的height属性去管。5.2 ResizeObserver 重算列宽别 observe 错对象容器宽度变了列宽得重算。现代浏览器里首选ResizeObserverlet ro null; let timer null; onMounted(() { ro new ResizeObserver(() { clearTimeout(timer); timer setTimeout(() { tableRef.value tableRef.value.doLayout(); }, 120); }); ro.observe(wrapperRef.value); }); onBeforeUnmount(() { ro ro.disconnect(); clearTimeout(timer); });三个要点。第一observe 外层容器不要 observe el-table 本身。doLayout()会重新计算列宽、改内部 DOM 的宽度如果你 observe 的是被改动的元素就会触发改宽度 → 触发回调 → 再改宽度的死循环控制台会刷出一堆警告。第二一定要防抖。侧边栏折叠动画通常是 300ms动画过程中每一帧宽度都在变如果每帧都重算列宽性能直接爆掉。防抖 120ms 是个不错的折中。如果你的列宽是自己用 canvas 算的那更要注意——重算意味着要重新遍历数据成本比doLayout()高得多。第三动画场景优先用transitionend。如果侧边栏折叠是可预期的点了按钮就折叠监听transitionend再调doLayout()比防抖更精确也不用猜动画多久结束sidebarEl.addEventListener(transitionend, () { tableRef.value tableRef.value.doLayout(); });5.3 隐藏容器里渲染的表格宽度全是错的这是新手最容易踩的坑而且现象很迷惑表格在 tab 的第二页里第一次切过去列宽全是默认的 80px刷新一下或者拖动窗口又好了。原因是容器在display: none状态下offsetWidth是 0。el-table 初始化的时候拿到容器宽度 0bodyMinWidth又大于 0于是走了容器不够宽的分支所有列都收缩到min-width。等容器真正显示出来el-table 并不知道要重算。解决办法按容器类型分el-tabs监听tab-click或者用v-model监听 activeName在nextTick之后调doLayout()。el-dialog监听opened事件因为 dialog 有动画open的时候宽度还没到位。el-collapse监听transitionend等折叠动画结束。v-showv-if的话组件会重新挂载宽度会重算v-show只是切display必须手动doLayout()。我一般会写一个小工具函数把这些时机统一起来function refreshTable(tableRef) { nextTick(() { requestAnimationFrame(() { tableRef.value tableRef.value.doLayout(); }); }); }requestAnimationFrame这一层是为了确保浏览器已经把新的布局应用上去了。实测下来少了这一层在部分场景下还是会拿到旧宽度。6. 封装一个能直接抄的自适应表格讲了这么多原理最后给一个我实际项目里在用的封装思路。核心是把测量和渲染分开测量逻辑放在一个 composable 里表格组件只负责把算好的宽度绑上去。// useAutoColumns.js import { ref, watch } from vue; const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const cache new Map(); const FONT { normal: 14px -apple-system, PingFang SC, Microsoft YaHei, Arial, sans-serif, bold: bold 14px -apple-system, PingFang SC, Microsoft YaHei, Arial, sans-serif }; function measure(text, font) { const key font :: text; if (cache.has(key)) return cache.get(key); ctx.font font; const w ctx.measureText(text null ? : String(text)).width; cache.set(key, w); return w; } export function useAutoColumns(columns, rowsRef, options {}) { const { maxWidth 400, extra 24, sampleLimit 200 } options; const widths ref({}); function calc() { const rows rowsRef.value || []; const sample rows.length sampleLimit ? rows.slice(0, sampleLimit).concat(rows.slice(-20)) : rows; const next {}; columns.forEach(col { if (col.width) { next[col.prop] col.width; return; } const headerW measure(col.label || , FONT.bold); let bodyW 0; for (const row of sample) { const w measure(row[col.prop], FONT.normal); if (w bodyW) bodyW w; } const raw Math.max(headerW, bodyW) extra (col.sortable ? 24 : 0); next[col.prop] Math.min(maxWidth, Math.ceil(raw * 1.05)); }); widths.value next; } watch(rowsRef, calc, { immediate: true, deep: false }); return { widths, recalc: calc }; }然后在页面里这么用el-table reftableRef :datarows classwrap-table el-table-column v-forcol in columns :keycol.prop :propcol.prop :labelcol.label :widthcol.width || widths[col.prop] / /el-table注意这里我把算出来的宽度全部绑到了width上而不是min-width。原因是当所有列都固定宽度时表格不会有多余空间可以分配这样列宽完全等于我算出来的值结果最可控。代价是容器变宽时表格右侧会留白——如果你不能接受留白就把最长的那个业务列改成min-width让它去承接剩余空间其余列用算出来的width。再补一张参数对照表方便你按项目调参数建议值说明maxWidth列表页 300详情页 480单列宽度上限防止长文本撑爆表格extra24单元格左右 padding 边框的补偿sampleLimit200采样行数行数多时按头尾取样1.051.03 ~ 1.08安全系数应对字体回退和页面缩放表头字体bold表头默认是粗体不加 bold 会算窄配合这段 CSS换行和自适应的效果就都齐了.wrap-table .el-table__body-wrapper .cell { white-space: normal; word-break: break-word; overflow-wrap: anywhere; line-height: 20px; } .wrap-table .el-table__header-wrapper .cell { white-space: normal; word-break: break-word; }有一点我要再强调一遍列表页默认不要开换行。换行会把行高变得不可预测一页显示的行数就变少了用户滚动成本变高而且表格会显得很松散。我的一般策略是列表页用show-overflow-tooltip让每行保持固定高度只有详情页、审批页这种要看全的场景才开换行。这个取舍决定了你后面要不要处理固定列错位、行高同步那一堆麻烦事。至于doLayout()这个 API它值得单独记一下它只做一件事——重新计算列宽并把结果应用到 DOM。它不会重新请求数据不会重置排序也不会清空选中项。所以你可以放心地在窗口 resize、tab 切换、侧边栏折叠这些时机里调它。我踩过的一个坑是有一段时间我以为它会把表格状态重置所以每次切换都去改:key强制重渲染结果表格闪一下、滚动位置丢失、用户骂人。后来换成doLayout()问题全没了。最后分享一个排查列宽问题的习惯动作打开 F12选中任意一个col看它的width属性值再对照你配置里的width/min-width。如果col上的值和你的配置完全对不上那说明问题出在分配阶段去看看是不是所有列都写了固定宽度、或者有列的宽度算成了负数如果col上的值是对的但视觉上不对那问题在 CSS 层去查是不是有别的样式覆盖了.cell的宽度或者box-sizing。这一招能在一分钟内把问题范围缩小一半比盲目改样式高效得多。
返回列表