
简介一份面向Vue.js开发者的多行文字展开收起功能实现示例针对长文本展示场景给出组件化解决方案。资源详细展示了如何通过CSS的-webkit-line-clamp属性结合Vue的数据绑定、事件监听与watcher机制实现文本超过三行后显示“查看更多”按钮点击可展开或收起并支持从父组件使用props传参复用。包体方面压缩包内含1个PDF文件大小仅36KB内容紧凑完整便于快速查阅。目前已有3510人浏览学习适合Vue初学及进阶开发者参考。读者可获得完整的组件代码、样式写法、状态切换逻辑以及监听文本长度动态控制按钮显示的设计思路可根据实际需求轻松修改行数限制直接嵌入到项目中复用有效提升长文本展示交互的开发效率。1. 为什么“多行文字展开收起”在 Vue 里不是点击一切那么简单在移动端信息流或后台列表里摘要区域常被限定为三行超出部分以省略号收尾点击“展开”显示全文再点“收起”回到三行。这个交互看起来只是改一个 CSS 类真正做起来会撞上三个问题CSS-webkit-line-clamp能在元素内部生成省略号但按钮插不进去文本是否溢出又取决于容器宽度、字体大小和内容长度不能靠写死的行数推断再加上 Vue 的响应式更新时机你刚测好的高度可能因为图片加载或父容器变化而失效。这篇文章以“Vue 控制多行文字展开收起”为主线从line-clamp样式讲到动态测高再到可复用组件的封装最后给出自适应容器、处理异步图片和验证效果的实操方法。内容覆盖 Vue 3 组合式 API 写法也交代了 Vue 2 选项式 API 的对应位置。适合的人群是写过一段时间 Vue、正在做 H5 列表或后台管理系统对“为什么有的卡片有展开按钮有的没有”感到困惑的开发者。如果你已经很熟悉scrollHeight可以直接跳到第三章看组件封装再到第五章看ResizeObserver的坑。2. 基础方案用 CSS-webkit-line-clamp配合 Vue 状态切换2.1-webkit-line-clamp的基本用法与局限先看最简单的三行截断。在 Vue 单文件组件里定义一个 class.ellipsis-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; }display: -webkit-box让元素成为弹性盒排列的容器-webkit-line-clamp: 3指定最大显示行数-webkit-box-orient: vertical表示按垂直方向排列overflow: hidden把超出的内容裁掉。word-break: break-all是给连续长单词或 URL 用的避免中英文混排时一行只装一半中文而撑破容器。需要说明的是这条属性目前仍是-webkit-前缀驱动W3C 标准版line-clamp在较新 Chromium 和 Safari 里已有实现但 Firefox 101 之前只支持带前缀的写法所以为了兼容性仍建议保留-webkit-全称。关键局限是省略号是浏览器渲染在文本末尾的你没有办法在省略号后面插入“展开”按钮按钮放到元素自己的 box 内部又会随着文本一起被裁掉。因此真实列表场景里按钮要么放在文本下方要么浮在文本层上方用绝对定位处理。属性作用兼容说明display: -webkit-box启用弹性盒垂直布局与flex布局属性不冲突但需要让四个声明保持在同一个规则里-webkit-line-clamp最大显示行数标准line-clamp在旧版浏览器不稳定前缀写法最保险-webkit-box-orient: vertical设置主轴方向为垂直必须和line-clamp写在一起缺失会导致裁切失效overflow: hidden隐藏超出内容省略号由 line-clamp 自动处理无需额外text-overflow2.2 在 Vue 里通过状态变量切换“展开/收起”类名当按钮不需要紧贴省略号时结构要简单得多。外层卡片上挂一个expanded状态文本根据状态切换两种 classtemplate div classcard p :class[desc, expanded ? desc--open : desc--closed]{{ text }}/p button v-ifcanToggle classcard__action clickexpanded !expanded {{ expanded ? 收起 : 展开 }} /button /div /template script setup import { ref } from vue const props defineProps({ text: { type: String, required: true } }) const expanded ref(false) const canToggle ref(true) /script这里把“是否展开”放进 Vue 的响应式状态expanded。按钮通过v-ifcanToggle控制显隐点击后切换expanded文本的 class 也随之变化。样式上desc--closed负责三行截断desc--open恢复普通块级排版.desc--closed { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .desc--open { display: block; white-space: normal; }expanded为false时走desc--closed为true时走desc--open。这里有个隐含问题canToggle被写成true后所有文本都会出现“展开”按钮哪怕它原本只有两行。所以下一步要动态判断内容是否真的超出设定行数这也是“多行文字展开收起”和普通类名切换最大的区别。2.3 什么时候不能只用 CSS 完成当卡片宽度不是固定值或者用户能在手机横竖屏之间切换时文本是否溢出取决于容器宽度、字号、字重等多种因素CSS 本身不会告诉你“这一屏下到底多不多”。更麻烦的是如果设计稿要求按钮出现在省略号同一行的右侧例如“文字…… 展开”就必须让按钮浮在文本层的右下角同时文本要预留出按钮宽度否则会重叠。常见做法是套一个position: relative容器文本区域设置padding-right给按钮让位按钮用position: absolute; right: 0; bottom: 0定位div classwrap p classdesc这里是一段比较长的文本内容超过设定行数后会被截断。/p button classmore展开/button /div.wrap { position: relative; padding-right: 48px; } .desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .more { position: absolute; right: 12px; bottom: 0; }padding-right会压缩文本实际宽度可能导致本应两行的文本变成三行才能容下这和设计预期不一致。如果按钮始终存在还要额外判断文本是否真的溢出否则会出现“没超两行也显示展开”的问题。所以纯 CSS 适合静态长文本和简单展示遇到按钮需要按需展示的场景还是要回到 JavaScript 测量。3. 动态测高方案用scrollHeight判断是否溢出再决定展开收起3.1 计算实际行高与最大高度把“是否出现展开按钮”从写死改为动态判断核心是拿到元素真实行高。假设设计定的是最多显示 3 行行高为 24px那么 3 行的最大高度就是 72px。当clientHeight 72或scrollHeight clientHeight时说明文本溢出需要显示展开按钮。function getLineInfo(el, lines) { const style window.getComputedStyle(el) const lineHeight parseFloat(style.lineHeight) || parseInt(style.fontSize) * 1.2 const maxHeight lineHeight * lines return { lineHeight, maxHeight, overflow: el.scrollHeight maxHeight 1 } }getComputedStyle(el).lineHeight在绝大多数浏览器里返回带px的字符串parseFloat可以取到数值如果返回normal则退化为用fontSize * 1.2估算。maxHeight是行高乘以目标行数。el.scrollHeight包含隐藏溢出内容的高度但它受box-sizing、padding 影响。严格场景下应该先让元素处于未截断状态再测量即在测量前把max-height和line-clamp临时禁用。我一般会这样写function measureOverflow(el, lines) { const originalMaxHeight el.style.maxHeight const originalOverflow el.style.overflow el.style.maxHeight none el.style.overflow visible const scrollHeight el.scrollHeight const style window.getComputedStyle(el) const lineHeight parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 el.style.maxHeight originalMaxHeight el.style.overflow originalOverflow return scrollHeight lineHeight * lines 1 }这里把max-height暂时置为none因为如果元素当前已经处于收缩状态scrollHeight返回的只是当前约束下的高度而不是真实内容高度。overflow: visible是为了避免滚动条参与计算。最后把值恢复避免影响后续渲染。判断条件里的1是容差。不同浏览器对scrollHeight的取整方式不同计算出的lineHeight * lines可能是浮点数比如 71.9999直接和 72 比较可能误判为不溢出。加 1px 后能稳定判定。但同步改样式会强制浏览器同步布局即 layout thrashing。一次测量无所谓频繁 resize 触发时建议用requestAnimationFrame包一层后面第五章会讲到。测量方式适用场景注意点el.clientHeight el.scrollHeight折叠态下判断溢出必须在元素处于折叠态时调用否则没有参考去掉 max-height 后取 scrollHeight不管当前状态直接拿真实高度会触发同步重排建议用 rAF 包裹计算行高乘行数已知字体和 line-height 时需要兼容line-height: normal3.2 展开状态的过渡动画实现测出溢出之后按钮显示很自然。但“展开”如果直接从三行跳到几十行视觉上就很生硬。常见做法是用 CSSmax-height过渡收起时给一个接近实际高度的限制展开时给一个大值靠transition: max-height .3s ease实现。template div classdescription :classexpanded ? description--open : description--closed {{ text }}/div /template style scoped .description { transition: max-height 0.3s ease-in-out; } .description--closed { max-height: 72px; overflow: hidden; } .description--open { max-height: 2000px; } /style这种用大值2000px的做法有缺陷如果文本实际高度只有 120px动画从 72px 到 2000px会先快后慢且动画耗时比视觉停止时间要长。更精确的做法是测量出实际高度后把max-height设置为scrollHeight px展开结束再改为none。如果不需要动画直接用 class 切换也行但用户会看不到内容从哪里开始所以大多数列表场景都会保留 0.2s 到 0.3s 的过渡。transition可作用的属性里max-height是可以动画化的因为浏览器能计算起始和结束的像素值。注意不要对line-clamp做过渡浏览器不会为-webkit-line-clamp的值变化生成中间帧所以要么切max-height要么隐藏后直接改行数。3.3 组件封装前必须先解决的判断时机测量时机比计算本身更容易出错。onMounted之后 DOM 已经渲染但如果文本里包含图片图片没有加载完时scrollHeight会偏小导致本应出现的展开按钮消失。另一个常见错误是在nextTick里执行测量但nextTick只保证 Vue 组件 DOM 更新完成不保证图片资源解码完成。因此在封装组件之前要先决定好测量时机策略纯文本内容在onMounted测量含图片的富文本需要等图片load事件或者使用window.load事件兜底。如果是动态加载的远程文本要等数据到达后再一次测量而不是只依赖初始化。这也是下一章设计组件时要把refresh方法暴露出去的原因。4. 封装一个可复用的 Vue 展开收起组件参数、插槽与边界4.1 组件 props 和事件设计把前面的逻辑收进一个组件先列 props 设计名称类型默认值用途textString要展示的文本未用插槽时生效linesNumber3收起时显示的行数expandTextString展开展开按钮文案collapseTextString收起收起按钮文案buttonAlignStringleft按钮对齐方式left/right/centerdisabledBooleanfalse禁用展开收起功能等同直接显示全文组的核心逻辑是isOverflow保存测量结果expanded保存展开状态text作为内容来源。对外不直接暴露测量方法而是提供refresh()供外部数据变化后重新测量。template div classexpandable div reftextRef classexpandable__text :class[!expanded isOverflow ? expandable__text--closed : ] slot{{ text }}/slot /div button v-ifisOverflow !disabled typebutton classexpandable__action :classexpandable__action--${buttonAlign} :aria-expandedexpanded clicktoggle {{ expanded ? collapseText : expandText }} /button /div /template script setup import { ref, onMounted, watch, nextTick } from vue const props defineProps({ text: { type: String, default: }, lines: { type: Number, default: 3 }, expandText: { type: String, default: 展开 }, collapseText: { type: String, default: 收起 }, buttonAlign: { type: String, default: left }, disabled: { type: Boolean, default: false } }) const emit defineEmits([toggle, refresh]) const textRef ref(null) const expanded ref(false) const isOverflow ref(false) function measureOverflow() { const el textRef.value if (!el) return const originalMaxHeight el.style.maxHeight const originalOverflow el.style.overflow el.style.maxHeight none el.style.overflow visible const scrollHeight el.scrollHeight const style window.getComputedStyle(el) const fontSize parseFloat(style.fontSize) const lineHeight parseFloat(style.lineHeight) || fontSize * 1.2 el.style.maxHeight originalMaxHeight el.style.overflow originalOverflow isOverflow.value scrollHeight lineHeight * props.lines 1 } function toggle() { expanded.value !expanded.value emit(toggle, expanded.value) } function refresh() { expanded.value false nextTick(() measureOverflow()) } onMounted(measureOverflow) watch(() props.text, refresh) defineExpose({ refresh }) /scriptslot内容的优先级高于text。如果你直接传复杂 HTML 或拼接的富文本推荐用插槽不要用v-html理由会在 4.2 说明。按钮的aria-expanded可以让屏幕阅读器知道当前状态但这里没有配套的aria-controls你应该把文本区域的id绑定给按钮再通过aria-controls指向它提升辅助设备体验。refresh方法暴露给父组件是应对父组件数据异步更新的关键。4.2 为什么用插槽而不是 v-html很多项目会把后端返回的富文本直接塞进v-html然后在容器上加line-clamp。v-html会把style、script之类的标签直接插进 DOM存在 XSS 风险而且富文本内部可能自带display: inline或imgline-clamp对它不一定生效。用插槽则保持外层元素是 Vue 编译后的真实节点文本由父级传入渲染结果更容易被scrollHeight计算。如果需要动态拼接 HTML可以用渲染函数或组件组合而不是字符串插值。如果内容非常长比如几万字slot仍会一次性创建大量文本节点但至少不会阻塞 Vue 的响应式依赖追踪比你手动操作innerHTML更容易定位内存泄漏。4.3 参数细节与边界情况lines最小值为 1如果传 0 或负数测量公式会变成负数高度按钮永远显示。可以在defineProps里加一层保护const safeLines Math.max(1, props.lines || 1)buttonAlign只控制按钮自身对齐要改变整个卡片的布局应该由父组件决定。disabled置为true时即使文本溢出也不显示按钮等价于摘要在列表里永远折叠。如果需求是“首次展开后即使文本变短也要保留展开态”可以把 watch 改为只在 text 变化时调用refresh而refresh里不重置expanded这需要按业务调整。还有一点容易被忽略组件在v-for列表里复用时如果key使用的是数组indexVue 会复用同一个组件实例text被替换时watch触发但 DOM 节点可能还是旧的测量会在旧内容上执行。所以列表项的key必须是item.id而不是index否则会看到“展开按钮和文本对不上”的错位现象。5. 自适应宽度与异步内容解决“展开后高度不对”的关键5.1 用 ResizeObserver 监听容器宽度而不是 window resize很多卡片宽度不由自己决定而是受栅格和父容器影响。window.resize只能监测到浏览器窗口变化当侧边栏收起、回流布局改变容器宽度时窗口尺寸没变但文本容器变宽了溢出判断仍然失效。所以应该用ResizeObserver监听文本容器自身尺寸。let resizeObserver null function initResizeObserver(el) { resizeObserver new ResizeObserver(entries { for (const entry of entries) { if (entry.target el) { refresh() } } }) resizeObserver.observe(el) } onMounted(() { measureOverflow() if (textRef.value) initResizeObserver(textRef.value) }) onBeforeUnmount(() { if (resizeObserver) resizeObserver.disconnect() })ResizeObserver的回调会在初始观察时触发一次所以如果你在onMounted里已经测量接着observe又会触发一次refresh会造成多余的重排。解决办法是初始化时加一个标志位跳过第一次回调或者直接由 observer 接管测量删除onMounted中的手动调用。但从维护角度我会保留手动measureOverflow在初始化 observer 后延迟一帧再观察避免同步重叠。API触发时机常用场景observe(el)开始观察时和后续尺寸变化时监听容器宽高变化unobserve(el)停止观察单个元素组件卸载或元素被替换disconnect()停止所有观察组件卸载时统一清理entries[i].contentRect回调参数读取新尺寸避免读offsetWidth强制同步布局5.2 处理图片加载导致的测量偏差含图片的文本块高度在图片加载完成前是不稳定的。图片未加载时scrollHeight只计算文字占位高度图片上指定了width/height的还好未指定时高度为 0测量就会错误地认为没有溢出导致该出现的展开按钮不出现。最直接的兼容做法是给图片设置宽高占位但这在富文本项目里很难强制。另一个办法是在容器内监听图片的load事件并重新测量function waitForImages(el) { const images Array.from(el.querySelectorAll(img)) if (images.length 0) return Promise.resolve() return Promise.all( images.map(img { if (img.complete img.naturalWidth ! 0) return Promise.resolve() return new Promise(resolve { img.addEventListener(load, resolve, { once: true }) img.addEventListener(error, resolve, { once: true }) }) }) ) } async function measureWithImages() { const el textRef.value if (!el) return await waitForImages(el) requestAnimationFrame(() measureOverflow()) }img.complete为true且naturalWidth不为 0表示图片已经解码完成跳过等待已经加载但失败的图片naturalWidth为 0通过error事件兜底。requestAnimationFrame确保在图片引起的高度变化被应用后再测量否则你可能拿到的是图片加载前的最后一帧布局。如果图片使用了loadinglazyload事件只有在图片滚动到视口附近时才触发你的测量永远不会执行。这时可以监听IntersectionObserver等图片进入视口后再触发refreshconst io new IntersectionObserver(entries { if (entries.some(entry entry.isIntersecting)) { refresh() io.disconnect() } }) io.observe(textRef.value)这个组合能覆盖大多数卡片的懒加载场景。但要注意IntersectionObserver依赖视口在 iframe 或隐藏容器里不会触发必要时加一个setTimeout兜底。5.3 SSR 和打包后布局异常的处理如果你的项目使用 Nuxt 或类似 SSR 框架组件初始化时window不存在直接调用getComputedStyle会抛异常。常见做法是只在客户端挂载后执行测量const isClient typeof window ! undefined typeof document ! undefined onMounted(() { if (!isClient) return measureOverflow() })同时要注意SSR 输出时按钮不可见客户端 hydrate 后测量判断为溢出再显示按钮这个过程会出现“闪烁”。所以建议给按钮和文本一个默认的收起样式服务端渲染时只输出折叠类名不输出按钮等客户端测量完再更新。另一个和“Vue 打包后布局异常”相关的点是生产包通常会压缩代码某些依赖返回值优化的写法可能导致measureOverflow里的测量时机被跳过。比如把refresh直接写在创建ResizeObserver的构造函数里生产环境下可能被压缩成不同顺序导致在组件注册前调用。稳妥做法是把测量函数保持在组件方法内部不要依赖实例外部的全局状态。控制台里如果出现Cannot read property scrollHeight of null优先检查textRef是否被v-if包裹条件渲染的节点在onMounted里可能还不存在。6. 用视觉回归和性能指标验证展开收起的实际效果6.1 最小可用测试脚本用 Playwright 断言高度变化与其人工反复点击不如写一条端到端断言。假设你已经在 Vue 项目里跑起了开发服务器用 Playwright 写一个简单用例import { test, expect } from playwright/test test(展开后文本高度大于收起状态, async ({ page }) { await page.goto(/demo) const box page.locator(.expandable__text) const closedHeight await box.evaluate(el el.getBoundingClientRect().height) await page.getByRole(button, { name: 展开 }).click() const openHeight await box.evaluate(el el.getBoundingClientRect().height) expect(openHeight).toBeGreaterThan(closedHeight) })这个用例能抓住两类问题按钮没有渲染导致无法点击以及展开状态没有让文本溢出高度不变。如果你的实现里有max-height: 2000px虽然高度会变大但最终文本可能仍被overflow: hidden裁掉所以还要额外断言scrollHeight clientHeight为真或者直接比较内容是否完整渲染。这套脚本在 CI 里可以作为视觉回归的一部分不需要完整截图比对因为截图对比很容易被字体渲染差异打断。6.2 用 transform 避免展开/收起触发布局抖动多行展开收起最容易被忽略的性能问题是“每次切换都触发整个列表重排”。当卡片数量多时展开一条如果让后续卡片全部下移浏览器要为每张卡片重新计算位置。常见优化是把展开内容放进独立的overflow: hidden层然后用transform: translateY或opacity动画避免触发大规模排布。参数上max-height过渡会产生重排但如果卡片在同一卡片内且没有外部依赖影响可控如果列表非常长建议改用will-change: transform或content-visibility: auto做跳过渲染优化。注意will-change会增加合成层数量组件数量超过 30 个时反而会让滚动卡顿所以不要滥用。6.3 一个容易忽略的验收点键盘可达性最后提供一个实用的检查清单按钮应该是原生button而不是div加点击事件按下 Enter 和空格键能触发切换展开后焦点仍应在按钮上不要跳走屏幕阅读器通过aria-expanded感知状态并通过aria-controls找到文本区域。可以在 Chrome DevTools 的 Accessibility 面板里检查。button :aria-expandedexpanded :aria-controlstextId clicktoggle {{ expanded ? collapseText : expandText }}/buttontextId使用组件内唯一的useId()生成防止 SSR hydrate 时对不上。接着在watch里监听expanded变化展开后把文本区域滚动到可视区watch(expanded, val { if (val) { nextTick(() { textRef.value?.scrollIntoView({ block: nearest, behavior: smooth }) }) } })scrollIntoView只在展开时执行block: nearest能避免把整页推走只把当前卡片移动到视口边缘。到这里展开收起组件已经能从“样式切换”一路走到“可访问性验证”剩下的细节多跑几次真机就能发现。本文还有配套的精品资源点击获取