多行文本截断组件

多行文本截断组件(TextTruncation)

1. 组件概述

1.1 开发背景

在前端开发中,文本显示常面临空间限制内容完整性平衡的问题:

  • 原生 CSS 仅支持单行文本截断(text-overflow: ellipsis),无法满足多行场景(如卡片描述、评论摘要);
  • 部分场景需在文本末尾预留空间放置额外元素(如 "查看更多" 按钮、标签、图标),常规截断方案无法适配;
  • 窗口缩放或容器尺寸变化时,文本需实时重新计算截断位置,确保布局不错乱;
  • 中英文混排时字符宽度差异大,简单按字符数截断易导致布局偏移。

基于上述痛点,开发了此支持多行限制 + 预留宽度的文本截断组件,解决复杂场景下的文本自适应显示问题。

1.2 核心用途

适用于需精确控制文本显示范围,并预留空间放置额外元素的场景,例如:

  • 商品卡片:文本描述限制 2 行,右侧预留 "加入购物车" 按钮空间;
  • 评论列表:评论内容限制 3 行,末尾预留 "点赞" 图标空间;
  • 列表项:标题限制 1 行,右侧预留 "编辑 / 删除" 操作按钮空间;
  • 信息卡片:摘要文本限制 2 行,底部预留 "展开" 按钮空间。

1.3 核心功能

功能点 描述
多行文本截断 支持自定义最大行数(maxLines),超出行数时自动在最后一行添加省略号
预留宽度适配 支持自定义预留宽度(reservedWidth),为额外元素预留空间
实时响应式 监听容器尺寸变化(如窗口缩放),动态重新计算截断位置
中英文兼容 以中文字符 "中" 为基准测量宽度,适配中英文混排场景
占位元素插槽 提供 placeholder 插槽,用于放置预留空间的额外元素(如按钮、图标)

2. 实现思路解析

组件核心逻辑围绕 "文本尺寸测量→截断规则计算→实时更新" 三步展开,以下是关键模块的设计思路:

2.1 整体结构设计

xml 复制代码
<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>
  • 外层 text-container:作为组件容器,确保宽度 100% 适配父元素;
  • 内层 text-content:文本显示核心容器,通过 position: relative 实现占位元素的绝对定位;
  • placeholder-box:预留空间的占位容器,通过绝对定位(bottom: 0; right: 0)固定在文本末尾;
  • placeholder 插槽:允许用户自定义预留空间的内容(如按钮、图标),增强组件灵活性。

2.2 核心逻辑模块

2.2.1 文本尺寸测量(calculateTextMetrics)

作用:精确测量文本的宽高、边界框信息,并同步设置占位元素的样式。

实现细节

  1. 使用 canvas 测量文本尺寸(比 DOM 测量性能更优,且支持离线计算);
  1. 通过 window.getComputedStyle 获取 text-content 的实际字体样式(字体大小、权重、家族),确保测量结果与实际渲染一致;
  1. 以中文字符 "中" 为基准计算单个字符宽度,适配中英文混排场景;
  1. 同步设置 placeholder-box 的宽高(宽 = 预留宽度,高 = 文本行高)和垂直位置(bottom 基于文本基线偏移),确保占位元素与文本对齐。
ini 复制代码
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 // 文本下边界偏移
  };
};
2.2.2 文本截断计算(updateDisplayedText)

作用:根据容器尺寸、最大行数、预留宽度,计算最终显示的文本(含省略号),是组件的核心逻辑。

实现步骤

  1. 基础参数计算
    • 容器宽度 = text-content 的实际宽度;
    • 可用宽度 = 容器宽度 - 预留宽度(reservedWidth);
    • 每行最大字符数 = 可用宽度 / 单个字符宽度(确保非负);
    • 文本总行数 = 总文本宽度 / 容器宽度(向上取整,不考虑最大行数限制)。
  1. 逐行截取文本
    • 循环处理文本,直到处理完所有文本或达到最大行数(maxLines);
    • 每行截取字符数 = 容器宽度 / 单个字符宽度(确保文本不超出容器宽度);
    • 若为最后一行且文本未处理完:截取时预留 3 个字符位置给省略号(...),避免省略号超出预留空间。
  1. 最终文本组装
    • 多行文本:仅截断最后一行,前 maxLines-1 行保持完整;
    • 单行文本:直接按最大字符数截断并添加省略号;
    • 文本未超出限制:完整显示原文本。
ini 复制代码
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;
  }
};
2.2.3 实时更新与监听

为确保文本在动态场景下正常显示,组件添加了三类监听:

  1. 组件挂载时初始化:onMounted 中调用 updateDisplayedText,确保初始渲染正确;
  1. 容器尺寸变化监听:使用 ResizeObserver 监听 text-content 的尺寸变化(如窗口缩放、父容器宽度调整),实时重新计算截断;
  1. Props 变化监听:通过 watch 监听 text(文本内容)和 reservedWidth(预留宽度)的变化,动态更新显示。
scss 复制代码
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);

3. 完整组件代码

xml 复制代码
<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>

4. 使用案例

以下是 3 个典型场景的使用示例,覆盖不同需求场景。

案例 1:商品卡片(多行文本 + 右侧按钮)

需求:商品描述限制 2 行,右侧预留 80px 空间放置 "加入购物车" 按钮。

xml 复制代码
<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>

案例 2:评论列表(多行文本 + 点赞图标)

需求:评论内容限制 3 行,末尾预留 30px 空间放置点赞图标。

xml 复制代码
<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>

案例 3:单行标题 + 编辑按钮

需求:列表标题限制 1 行,右侧预留 60px 空间放置 "编辑" 按钮。

xml 复制代码
<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>

5. 注意事项

  1. 预留宽度准确性:reservedWidth 需与占位元素的实际宽度一致(如按钮宽 80px,则 reservedWidth=80),否则可能导致文本与占位元素重叠;
  1. 字体样式一致性:组件基于 text-content 的实际字体样式测量文本,若父组件修改了字体(如 font-size、font-family),需确保样式能传递到 text-content;
  1. 兼容性 :ResizeObserver 兼容 Chrome 64+、Firefox 69+、Edge 79+,若需兼容低版本浏览器,需引入 ResizeObserver polyfill
  1. 性能优化:组件使用 canvas 测量文本(性能优于 DOM 测量),但频繁更新 text 或 reservedWidth 时,建议添加防抖(如 lodash.debounce),避免多次计算。

6. 实现效果

相关推荐
Verlif4 小时前
Vue3项目使用PWA技术进行离线加载
vue.js·pwa
通往曙光的路上5 小时前
vue啊哈哈哈哈哈哈哈哈
前端·javascript·vue.js
fouryears_234175 小时前
如何将Vue 项目转换为 Android App(使用Capacitor)
android·前端·vue.js
小高0076 小时前
🤔Proxy 到底比 defineProperty 强在哪?为什么今天还在聊 Proxy?
前端·javascript·vue.js
FuckPatience6 小时前
电脑所有可用的网络接口
前端·javascript·vue.js
前端开发爱好者7 小时前
尤雨溪宣布:Vite 纪录片震撼发布!
前端·javascript·vue.js
科兴第一吴彦祖7 小时前
基于Spring Boot + Vue 3的乡村振兴综合服务平台
java·vue.js·人工智能·spring boot·推荐算法
Man9 小时前
🔥 Vue3 动态 ref 黑科技:一招解决 v-for 中的组件引用难题!
前端·vue.js
叫我少年9 小时前
Vue3 集成 VueRouter
vue.js