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

[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的宽度。
相关推荐
盗德9 小时前
为什么要用Monorepo管理前端项目?(详解)
前端·架构·代码规范
五号厂房9 小时前
ProTable 大数据渲染优化:实现高性能表格编辑
前端
右子9 小时前
理解响应式设计—理念、实践与常见误解
前端·后端·响应式设计
KaiSonng9 小时前
【前端利器】这款轻量级图片标注库让你的Web应用瞬间提升交互体验
前端
二十雨辰9 小时前
vite性能优化
前端·vue.js
明月与玄武9 小时前
浅谈 富文本编辑器
前端·javascript·vue.js
paodan9 小时前
如何使用ORM 工具,Prisma
前端
布列瑟农的星空10 小时前
重学React——memo能防止Context的额外渲染吗
前端
FuckPatience10 小时前
Vue 与.Net Core WebApi交互时路由初探
前端·javascript·vue.js
小小前端_我自坚强10 小时前
前端踩坑指南 - 避免这些常见陷阱
前端·程序员·代码规范