在内容型网站和应用中,多行文本的展开收起功能几乎是标配需求。不同于常见的纯文字按钮方案,本文将分享一个基于 Vue 3 的增强版实现,它具备以下特性:
这个组件特别适合社区类、内容展示类项目,比如论坛帖子预览、商品详情折叠、用户评论等场景。下面我将从原理到实现完整解析这个组件的开发过程。
实现多行文本截断主要有三种技术路线:
纯CSS方案:
JavaScript计算方案:
Canvas测量方案:
本组件选择了JavaScript计算方案,因为:
组件通过比较三个高度值来决定是否显示折叠按钮:
getComputedStyle获取元素的实际渲染高度maxLines和lineHeight计算得出scrollHeight检测内容是否被截断关键判断逻辑:
javascript复制const shouldCollapse =
descHeight > maxHeight - 1 &&
descRef.value.scrollHeight > descRef.value.clientHeight
这里maxHeight - 1的减1操作是为了与CSS中的+1形成互补,确保边缘情况下的正确判断。
组件的模板分为几个关键部分:
html复制<template>
<div v-if="contentText" class="wrapper">
<!-- 隐藏的checkbox控制展开状态 -->
<input
v-if="state.showToggleButton"
:id="uniqueId"
class="exp"
type="checkbox"
@click.stop="displayAllContent"
/>
<!-- 文本内容容器 -->
<div
ref="descRef"
class="text"
:class="{ 'text-white': isTextWhiteColor }"
@click.stop="handleClickContentText"
>
<!-- 折叠按钮 -->
<label
v-if="state.showToggleButton"
class="btn"
:class="{ 'btn-white': isTextWhiteColor }"
:for="uniqueId"
aria-label="展开/收起"
></label>
<!-- 话题标签 -->
<span
v-if="topic"
class="topic-text"
:class="{ 'topic-text-white': topicDisable }"
@click.stop="handleClickTopicText"
>
{{ topic }}
</span>
<!-- 等级显示 -->
<span v-if="level !== undefined" class="g-font-number">
等级{{ level }}
</span>
<!-- 主要内容 -->
{{ contentText }}
</div>
</div>
</template>
设计要点:
v-if="contentText"确保无内容时不渲染label关联checkbox,避免直接状态管理javascript复制const props = defineProps({
level: Number,
topic: String,
contentText: {
type: String,
required: true,
},
maxLines: {
type: Number,
default: 2,
validator: (value) => value > 0,
},
lineHeight: {
type: Number,
default: 20,
validator: (value) => value > 0,
},
fontSize: {
type: Number,
default: 14,
validator: (value) => value > 0,
},
isTextWhiteColor: Boolean,
topicDisable: Boolean,
})
参数说明:
maxLines:控制默认显示的行数lineHeight:必须与实际行高一致,用于精确计算fontSize:确保文字大小与行高匹配topicDisable:控制话题标签是否可点击javascript复制const state = reactive({
showAll: true,
collapsedStatus: true,
showToggleButton: false,
})
const emit = defineEmits({
expanded: (newVal) => typeof newVal === 'boolean',
handleClickTopicText: null,
handleClickContentText: null,
})
事件设计:
expanded:通知父组件展开状态变化handleClickTopicText:话题标签点击事件handleClickContentText:内容区域点击事件高度计算与初始化:
javascript复制const getMaxHeight = () => {
return props.lineHeight * props.maxLines + 1
}
const initLayout = async () => {
await nextTick()
if (!descRef.value) return
const descHeight = parseFloat(window.getComputedStyle(descRef.value).height)
const maxHeight = getMaxHeight()
state.showToggleButton =
descHeight > maxHeight - 1 &&
descRef.value.scrollHeight > descRef.value.clientHeight
}
事件处理:
javascript复制const handleClickTopicText = () => {
if (!props.topicDisable) emit('handleClickTopicText')
}
const handleClickContentText = (event) => {
if (!event.target.classList.contains('btn')) {
emit('handleClickContentText')
}
}
const displayAllContent = () => {
state.showAll = !state.showAll
state.collapsedStatus = !state.collapsedStatus
}
css复制.text {
overflow: hidden;
text-overflow: ellipsis;
max-height: v-bind('getMaxHeight() + "px"');
}
.text::before {
content: '';
height: calc(100% - v-bind('props.lineHeight + "px"'));
float: right;
}
.btn::before {
content: '...';
position: absolute;
left: -5px;
transform: translateX(-100%);
}
技巧说明:
::before伪元素创建浮动空间实现多行省略float: right让省略号保持在右侧css复制.exp:checked + .text {
max-height: none;
}
.exp:checked + .text .btn::after {
background-image: url('../assets/arrow-up.png');
}
.exp:checked + .text .btn::before {
visibility: hidden;
}
关键点:
:checked伪类控制展开状态html复制<TextEllipsis
contentText="这里是需要折叠的长文本内容..."
maxLines="3"
/>
html复制<TextEllipsis
:level="8"
:maxLines="3"
topic="#今日话题"
contentText="Vue是一款用于构建用户界面的JavaScript框架..."
:lineHeight="24"
:fontSize="16"
:isTextWhiteColor="true"
@expanded="handleExpand"
@handleClickTopicText="handleTopicClick"
/>
css复制/* 自定义按钮样式 */
.custom-btn::after {
background-image: url('./custom-arrow.png') !important;
}
/* 修改话题标签颜色 */
.topic-text {
color: #ff4757 !important;
}
问题现象:
折叠按钮显示逻辑异常,有时该显示不显示
排查步骤:
lineHeight值与实际行高一致line-heightfontSize参数匹配解决方案:
javascript复制// 添加调试日志
console.log('实际高度:', descHeight, '最大高度:', maxHeight)
问题现象:
省略号不在行末显示,或者与按钮重叠
可能原因:
修复方案:
css复制.wrapper {
width: 100%; /* 确保足够宽度 */
overflow: hidden; /* 创建BFC */
}
.text::before {
width: 40px; /* 固定伪元素宽度 */
}
问题场景:
异步加载内容后折叠状态不正确
解决方案:
javascript复制watch(() => props.contentText, async () => {
await nextTick()
initLayout()
})
javascript复制import { debounce } from 'lodash-es'
const initLayout = debounce(async () => {
// ...
}, 100)
javascript复制const observer = new ResizeObserver(initLayout)
onMounted(() => observer.observe(descRef.value))
onUnmounted(() => observer.disconnect())
javascript复制const heightCache = ref(null)
const getMaxHeight = () => {
if (!heightCache.value) {
heightCache.value = props.lineHeight * props.maxLines + 1
}
return heightCache.value
}
css复制.text {
transition: max-height 0.3s ease;
}
.btn::after {
transition: transform 0.2s ease;
}
.exp:checked + .text .btn::after {
transform: rotate(180deg);
}
javascript复制const props = defineProps({
expandIcon: {
type: String,
default: '../assets/arrow-down.png'
},
collapseIcon: {
type: String,
default: '../assets/arrow-up.png'
}
})
javascript复制const locale = inject('locale')
const btnText = computed(() => ({
expand: locale === 'en' ? 'Expand' : '展开',
collapse: locale === 'en' ? 'Collapse' : '收起'
}))
在实际项目中使用这个组件时,建议根据具体需求选择合适的配置组合。对于性能要求高的场景,可以开启防抖和缓存优化;对于国际化项目,可以接入多语言支持。