轻云听书app
17.87MB · 2025-10-15
在前端开发中,文本显示常面临空间限制与内容完整性平衡的问题:
基于上述痛点,开发了此支持多行限制 + 预留宽度的文本截断组件,解决复杂场景下的文本自适应显示问题。
适用于需精确控制文本显示范围,并预留空间放置额外元素的场景,例如:
功能点 | 描述 |
---|---|
多行文本截断 | 支持自定义最大行数(maxLines),超出行数时自动在最后一行添加省略号 |
预留宽度适配 | 支持自定义预留宽度(reservedWidth),为额外元素预留空间 |
实时响应式 | 监听容器尺寸变化(如窗口缩放),动态重新计算截断位置 |
中英文兼容 | 以中文字符 “中” 为基准测量宽度,适配中英文混排场景 |
占位元素插槽 | 提供 placeholder 插槽,用于放置预留空间的额外元素(如按钮、图标) |
组件核心逻辑围绕 “文本尺寸测量→截断规则计算→实时更新” 三步展开,以下是关键模块的设计思路:
<template>
<div class="text-container" ref="container">
<!-- 文本显示容器:承载截断后的文本 -->
<div class="text-content" id="textContent">
{{ displayedText }}
<!-- 预留空间占位符:仅当reservedWidth>0时显示 -->
<div class="placeholder-box" id="placeholderBox" v-if="reservedWidth">
<slot name="placeholder"></slot> <!-- 插槽:放置额外元素 -->
</div>
</div>
</div>
</template>
作用:精确测量文本的宽高、边界框信息,并同步设置占位元素的样式。
实现细节:
const calculateTextMetrics = (text) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 获取实际字体样式
const textContent = document.getElementById('textContent');
const styles = window.getComputedStyle(textContent);
const font = `${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
context.font = font;
const metrics = context.measureText(text);
// 同步设置占位元素样式
const placeholderBox = document.getElementById('placeholderBox');
if (placeholderBox) {
placeholderBox.style.width = `${props.reservedWidth}px`;
placeholderBox.style.height = `${parseInt(styles.fontSize, 10)}px`;
placeholderBox.style.bottom = `${metrics.actualBoundingBoxDescent}px`;
}
return {
width: metrics.width, // 文本宽度
height: parseInt(styles.fontSize, 10), // 文本行高
actualBoundingBoxAscent: metrics.actualBoundingBoxAscent, // 文本上边界偏移
actualBoundingBoxDescent: metrics.actualBoundingBoxDescent // 文本下边界偏移
};
};
作用:根据容器尺寸、最大行数、预留宽度,计算最终显示的文本(含省略号),是组件的核心逻辑。
实现步骤:
const updateDisplayedText = () => {
const textContent = document.getElementById('textContent');
if (!textContent) return;
// 1. 计算基础参数
const containerWidth = textContent.offsetWidth;
const { width: charWidth } = calculateTextMetrics('中'); // 基准字符宽度
const availableWidth = containerWidth - props.reservedWidth;
const maxChars = Math.max(0, Math.floor(availableWidth / charWidth)); // 每行最大可显字符
const totalLineCount = Math.ceil(props.text.length * charWidth / containerWidth); // 文本总行数
// 2. 逐行截取文本(按maxLines限制)
let lines = [];
let currentLine = 0;
let startIndex = 0;
while (startIndex < props.text.length && currentLine < props.maxLines) {
const charsPerLine = Math.floor(containerWidth / charWidth); // 每行可显字符(无预留)
const endIndex = Math.min(startIndex + charsPerLine, props.text.length);
// 最后一行且文本未处理完:添加省略号
if (currentLine === props.maxLines - 1 && endIndex < props.text.length) {
lines.push(props.text.substring(startIndex, startIndex + Math.max(0, charsPerLine - 3)) + '...');
break;
}
lines.push(props.text.substring(startIndex, endIndex));
startIndex = endIndex;
currentLine++;
}
// 3. 处理预留宽度的字符限制(最后一行不超出可用宽度)
if (totalLineCount > 1) {
const lastLine = lines[lines.length - 1];
if (lastLine.length > maxChars) {
lines[lines.length - 1] = lastLine.substring(0, Math.max(0, maxChars - 3)) + '...';
}
displayedText.value = lines.join('n');
} else if (props.text.length > maxChars) {
displayedText.value = props.text.substring(0, Math.max(0, maxChars - 3)) + '...';
} else {
displayedText.value = props.text;
}
};
为确保文本在动态场景下正常显示,组件添加了三类监听:
let resizeObserver;
onMounted(() => {
updateDisplayedText();
// 监听容器尺寸变化
resizeObserver = new ResizeObserver(() => updateDisplayedText());
const textContent = document.getElementById('textContent');
if (textContent) resizeObserver.observe(textContent);
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect(); // 销毁监听,避免内存泄漏
});
// 监听Props变化
watch(() => props.text, updateDisplayedText);
watch(() => props.reservedWidth, updateDisplayedText);
<template>
<div class="text-container" ref="container">
<div class="text-content" id="textContent">
{{ displayedText }}
<!-- 剩余空间占位符:仅当reservedWidth>0时显示 -->
<div class="placeholder-box" id="placeholderBox" v-if="reservedWidth">
<slot name="placeholder"></slot> <!-- 插槽:放置额外元素(按钮、图标等) -->
</div>
</div>
</div>
</template>
<script setup>
/**
* 文本截断组件,支持以下功能:
* 1. 根据容器宽度和预留宽度自动计算可显示字符数
* 2. 支持最大行数限制(maxLines)
* 3. 实时响应容器尺寸变化
* 4. 正确处理中英文字符宽度差异
*/
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({
/** 需显示的原始文本 */
text: {
type: String,
required: true
},
/** 预留宽度(px):为额外元素(如按钮)预留的空间 */
reservedWidth: {
type: Number,
default: 0,
required: true
},
/** 最大显示行数 */
maxLines: {
type: Number,
default: 2
}
});
const container = ref(null);
const displayedText = ref(props.text); // 最终显示的截断文本
/**
* 计算文本度量信息
* @param {string} text - 要测量的文本
* @returns {object} 包含宽度、高度和边界框信息的对象
* 注意:使用中文字符'中'作为基准测量字符宽度,适配中英文混排
*/
const calculateTextMetrics = (text) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 获取实际应用的字体样式(确保测量与渲染一致)
const textContent = document.getElementById('textContent');
const styles = window.getComputedStyle(textContent);
const font = `${styles.fontWeight} ${styles.fontSize} ${styles.fontFamily}`;
context.font = font;
const metrics = context.measureText(text);
// 同步设置占位符样式(宽=预留宽度,高=文本行高,垂直对齐文本基线)
const placeholderBox = document.getElementById('placeholderBox');
if (placeholderBox) {
placeholderBox.style.width = `${props.reservedWidth}px`;
placeholderBox.style.height = `${parseInt(styles.fontSize, 10)}px`;
placeholderBox.style.bottom = `${metrics.actualBoundingBoxDescent}px`;
}
return {
width: metrics.width, // 文本宽度(px)
height: parseInt(styles.fontSize, 10), // 文本行高(px)
actualBoundingBoxAscent: metrics.actualBoundingBoxAscent, // 文本上边界到基线的距离
actualBoundingBoxDescent: metrics.actualBoundingBoxDescent // 文本下边界到基线的距离
};
};
/**
* 更新显示的文本内容,核心截断逻辑
* 处理逻辑:
* 1. 计算每行可显示字符数(基于容器宽度-预留宽度)
* 2. 按maxLines限制逐行截取文本
* 3. 最后一行超出时添加省略号,前n-1行保持完整
*/
const updateDisplayedText = () => {
const textContent = document.getElementById('textContent');
if (!textContent) return;
// 1. 计算基础参数
const containerWidth = textContent.offsetWidth;
const { width: charWidth } = calculateTextMetrics('中'); // 基准字符宽度
const availableWidth = containerWidth - props.reservedWidth;
const maxChars = Math.max(0, Math.floor(availableWidth / charWidth)); // 预留后每行最大可显字符
const totalLineCount = Math.ceil(props.text.length * charWidth / containerWidth); // 文本总行数(无限制时)
// 2. 按maxLines逐行截取文本
let lines = [];
let currentLine = 0;
let startIndex = 0;
while (startIndex < props.text.length && currentLine < props.maxLines) {
const charsPerLine = Math.floor(containerWidth / charWidth); // 每行可显字符(无预留)
const endIndex = Math.min(startIndex + charsPerLine, props.text.length);
// 最后一行且文本未处理完:预留3个字符位置给省略号
if (currentLine === props.maxLines - 1 && endIndex < props.text.length) {
lines.push(props.text.substring(startIndex, startIndex + Math.max(0, charsPerLine - 3)) + '...');
break;
}
lines.push(props.text.substring(startIndex, endIndex));
startIndex = endIndex;
currentLine++;
}
// 3. 处理预留宽度的字符限制(确保最后一行不超出可用宽度)
if (totalLineCount > 1) {
const lastLine = lines[lines.length - 1];
if (lastLine.length > maxChars) {
lines[lines.length - 1] = lastLine.substring(0, Math.max(0, maxChars - 3)) + '...';
}
displayedText.value = lines.join('n');
} else if (props.text.length > maxChars) {
displayedText.value = props.text.substring(0, Math.max(0, maxChars - 3)) + '...';
} else {
displayedText.value = props.text; // 文本未超出限制,完整显示
}
};
let resizeObserver; // 监听容器尺寸变化的实例
onMounted(() => {
updateDisplayedText(); // 初始化文本显示
// 监听容器尺寸变化(如窗口缩放、父容器宽度调整)
resizeObserver = new ResizeObserver(() => updateDisplayedText());
const textContent = document.getElementById('textContent');
if (textContent) resizeObserver.observe(textContent);
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect(); // 销毁监听,避免内存泄漏
});
// 监听Props变化,动态更新文本
watch(() => props.text, updateDisplayedText);
watch(() => props.reservedWidth, updateDisplayedText);
</script>
<style scoped>
.text-container {
width: 100%; /* 适配父元素宽度 */
}
.text-content {
white-space: pre-line; /* 保留换行符,自动换行 */
word-break: break-all; /* 单词超出时强制换行,避免横向溢出 */
position: relative; /* 为占位元素提供绝对定位上下文 */
}
.placeholder-box {
min-height: 12px; /* 确保占位元素不塌陷 */
background-color: transparent; /* 默认透明,用户可自定义 */
position: absolute; /* 固定在文本末尾 */
bottom: 0;
right: 0;
/* 注意:宽度由JS动态设置(等于reservedWidth) */
}
</style>
以下是 3 个典型场景的使用示例,覆盖不同需求场景。
需求:商品描述限制 2 行,右侧预留 80px 空间放置 “加入购物车” 按钮。
<template>
<div class="product-card">
<img src="/product.jpg" alt="商品图片" class="product-img">
<h3 class="product-title">2024夏季新款纯棉T恤</h3>
<!-- 文本截断组件:text=商品描述,reservedWidth=按钮宽度,maxLines=2 -->
<TextTruncation
:text="productDesc"
:reservedWidth="80"
:maxLines="2"
>
<!-- 占位插槽:放置“加入购物车”按钮 -->
<template #placeholder>
<button class="add-cart-btn">加入购物车</button>
</template>
</TextTruncation>
<div class="product-price">¥99.00</div>
</div>
</template>
<script setup>
import TextTruncation from './TextTruncation.vue';
// 商品描述(长文本)
const productDesc = "2024夏季新款纯棉T恤,精选新疆长绒棉,柔软亲肤,透气吸汗,多种颜色可选,适合日常穿搭、通勤、约会等多种场景,尺码齐全(S-XXL),洗后不褪色、不变形。";
</script>
<style scoped>
.product-card {
width: 300px;
border: 1px solid #eee;
border-radius: 8px;
padding: 16px;
}
.product-img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
}
.product-title {
font-size: 16px;
margin: 8px 0;
}
.add-cart-btn {
width: 80px;
height: 24px;
font-size: 12px;
background: #ff4400;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.product-price {
color: #ff4400;
font-size: 18px;
margin-top: 8px;
}
</style>
需求:评论内容限制 3 行,末尾预留 30px 空间放置点赞图标。
<template>
<div class="comment-list">
<div class="comment-item" v-for="(comment, idx) in comments" :key="idx">
<img src="/avatar.jpg" alt="用户头像" class="user-avatar">
<div class="comment-content">
<div class="user-name">用户{{ idx + 1 }}</div>
<!-- 文本截断组件:text=评论内容,reservedWidth=图标宽度,maxLines=3 -->
<TextTruncation
:text="comment.content"
:reservedWidth="30"
:maxLines="3"
>
<!-- 占位插槽:放置点赞图标 -->
<template #placeholder>
<div class="like-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#999">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span class="like-count">{{ comment.likeCount }}</span>
</div>
</template>
</TextTruncation>
</div>
</div>
</div>
</template>
<script setup>
import TextTruncation from './TextTruncation.vue';
// 评论数据
const comments = [
{
content: "这款产品真的超好用!之前买过类似的,但是这个的质量明显更好,而且价格也很实惠,推荐给大家!已经用了一周了,没有任何问题,下次还会回购。",
likeCount: 24
},
{
content: "第一次购买,体验不错,物流很快,包装也很严实,打开后没有损坏。使用起来很方便,操作简单,新手也能快速上手。唯一的小缺点是颜色比图片稍深一点,但不影响使用。",
likeCount: 18
}
];
</script>
<style scoped>
.comment-list {
width: 600px;
margin: 0 auto;
}
.comment-item {
display: flex;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #eee;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.user-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.like-icon {
display: flex;
align-items: center;
gap: 4px;
color: #999;
font-size: 12px;
}
</style>
需求:列表标题限制 1 行,右侧预留 60px 空间放置 “编辑” 按钮。
<template>
<div class="setting-list">
<div class="setting-item" v-for="(item, idx) in settings" :key="idx">
<!-- 文本截断组件:text=标题,reservedWidth=按钮宽度,maxLines=1 -->
<TextTruncation
:text="item.title"
:reservedWidth="60"
:maxLines="1"
>
<!-- 占位插槽:放置“编辑”按钮 -->
<template #placeholder>
<button class="edit-btn">编辑</button>
</template>
</TextTruncation>
</div>
</div>
</template>
<script setup>
import TextTruncation from './TextTruncation.vue';
// 设置项数据
const settings = [
{ title: "账号与安全设置(包含密码修改、手机绑定、邮箱验证)" },
{ title: "通知设置(消息推送、邮件通知、短信提醒)" },
{ title: "隐私设置(个人信息可见范围、第三方授权管理)" }
];
</script>
<style scoped>
.setting-list {
width: 400px;
border: 1px solid #eee;
border-radius: 8px;
margin: 20px auto;
}
.setting-item {
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.setting-item:last-child {
border-bottom: none;
}
.edit-btn {
width: 60px;
height: 24px;
font-size: 12px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
</style>
“李鬼”再现: PlayStation 商店惊现《黑神话:悟空》山寨游戏
23000Pa 吸力:小米米家扫地机器人 5 Pro 薄嵌上下水版国补后 2931 元