前端「标签溢出省略」的挑战和实现

[What]什么是标签溢出省略

在前端开发中,我们经常会用到下拉选择器组件,例如arco-design 的 Select 组件,在多选模式下,可以通过传入形如maxTagCount这样的属性来配置超出多少条数据进行截断展示。

但是在实际工作中,我们会发现maxTagCount并不容易配置,因为它会随着 Select 以及其 Option 的宽度而变化,从而导致以下 2 种(容易让人强迫症发作的)情况:

  • 空间明明是足够的、但是被截断了;
  • 空间不足、但是没有被截断。

自然而然,我们就会思考是否可以提供类似maxTagCount='auto'这样的特性。

根据容器大小,动态调整可见标签的数量,并且把不可见的标签折叠到一个特殊的标签内,这就是「标签溢出省略」

[Why]为什么需要溢出省略

  • 从组件自身的角度看,溢出省略是为了更合理地利用空间、尽可能多地展示标签;
  • 从页面整体的角度看,溢出省略是为了固定某个容器的大小(宽度 or 高度),从而使得页面整体的布局更加美观。

[How]如何实现标签溢出省略

实现溢出省略的挑战在于:

  1. 如何计算可见标签的数量;
  2. 如何降低计算的复杂度。

省略的本质在于计算

首先明确几个概念:

  • 最后一个可见标签的索引:以下简称"max"
  • 标签列表的长度:以下简称"dataLength"
  • 内容为+x、hover展示剩余标签列表的标签:以下简称"+x的特殊标签"

假设我们已经计算得出 max,那么就可以按照既定的策略去渲染标签列表:

  • 索引为 0 ~ 索引为 max-1,正常渲染;
  • 索引为 max:渲染一个特殊标签,内容为+(dataLength - max),hover 在弹出层中渲染剩余的标签列表;
  • 其余标签:不在容器中渲染。

如何计算

从左到右(水平) 排列为例说明计算方式。

渲染标签列表

  1. 在渲染第一个标签的同时,渲染一个不可见的容器(以下简称hidden-list),设置其内部为弹性布局:主轴为水平方向、起点在左侧
  2. 在hidden-list内部,按顺序遍历列表并渲染出所有的标签。

这样做有2️⃣个目的:

  • 能够让hidden-list一直存在于DOM树中,响应标签以及容器的变化;
  • 能够让hidden-list在水平方向上的起点与第一个标签相同。

接下来,我们就可以利用这个hidden-list计算第几个标签超出了容器的宽度。

计算第几个标签超出了容器的宽度

在弹性布局中,我们可以通过flex-wrap来设置内部元素超出一行时是否换行。

因此,我们就会有 2 种方案来判断标签是否超出了容器的宽度。

方案 1:不换行,比较标签的左 偏移量 (offsetLeft)和容器宽度(clientWidth)之间的大小。

遍历hidden-list(从索引为 1 开始),若索引为 x-1 的标签的左偏移量小于容器宽度、索引为 x 的标签的左偏移量大于容器宽度,就能得出 max=x-2。

方案 2:换行,比较标签的顶部偏移量(offsetTop)和第一个标签高度(clientHeight)之间的大小。

遍历hidden-list,若索引为 x 的标签的顶部偏移量大于第一个标签的高度,就能得出 max=x-1。

调整渲染顺序

完成上面 2 个步骤以后,我们就得出了 max 的值,但实际上我们并没有考虑到 +x 这个特殊标签。

如果在计算得出max后尝试用+x替换Label n,就会遇到以下2个问题:

问题1️⃣:+x和Label n的宽度不一定相同,增加了计算的复杂度。

解决方案:避免比较+x和Label n之间的宽度大小,直接把这个特殊标签放到hidden-list的第1位进行渲染。

问题2️⃣:+1和+99的宽度并不相同,增加了计算的复杂度。

解决方案:提前确定+x的宽度,直接把+x 的 x 设置成它的最大值(dataLength)。

完善渲染策略

在第一次渲染时,显然我们还不知道 max 的值,因此我们可以设置 max 的初始值为 undefined。

❗实际上,还有一种非常极端的情况是:hidden-list第一行只能容纳第一个标签。

这种情况下,计算得出 max = 0,也就是说:当 max = 0 时,只需要渲染一个特殊标签即可。

渲染策略总结如下:

  • 索引为 0:

    • 若 max 不为 0,渲染第一个标签以及hidden-list;
    • 否则,渲染特殊标签,内容为+(dataLength - max);
  • 若 max 为 undefined,返回 null;

  • 若索引为 max,渲染特殊标签,内容为+(dataLength - max);

  • 若索引大于 max,返回 null;

  • 其他情况(索引在 0 到 max 之间),正常渲染对应索引的标签即可。

关键代码

在了解了计算方法以后,我们就可以拆分出以下几个关键的函数:

renderFinalItem渲染正常的标签

javascript 复制代码
function renderFinalItem(item) {
  return <span>{item}</span>
}

renderSpecialItem渲染特殊的标签

javascript 复制代码
function renderSpecialItem() {
  return (
    <Popover>
      +{list.length - max}
    </Popover>
  )
}

renderHiddenList渲染不可见的容器(以及标签列表)

javascript 复制代码
function renderHiddenList() {
  return (
    <Space
      ref={hiddenListRef}
      style={{ width: "100%" }}
      className="hidden-list-container"
    >
      <span>+{dataLength}</span>
      {list.map((item) => renderFinalItem(item))}
    </Space>
  );
}

renderList渲染标签列表

javascript 复制代码
function renderList(list) {
  return list.map((item, index) => {
    if (index === 0) {
      if (max === 0) {
        return renderSpecialItem()
      }
      
      return (
        <>
          {renderFinalItem(item)}
          {renderHiddenList()}
        </>
      )
    }
    
    if (typeof max === 'undefined') {
      return null
    }
    
    if (index === max) {
      return renderSpecialItem()
    }
    
    if (index > max) {
      return null
    }
    
    return renderFinalItem(item)
  })
}

compute计算 max 的值(基于方案 2)

scss 复制代码
function compute() {
  if (!hiddenListRef.current) {
    return
  }
  
  const hiddenList = hiddenListRef.current
  const childNodeList = [].slice.call(hiddenList.children)
  const firstChildHeight = childNodeList[0].clientHeight
  
  for(let i = 1; i < childNodeList.length; i++) {
    if (childNodeList[i].offsetTop >= firstChildHeight) {
      const lastVisibleIndex = Math.max(i - 1, 0)
      setMax(lastVisibleIndex)
      return
    }
  }
  
  setMax(list.length)
}

何时计算

  • 初始化时,需要计算;
  • 标签列表变化时,需要计算 -> 监听列表的变化;
  • 容器宽度发生变化时,需要计算 -> 监听容器的 resize 事件。

代码示例如下:

scss 复制代码
export function OverflowItems() {
  const containerRef = useRef()

  useEffect(() => {
    compute()
  }, [ list ])

  useEffect(() => {
    const container = containerRef.current
    if (!container) {
      return
    }
    
    const handleResize = () throttle(() => {
      compute()
    }, 100)
    
    const observer = new ResizeObserver(handleResize)
    
    observer.observe(container)
    
    return () => {
      observer.unobserve(container)
    }
  }, [])
  
  return (
    <Space ref={containerRef}>{renderList()}</Space>
  )
}

🎉让我们用codesandbox来看看最后的效果:

暂时无法在飞书文档外展示此内容

拓展思考

从上到下(竖直)排列

其实思路是完全一致的,我们依然有 2 种选择:

方案1:不换行,比较标签的****顶部偏移量(offsetTop) 与****容器高度(clientHeight) 之间的大小。

方案2:换行,比较标签的****左偏移量(offsetLeft) 与第一个****标签宽度(clientWidth) 之间的大小。

超出 n 行再省略

在文本溢出省略的场景中,默认都是在"超出 1 行"时进行省略,但在arco-design 的 Typography 组件中,我们还可以通过rows来配置超出多少行进行省略:

那在标签溢出省略中,基于方案 2(换行排列),我们也可以支持类似的特性。

以"超出 2 行再省略"为例:

  • 记录第一行第一个标签的高度(firstRowHeight);
  • 记录第二行第一个标签的高度(secondRowHeight);
  • 若索引为 x 的标签的顶部偏移量大于 firstRowHeight 和 secondRowHeight 之和,就能得出 max = x - 1。

总结

前端「标签溢出省略」的挑战和实现总结如下:

  1. 如何计算可见标签的数量?

通过渲染一个不可见的标签列表hidden-list,提前确定每个标签的位置,再通过比较标签和容器的相对位置来计算可见标签的数量。

  1. 如何降低计算的复杂度?

在渲染hidden-list时,通过以下2个手段来降低计算的复杂度:

  • 把+x放到列表第1位,从而避免+x和Label n的宽度比较;
  • 把x设置成它的最大值(dataLength),从而提前确定+x的宽度。
相关推荐
余生H5 分钟前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍8 分钟前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai13 分钟前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默24 分钟前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
2401_8572979135 分钟前
招联金融2025校招内推
java·前端·算法·金融·求职招聘
茶卡盐佑星_1 小时前
meta标签作用/SEO优化
前端·javascript·html
Ink1 小时前
从底层看 path.resolve 实现
前端·node.js
金灰1 小时前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5
茶卡盐佑星_1 小时前
说说你对es6中promise的理解?
前端·ecmascript·es6
Promise5201 小时前
总结汇总小工具
前端·javascript