CSS省略号与展开收起:无定位实现多行文本截断的完整方案
做内容卡片列表的时候最烦的一件事就是简介超过两行要显示省略号同时还要给个展开/收起的入口。网上搜到的方案大多绕不开 position 定位按钮要么 absolute 盖在文本右下角要么在父容器上加 relative 然后堆一堆 z-index。这套做法在小场景里能用一旦列表复杂点、背景有渐变、卡片圆角有差异就会暴露各种问题。我最近整理了一套 css 省略号展示方案兼容性不错配合 js 判断几行来动态控制查看详情按钮显隐全程不依赖定位属性实测下来在列表渲染、展开交互、响应式变化上都比定位方案稳得多。这篇文章把完整实现思路和代码都放出来重点讲清楚三个问题省略号怎么写得兼容、js 怎么精确判断真实行数、以及在不用定位的前提下按钮怎么排版不别扭。适合正在做资讯卡片、商品简介、评论折叠这类场景的读者直接参考。1. 为什么省略号判断行数会让人绕不开定位1.1 常见的查看详情定位遮罩方案有什么隐患先看大家最常搜到的写法。要实现两行文本末尾有个查看详情按钮常规结构是给外层容器设 position: relative给按钮设 position: absolute然后 left/right/bottom 钉住右下角再给按钮背后垫一层从透明到白色或页面背景色的线性渐变文本区域用 padding-bottom 预留按钮的位置。这个方案在单个静态卡片里确实能出效果但把它放到真实项目里就会遇到几类问题第一背景会穿帮。渐变遮罩最后一层颜色是写死的比如 linear-gradient(to right, transparent, #fff 70%)一旦卡片背景不是纯白而是浅灰、暖色、渐变色甚至卡片是圆角毛玻璃按钮底下就会露出一条颜色分界线怎么看怎么假。第二padding-bottom 剧毒。为了让按钮不盖住正文要给文本容器预留 padding-bottom。可多行省略号相关的容器一旦加了 padding最后一行文字的视觉位置和 max-height 估算很容易偏差。更麻烦的是展开后 padding-bottom 依然留着按钮和正文之间会出现莫名空白还得单独处理。第三定位坐标和滚动容器耦合。列表页只要有 transform、filter、will-change 这些属性absolute 的包含块就可能变成最近的变形祖先按钮位置会飘。加了 overflow: hidden 的容器还会直接把按钮裁掉。排查这类问题非常费时间。所以不需要定位这个约束不是矫情而是为了在复杂页面里少一些看不见的坑。1.2 把按钮拉回文档流让交互跟随内容自然走我最终采用的思路很简单按钮就是一个普通的 button放在文本容器后面处于正常文档流。文本超过两行时js 负责让按钮可见点击展开后按钮跟着全文自动落到正文下方点击收起按钮回到两行截断位置的下方。整个过程不涉及任何绝对定位也没有背景遮罩。牺牲掉按钮刚好浮在第二行末尾这种覆盖式效果换来的是代码结构干净、不用处理 z-index、不依赖父级 position也不存在遮罩颜色穿帮的问题。对列表卡片来说展开全文作为独立一行按钮视觉上完全成立用户理解成本也低。如果你实在想让按钮看起来更靠近文本可以用 margin-top 负值把它往上拉半行到一行比如 margin-top: -1.5em让按钮和最后一行文字贴近。负值和定位是两回事没有脱离文档流不会引入包含块问题。这部分我会在完整代码里给一个可调节的写法。2. 省略号的纯 CSS 实现line-clamp 的兼容性边界与降级策略2.1 单行省略与多行省略是两套完全不同的属性单行省略三板斧是 white-space: nowrap、overflow: hidden、text-overflow: ellipsis。这套兼容性极好IE 都能用但只能在一行内生效。多行省略不能直接套 text-overflow主流写法是.multi-line { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }这段代码里真正干活的是 -webkit-line-clamp后面的 -webkit-box 只是个容器形态。所以网上有人单独写一个 line-clamp: 2 就想生效是不行的它必须配合上面的 display 和 box-orient。我建议在写多行省略的时候同时给容器一个显式的 line-height。原因有两个一是 line-clamp 的行数依赖行高计算显式声明之后不同浏览器表现一致二是后面做 JS 判断和降级方案都需要一个稳定的行高值。比如.multi-line { line-height: 1.5; font-size: 14px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }2.2 各家浏览器实际支持情况别被私有前缀吓住很多人看到 -webkit- 前缀就觉得 Chrome 专用其实兼容性比想象中好Chrome、Edge、Safari、Opera 一直支持 -webkit-line-clampFirefox 从 68 开始支持 -webkit-line-clampIE 不支持但 IE 已经没必要专门适配到像素级。标准属性 line-clamp 是近几年才陆续进浏览器的写两遍属性不影响支持者关键是要把 -webkit-line-clamp 写在标准声明之前因为不支持标准属性但支持前缀的浏览器会各取所需。注意一个小坑如果 -webkit-box 容器里塞了 float 元素或者 inline-block 元素line-clamp 的裁剪行为在不同浏览器会有细微差异文本容器里最好只放纯文本和行内标签不要放脱离文本流的元素。2.3 通过 supports 做降级没有省略号但内容不崩真正要做兼容性强不是让所有浏览器都显示一个完美省略号而是能力降级时布局不乱。我的做法是先按 line-clamp 实现再用 supports 给不支持的浏览器一个 max-height 截断方案。.multi-line { font-size: 14px; line-height: 1.5; overflow: hidden; /* 不支持 line-clamp 时的兜底两行文本高度 */ max-height: calc(1.5 * 2 * 14px); } supports ((-webkit-line-clamp: 2) or (line-clamp: 2)) { .multi-line { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; max-height: none; } }max-height: calc(1.5 * 2 * 14px) 里的 1.5 是行高倍数2 是行数14px 是字号。这类 calc 写法的好处是语义明确以后改行数只动一个数字。降级方案没有省略号但文本被整齐截断按钮依然由 JS 判断是否出现所以功能链是完整的能点开看全文收起后高度正常。这比在 IE 里显示一坨溢出文本强多了。3. JS 判断行数的原理拆解为什么 scrollHeight 方案会骗你3.1 scrollHeight / clientHeight 的适用前提先说不推荐的做法const lineCount Math.ceil(el.scrollHeight / parseFloat(getComputedStyle(el).lineHeight));这个方案在普通块级容器上用没问题但一旦元素自身设置了 -webkit-line-clamp就不一定准了。因为 line-clamp 会把超出行数的文本在布局阶段直接砍掉scrollHeight 可能只反映被 clamp 之后的高度而不是文本真实内容的高度。更别提 line-height 是 normal 时取到的计算值未必等于实际行高增加误差。所以它只适合自己用 max-height overflow: hidden 截断的场景不算一个普适的判断方法。3.2 用 Range.getClientRects() 拿到每一行的真实渲染矩形浏览器在排版文本时每一行文字都会生成一个矩形区域。通过创建一个 Range 选中整个文本容器再读取 getClientRects()就能拿到这些行矩形的列表。行数就是不同的顶部坐标数量。function getLineCount(el) { const range document.createRange(); range.selectNodeContents(el); const tops new Set(); const rects range.getClientRects(); for (const rect of rects) { // 过滤掉宽度为 0 的幽灵矩形 if (rect.width 0 rect.height 0) continue; tops.add(Math.round(rect.top)); } return tops.size; }为什么要按 top 去重而不是直接数 rects.length因为一段文本里如果有 span、strong、a 这类行内元素Range 可能把同一行的不同片段拆成多个矩形直接数会虚高。而同一行文字的顶部坐标是基本一致的取整后放到 Set 里就能去重。这是我在实际项目里踩过之后改的最初直接用 rects.length结果带链接的标题全被判断成超过两行。3.3 为什么要在未截断副本上测量而不是直接测原元素这是整套方案最关键的一个细节。如果待检测元素本身应用了 line-clamp: 2超出的第三行文本在渲染树里不会再生成布局矩形。也就是说你直接对原元素调用 getClientRects 时很可能只拿到两行的矩形会误判为没有超出。解决办法是把一个克隆副本放到屏幕外副本保持与原元素相同的宽度、字体、行高、padding但不设置任何行数限制然后在这个副本上跑 Range 检测算出真实行数。function createMeasureClone(el) { const clone el.cloneNode(true); const style window.getComputedStyle(el); const w el.getBoundingClientRect().width; const layoutProps [ fontSize, fontFamily, fontWeight, lineHeight, letterSpacing, wordSpacing, wordBreak, whiteSpace, textTransform, textIndent, textAlign, paddingLeft, paddingRight ]; for (const prop of layoutProps) { clone.style[prop] style[prop]; } clone.style.width w px; clone.style.maxHeight none; clone.style.webkitLineClamp unset; clone.style.lineClamp unset; clone.style.display block; clone.style.position fixed; clone.style.left -9999px; clone.style.top -9999px; clone.style.visibility hidden; clone.style.pointerEvents none; document.body.appendChild(clone); return clone; }副本的定位用的是 fixed left 负值这属于测量工具的内部实现不参与业务 UI。最终页面上用户看到的元素依然没有任何定位属性。如果你很介意不需要定位这个硬指标也可以把测量逻辑改成临时移除原元素的 clamp测完再恢复但那样在低端设备上会肉眼看到布局闪烁我不推荐。正常的业务判断函数这样写function isOverflow(el, limitLines) { const clone createMeasureClone(el); const count getLineCount(clone); document.body.removeChild(clone); return count limitLines; }最后一定要 removeChild否则列表场景会累积一堆隐藏节点占内存还干扰选择器匹配。4. 完整组件实现CSS JS 配合按钮全程不占位不闪烁4.1 HTML 结构约定我用三个约定好了的角色外层容器负责承载展开状态文本区负责被 clamp按钮负责交互。div classexpander>.expander__text { font-size: 14px; line-height: 1.5; margin: 0; overflow: hidden; max-height: calc(1.5 * 2 * 14px); } supports ((-webkit-line-clamp: 2) or (line-clamp: 2)) { .expander__text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; max-height: none; } } .expander__text.is-open { display: block; -webkit-line-clamp: unset; line-clamp: unset; -webkit-box-orient: unset; max-height: none; overflow: visible; } .expander__toggle { display: block; margin-top: 6px; border: none; background: transparent; color: #2d6cdf; font-size: 13px; cursor: pointer; padding: 2px 4px; } .expander__toggle[hidden] { display: none; }几个细节说明.is-open 里把 display 改成 block是为了让文本脱离 -webkit-box 布局否则有些浏览器展开后 line-clamp 依然参与裁剪。margin-top: 6px 让按钮和文本之间有一点距离。如果想让按钮更贴近文本可以改成 margin-top: -1.5em 试试但注意它可能会和最后一行文字重叠需要配合按钮背景色所以我默认给的是正向间距。button[hidden] 要单独写 display: none因为按钮本身设置了 display: block会覆盖浏览器默认的 hidden 样式。4.3 JS 测量与按钮显隐逻辑const LINE_LIMIT 2; function measureAndToggle(root) { const text root.querySelector([data-text]); const toggle root.querySelector([data-toggle]); if (!text || !toggle) return; // 展开状态下不自动隐藏按钮因为用户需要收起入口 if (root.dataset.open true) { toggle.hidden false; return; } toggle.hidden !isOverflow(text, LINE_LIMIT); } function bindExpander(root) { const toggle root.querySelector([data-toggle]); toggle.addEventListener(click, () { const text root.querySelector([data-text]); const isOpen root.dataset.open true; if (isOpen) { text.classList.remove(is-open); root.dataset.open false; toggle.textContent 展开全文; measureAndToggle(root); } else { text.classList.add(is-open); root.dataset.open true; toggle.textContent 收起; } }); } document.addEventListener(DOMContentLoaded, () { const expanders document.querySelectorAll([data-expander]); expanders.forEach((root) { bindExpander(root); measureAndToggle(root); }); });这段逻辑里有两个容易漏的点一是展开状态下不要再调用 isOverflow否则按钮会被重新隐藏。用户在展开后肯定需要收起按钮所以 dataset.open 为 true 时直接强制显示按钮。二是点击收起的回调里等文本恢复 clamp 后再调用 measureAndToggle让按钮按照新的两行状态判断是否隐藏。实测下来如果收起后文本依然超过两行按钮会正确保留文字还是展开全文如果因为窗口变化导致文本现在只剩一行按钮就会自动消失。4.4 列表场景下的事件绑定优化如果页面里有几十个卡片每个都单独 addEventListener 也没问题但更好的做法是事件委托。给列表容器挂一个监听用 closest 找到最近的 [data-expander]const list document.querySelector(.card-list); list.addEventListener(click, (e) { const toggle e.target.closest([data-toggle]); if (!toggle) return; const root toggle.closest([data-expander]); if (!root) return; const text root.querySelector([data-text]); const isOpen root.dataset.open true; if (isOpen) { text.classList.remove(is-open); root.dataset.open false; toggle.textContent 展开全文; measureAndToggle(root); } else { text.classList.add(is-open); root.dataset.open true; toggle.textContent 收起; } });这样新增卡片时不需要初始化事件只要 HTML 结构符合约定DOMContentLoaded 里的测量逻辑会自动覆盖到。5. 响应式与细节字体加载、resize、多卡片复用5.1 字体加载完成前不要测量不然行数全是错的页面字体如果用了自定义字体在字体文件加载完成前浏览器先用 fallback 字体渲染。fallback 字体和正式字体的字符宽度不一样换行位置也就不同此时测出来的行数基本不准。一个很典型的场景卡片文字用中文fallback 是系统宋体但页面字体是思源黑体两者字符宽度差几个像素三行文本可能被误判成两行按钮就不显示了。解决办法是先等 fonts.readydocument.fonts.ready.then(() { expanders.forEach(measureAndToggle); });如果项目里有部分元素用 font-display: block字体加载时间会更长这个等待就更有必要。我之前踩过这个坑一开始只在 DOMContentLoaded 里测后来发现某些卡片按钮时隐时现就是因为字体加载时序。5.2 resize / 容器宽度变化后重新计算窗口尺寸变化时文本区域宽度改变行数一定会变。最简单粗暴的做法是 window resize 时重新测量加个防抖let resizeTimer; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { document.querySelectorAll([data-expander]).forEach(measureAndToggle); }, 200); });这里有个体验细节resize 过程中会让克隆副本反复插入和移除虽然副本在屏幕外但浏览器依然要做一次完整的重排。如果卡片很多200ms 的防抖不够可以改成 300ms。如果卡片数量超过五十建议用 IntersectionObserver 只对进入视口的卡片做测量不要在页面初始化时一次性测所有。还有一种更精准的容器级监听ResizeObserver 观察每个 expander 的宽度变化。这个适合卡片在页面里宽度不随窗口变化、但会因侧栏折叠而变化的场景。代码也不复杂const resizeObserver new ResizeObserver((entries) { entries.forEach((entry) { const root entry.target; measureAndToggle(root); }); }); document.querySelectorAll([data-expander]).forEach((root) { resizeObserver.observe(root); });ResizeObserver 触发的频率比 resize 低很多实测列表场景没有明显卡顿。两个方案可以根据卡片数量和触发场景选择不必同时用。5.3 测量函数的性能优化与内存清理克隆一个元素到 body 里再跑 Range 检测本质上是制造一次隐藏布局。单个元素还好一百个卡片同时跑低端机会有明显的卡顿。有几个办法缓解组件内部缓存上一次测量结果行数没变化就不操作 DOM初始化时用 requestIdleCallback 分批处理而不是一次循环跑完测量完立刻移除克隆节点不在 body 里积累垃圾节点我实际项目里用的是第三种配合一个 requestIdleCallback 分批处理页面首屏不会因为测量而阻塞。5.4 几个容易忽略的边界情况文本里有 emoji 或行内图片时Range 得到的矩形可能包含它们的高度如果 emoji 比行高还高顶部坐标会出现额外取值导致行数偏多。纯文本简介不会遇到但用户昵称、商品标题这类内容也要兼容时可以先过滤掉高度明显大于 lineHeight 的 rect。我不会把这部分做成主流程因为过滤规则本身有风险但知道这个现象遇到问题时不至于无从查起。另一个边界是 display: none 的元素。隐藏状态下的元素宽度是 0克隆后行数完全失真。所有测量必须等元素真实渲染后再做不能放在 display: none 的父容器里初始化。如果卡片在 Tab 切换里需要等 Tab 显示后再调用一次 measure。最后分享一个我自己坚持的小规则CSS 里的 max-height 兜底和 JS 里的 LINE_LIMIT 必须保持一致我在代码里用了一个 CSS 变量来管理这两个值避免改行数时只改一处.expander { --line-limit: 2; --line-height: 1.5; --font-size: 14px; } .expander__text { max-height: calc(var(--line-height) * var(--line-limit) * var(--font-size)); }JS 侧通过读取 getComputedStyle 的 --line-limit 拿到行数这样将来从两行改成三行只需要改 CSS 变量JS 逻辑完全不用动。这个思路是我在维护多个内容模板时总结出来的比在 JS 里写死数字要省心。