简介:一份面向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-overflow |
2.2 在 Vue 里通过状态变量切换“展开/收起”类名
当按钮不需要紧贴省略号时,结构要简单得多。外层卡片上挂一个expanded状态,文本根据状态切换两种 class:
<template> <div class="card"> <p :class="['desc', expanded ? 'desc--open' : 'desc--closed']">{{ text }}</p> <button v-if="canToggle" class="card__action" @click="expanded = !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-if="canToggle"控制显隐,点击后切换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 class="wrap"> <p class="desc">这里是一段比较长的文本内容,超过设定行数后会被截断。</p> <button class="more">展开</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: normal |
3.2 展开状态的过渡动画实现
测出溢出之后,按钮显示很自然。但“展开”如果直接从三行跳到几十行,视觉上就很生硬。常见做法是用 CSSmax-height过渡:收起时给一个接近实际高度的限制,展开时给一个大值,靠transition: max-height .3s ease实现。
<template> <div class="description" :class="expanded ? '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 设计:
| 名称 | 类型 | 默认值 | 用途 |
|---|---|---|---|
text | String | '' | 要展示的文本,未用插槽时生效 |
lines | Number | 3 | 收起时显示的行数 |
expandText | String | '展开' | 展开按钮文案 |
collapseText | String | '收起' | 收起按钮文案 |
buttonAlign | String | 'left' | 按钮对齐方式,left/right/center |
disabled | Boolean | false | 禁用展开收起功能,等同直接显示全文 |
组的核心逻辑是:isOverflow保存测量结果,expanded保存展开状态,text作为内容来源。对外不直接暴露测量方法,而是提供refresh(),供外部数据变化后重新测量。
<template> <div class="expandable"> <div ref="textRef" class="expandable__text" :class="[!expanded && isOverflow ? 'expandable__text--closed' : '']" > <slot>{{ text }}</slot> </div> <button v-if="isOverflow && !disabled" type="button" class="expandable__action" :class="`expandable__action--${buttonAlign}`" :aria-expanded="expanded" @click="toggle" > {{ 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 }) </script>slot内容的优先级高于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或<img>,line-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使用的是数组index,Vue 会复用同一个组件实例,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确保在图片引起的高度变化被应用后再测量,否则你可能拿到的是图片加载前的最后一帧布局。
如果图片使用了loading="lazy",load事件只有在图片滚动到视口附近时才触发,你的测量永远不会执行。这时可以监听IntersectionObserver,等图片进入视口后再触发refresh:
const 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-expanded="expanded" :aria-controls="textId" @click="toggle" >{{ expanded ? collapseText : expandText }}</button>textId使用组件内唯一的useId()生成,防止 SSR hydrate 时对不上。接着在watch里监听expanded变化,展开后把文本区域滚动到可视区:
watch(expanded, val => { if (val) { nextTick(() => { textRef.value?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }) } })scrollIntoView只在展开时执行,block: 'nearest'能避免把整页推走,只把当前卡片移动到视口边缘。到这里,展开收起组件已经能从“样式切换”一路走到“可访问性验证”,剩下的细节多跑几次真机就能发现。
本文还有配套的精品资源,点击获取