135 Vue 卡片标签溢出处理:如何用真实 DOM 宽度实现“显示部分标签 +N

Vue 卡片标签溢出处理:如何用真实 DOM 宽度实现“显示部分标签 +N”

在这里插入图片描述

在前端页面里,卡片上经常会展示一组标签,比如任务类型、风险等级、状态来源等。

常见需求是:当标签很多或者标签文本很长时,不希望直接粗暴截断,而是希望完整显示能放下的标签,剩余标签用 +N 表示。

例如:

[违建] [疑似占地] [待核查] [高风险]

如果卡片宽度不够,不是显示成:

[违建] [疑似占...]

而是显示成:

[违建] [疑似占地] [+2]

鼠标移到 +2 上时,再通过 tooltip 展示完整标签列表。

这篇文章记录一种比较精确、稳定的实现思路:通过隐藏 DOM 实际测量标签宽度,再计算当前容器最多能完整放下几个标签。

一、为什么不能只靠 CSS 截断?

最简单的方式可能是:

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

这种写法适合单行文本,但不太适合一组标签。

因为标签不是普通文本,它们通常包含:

  • 左右 padding
  • border
  • border-radius
  • 不同字体大小
  • 不同字数
  • 标签之间的 gap
  • +N 这个额外元素

如果只靠 CSS 截断,容易出现下面这些问题:

  1. 标签被截成半个,不完整。
  2. 看不出到底隐藏了几个标签。
  3. 无法配合 tooltip 展示完整列表。
  4. 不同长度标签下显示效果不可控。

所以更好的方式是:先计算能显示几个标签,再决定渲染哪些标签。

二、整体实现思路

这个方案的核心是准备两套标签 DOM。

第一套是真正展示给用户看的标签区域:

<div ref="tagsContainerRef" class="card-tags">
  <a-tag v-for="tag in visibleTags" :key="tag">
    {{ tag }}
  </a-tag>

  <a-tooltip v-if="overflowTagCount > 0">
    <a-tag>+{{ overflowTagCount }}</a-tag>
  </a-tooltip>
</div>

第二套是隐藏起来专门测量宽度的标签区域:

<div ref="tagMeasureRef" class="tag-measure" aria-hidden="true">
  <a-tag
    v-for="tag in item.tags"
    :key="tag"
    data-measure-tag
  >
    {{ tag }}
  </a-tag>

  <a-tag
    v-for="count in item.tags.length"
    :key="count"
    :data-more-count="count"
  >
    +{{ count }}
  </a-tag>
</div>

隐藏测量区的 CSS 大致是:

.tag-measure {
  position: absolute;
  z-index: -1;
  visibility: hidden;
  pointer-events: none;
}

这里要注意:不能用 display: none

因为 display: none 的元素不会参与布局,拿不到真实宽度。而 visibility: hidden 虽然看不见,但浏览器仍然会正常布局,所以可以通过 offsetWidth 获取真实宽度。

三、核心状态:visibleTagCount

我们需要一个状态记录当前可以显示几个标签:

const visibleTagCount = ref(props.item.tags.length);

然后根据它计算真正展示的标签:

const visibleTags = computed(() =>
  props.item.tags.slice(0, visibleTagCount.value)
);

再计算隐藏了几个标签:

const overflowTagCount = computed(
  () => props.item.tags.length - visibleTagCount.value
);

举个例子:

props.item.tags = ['违建', '疑似占地', '待核查', '高风险'];
visibleTagCount.value = 2;

那么:

visibleTags = ['违建', '疑似占地'];
overflowTagCount = 2;

最终界面显示:

[违建] [疑似占地] [+2]

四、计算容器可用宽度

第一步是获取标签容器真正可用的宽度。

const getAvailableTagsWidth = () => {
  const container = tagsContainerRef.value;

  if (!container) {
    return 0;
  }

  const style = window.getComputedStyle(container);
  const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
  const paddingRight = Number.parseFloat(style.paddingRight) || 0;

  return container.clientWidth - paddingLeft - paddingRight;
};

为什么要减掉 padding?

因为 clientWidth 包含容器的左右 padding。

假设容器宽度是 328px,左右 padding 各 16px,那么标签真正可用的宽度是:

328 - 16 - 16 = 296

如果不减掉 padding,算法会误以为空间更大,最终可能导致标签溢出。

五、计算标签之间的间距 gap

标签通常是 flex 布局,并且有 gap:

.card-tags {
  display: flex;
  gap: 8px;
}

所以计算一排标签总宽度时,不能只加标签自身宽度,还要加标签之间的 gap。

const getTagsGap = () => {
  const container = tagsContainerRef.value;

  if (!container) {
    return 0;
  }

  const style = window.getComputedStyle(container);
  return Number.parseFloat(style.columnGap || style.gap) || 0;
};

六、计算一排元素总宽度

有了标签宽度和 gap,就可以计算一排标签的总宽度。

const getRowWidth = (widths: number[], gap: number) =>
  widths.reduce((total, width) => total + width, 0) +
  Math.max(widths.length - 1, 0) * gap;

比如有 3 个标签:

标签宽度:60, 80, 50
gap:8

总宽度不是:

60 + 80 + 50 = 190

而是:

60 + 8 + 80 + 8 + 50 = 206

也就是:

标签总宽度 + (标签数量 - 1) * gap

七、核心算法:从多到少尝试能显示几个标签

真正决定显示几个标签的函数大致如下:

const updateVisibleTags = async () => {
  await nextTick();

  const tagCount = props.item.tags.length;
  const measureRoot = tagMeasureRef.value;
  const availableWidth = getAvailableTagsWidth();

  if (!tagCount || !measureRoot || !availableWidth) {
    visibleTagCount.value = tagCount;
    return;
  }

  const gap = getTagsGap();

  const tagWidths = Array.from(
    measureRoot.querySelectorAll<HTMLElement>('[data-measure-tag]')
  ).map((tag) => tag.offsetWidth);

  if (getRowWidth(tagWidths, gap) <= availableWidth) {
    visibleTagCount.value = tagCount;
    return;
  }

  for (let count = tagCount - 1; count >= 0; count -= 1) {
    const hiddenCount = tagCount - count;

    const moreTag = measureRoot.querySelector<HTMLElement>(
      `[data-more-count="${hiddenCount}"]`
    );

    const visibleWidths = tagWidths.slice(0, count);

    const rowItemsWidth = moreTag
      ? [...visibleWidths, moreTag.offsetWidth]
      : visibleWidths;

    if (getRowWidth(rowItemsWidth, gap) <= availableWidth) {
      visibleTagCount.value = count;
      return;
    }
  }

  visibleTagCount.value = 0;
};

这段逻辑可以拆成几步理解。

八、为什么要用 nextTick?

函数开头有一行:

await nextTick();

这是因为 Vue 的数据更新和 DOM 更新不是完全同步的。

如果标签数据刚变化,马上去测量 DOM,可能 DOM 还没更新完成。这时拿到的 offsetWidth 就可能是旧的,甚至拿不到元素。

nextTick() 的作用是:等 Vue 把本轮 DOM 更新完成之后,再执行后面的测量逻辑。

九、先判断全部标签能不能放下

先拿到所有标签的真实宽度:

const tagWidths = Array.from(
  measureRoot.querySelectorAll<HTMLElement>('[data-measure-tag]')
).map((tag) => tag.offsetWidth);

假设标签宽度是:

tagWidths = [48, 82, 60, 64];

然后判断全部标签加起来能不能放进容器:

if (getRowWidth(tagWidths, gap) <= availableWidth) {
  visibleTagCount.value = tagCount;
  return;
}

如果能放下,就显示全部标签,不需要出现 +N

十、如果放不下,就从多到少尝试

如果全部标签放不下,就进入循环:

for (let count = tagCount - 1; count >= 0; count -= 1) {
  // ...
}

假设总共有 4 个标签。

算法会依次尝试:

显示 3 个标签 +1
显示 2 个标签 +2
显示 1 个标签 +3
显示 0 个标签 +4

注意:这里不是只算可见标签的宽度,还要把 +N 标签本身的宽度也算进去。

例如显示 2 个,隐藏 2 个时,实际宽度应该是:

[标签1] [标签2] [+2]

所以代码里会取出对应的 +2 标签:

const moreTag = measureRoot.querySelector<HTMLElement>(
  `[data-more-count="${hiddenCount}"]`
);

再把它的宽度也加入计算:

const rowItemsWidth = moreTag
  ? [...visibleWidths, moreTag.offsetWidth]
  : visibleWidths;

只要某一次能放下,就更新:

visibleTagCount.value = count;
return;

这说明已经找到当前容器里最多能完整显示的标签数量。

十一、为什么要提前渲染所有 +N 标签?

测量区域里有这段:

<a-tag
  v-for="count in item.tags.length"
  :key="count"
  :data-more-count="count"
>
  +{{ count }}
</a-tag>

它会提前渲染:

+1
+2
+3
+4
...

这么做是为了测量 +N 的真实宽度。

因为 +9+10+100 的宽度可能是不一样的。而且一个 tag 组件内部还有 padding、字体、line-height 等样式。

如果靠手动估算字符宽度,很容易不准确。直接让浏览器渲染,再用 offsetWidth 测量,是最稳的方式。

十二、完整推演示例

假设容器可用宽度是:

availableWidth = 180

标签宽度是:

标签1 = 50
标签2 = 70
标签3 = 80
标签4 = 60
gap = 8

全部显示需要:

50 + 8 + 70 + 8 + 80 + 8 + 60 = 284

284 大于 180,放不下。

于是开始尝试。

尝试 1:显示 3 个,隐藏 1 个

[标签1] [标签2] [标签3] [+1]

假设 +1 宽度是 38:

50 + 8 + 70 + 8 + 80 + 8 + 38 = 262

262 大于 180,还是放不下。

尝试 2:显示 2 个,隐藏 2 个

[标签1] [标签2] [+2]

假设 +2 宽度是 38:

50 + 8 + 70 + 8 + 38 = 174

174 小于 180,放得下。

于是最终设置:

visibleTagCount.value = 2;

界面最终显示:

[标签1] [标签2] [+2]

十三、监听容器宽度变化:ResizeObserver

卡片宽度可能发生变化,比如:

  • 浏览器窗口变窄
  • 父级布局变化
  • 侧边栏展开或收起
  • 响应式布局调整

所以组件在挂载后会监听标签容器尺寸变化:

onMounted(() => {
  updateVisibleTags();

  if (tagsContainerRef.value) {
    resizeObserver = new ResizeObserver(() => {
      updateVisibleTags();
    });

    resizeObserver.observe(tagsContainerRef.value);
  }
});

ResizeObserver 是浏览器提供的 API,用来监听 DOM 元素尺寸变化。

当容器宽度变化时,重新执行 updateVisibleTags(),界面就能重新计算应该显示几个标签。

组件卸载时要记得断开监听:

onBeforeUnmount(() => {
  resizeObserver?.disconnect();
});

这一步可以避免组件销毁后仍然保留无用监听。

十四、监听标签数据变化

如果标签数据本身变化了,也需要重新计算。

watch(
  () => props.item.tags,
  () => {
    visibleTagCount.value = props.item.tags.length;
    updateVisibleTags();
  },
  { deep: true }
);

这里先把 visibleTagCount 重置成标签总数:

visibleTagCount.value = props.item.tags.length;

相当于先假设全部显示。

然后再执行测量:

updateVisibleTags();

这样可以避免旧的显示数量影响新的计算。

十五、这个方案的优点

这个实现比普通 CSS 截断更稳定,主要优点有:

  1. 标签不会被截成半个。
  2. 可以准确显示隐藏数量,比如 +2+5
  3. 可以配合 tooltip 展示完整标签列表。
  4. 不需要手动估算文字宽度。
  5. 能适配不同字体、padding、组件样式。
  6. 容器宽度变化时可以重新计算。
  7. 标签数据变化时也能自动更新。

十六、需要注意的点

这个方案虽然精确,但也有一些注意事项。

1. 测量元素不能用 display: none

如果使用:

display: none;

元素不会参与布局,offsetWidth 会是 0。

应该使用:

visibility: hidden;

2. 要等 DOM 更新后再测量

Vue 中建议使用:

await nextTick();

否则可能测到旧 DOM。

3. 要把 +N 的宽度也算进去

很多实现容易漏掉这一点。如果只计算可见标签宽度,不计算 +N 宽度,最终还是可能溢出。

4. 要考虑 gap 和 padding

容器 padding、标签 gap 都会影响最终宽度。如果漏算,视觉上可能出现一点点溢出。

5. 组件卸载时断开 ResizeObserver

使用 ResizeObserver 后,最好在组件卸载时调用:

resizeObserver?.disconnect();

十七、总结

这个标签溢出方案的核心思想是:

先隐藏渲染所有标签和所有 +N 标签
再通过 offsetWidth 获取真实像素宽度
然后从多到少尝试:显示几个标签 + 一个 +N 是否能放进容器
找到能放下的最大数量
最后只渲染这些标签

它不是“显示后再粗暴裁剪”,而是“渲染前先算清楚应该显示什么”。

这种方式非常适合:

  • 卡片列表
  • 文件标签
  • 任务标签
  • 筛选条件摘要
  • 用户画像标签
  • 管理台数据概览

尤其是在后台系统、运营台、数据平台这类界面里,它能让标签展示更稳定、更清晰,也更接近真实产品需求。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值