写在前面#
对于经常需要开发企业管理后台的前端开发来说,一定会遇到表格内显示字段省略后提示浮窗的需求场景,当然成熟的 UI 框架也已经解决了这一需求场景。但是对于整个项目或系统来说,类似的场景如何更好的复用,以及目前实际使用的 UI 框架可能不符合需求,因此结合实际业务封装了这个Hooks和对应扩展的业务组件。
基于个人项目环境进行封装的Hooks,仅以本文介绍封装Hooks思想心得,故相关代码可能不适用他人
项目环境#
Vue3.x + Ant Design Vue3.x + Vite4.x
业务场景分析#
图文内容仅供参考,仅提供文章内所需思考对应的图例
在以上图片中,是管理后台系统中常见的表格内容,因使用的是 Ant Design Vue 框架,根据官方的文档中所示:
Column
的API ellipsis
超出宽度自动省略,不支持和排序筛选一起使用,,且表格布局将变成 tableLayout="fixed"
。
实际使用的代码:
[
{
title: '所属角色',
key: 'role',
width: 100,
},
{
title: '所在部门',
key: 'department',
width: 160,
ellipsis: true
}
]
从上图中则暴露了一个问题,那就是由于 column
作为“配置项”传入表格组件,对于字数可能较长的字段,配置 ellipsis: true
后,无论文本内容是否超出表格列的宽度,都会渲染出 tooltip
,从体验方面和性能方面来说,都未必好,渲染了一些“无意义”的DOM。
同样的,在中后台管理系统中,因为业务考虑或 UI 界面设计等等原因,会出现部分显示区域需要显示可能过长的字段内容,而根据技术选型配套的 Ant Design Vue提供了 tooltip
组件依然有上述问题。
Element Plus 如何做的?#
作为前端流行的UI框架之一,ElementUI Plus的表格内容,对于上述场景是怎么做的,我们可以从其文档中找到对应的内容~
在上图中,发现 Element Plus 确实对于表格场景,解决了字段根据是否超出再显示 tooltip 的问题。后根据上述配置进行 demo 验证也发现确实可用。 根据官方文档和仓库中的部分源码,发现一个第三方 js 库
Popper.js#
TOOLTIP & POPOVER POSITIONING ENGINE
从官方文档及搜索出来各种教程,不难理解,这是一个扩展性较好的 tooltips 提示类 JS 插件,大小仅为 3.5KB 左右,使用与配置也相当简单,基于 popper.js
封装的组件库也有不少,关于这部分的内容,不作为文章重点,且已经有很多介绍其原理和其他相关的优秀内容,在此,不作赘述~
在了解了这个是干嘛的之后,开始着手写项目中需要的Hooks,
使用 popper.js
主要用到 createPopper()
方法,其接受了 3 个参数:reference
(需要弹框的按钮Element)、popper
(tooltip内容HTMLElement)以及 options
options
内主要用到了 placement
(方向) 和 modifiers
,Hooks内用到了name
和offset
,未考虑其他配置参数,更完善和更复杂的一些封装,可以查看Element Plus
或 Tippy.js
等优秀的组件(方法)库。
const popperInstance = createPopper(parent, tooltipContent, {
placement: options.placement ?? 'top',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
});
封装分解:判断逻辑之宽度计算#
在查看了Element Plus
的文档及源码后,发现其仅在Table
组件中有自动省略显示的配置。而对于其他场景,通常我们的做法都是使用tooltip
组件,而这种方式并没有考虑实际内容有没有超出。不能做到动态决定是否显示 tooltip。
Hooks 内的做法则是根据 【 子元素的宽度 + 父元素的 padding > 父元素的宽度 ?展示 tooltip : 不展示】
下面内容是关于实现此 Hooks 的一些部分内容拆解
const getPadding = el => {
const style = window.getComputedStyle(el, null);
const paddingLeft = Number.parseInt(style.paddingLeft, 10) || 0;
const paddingRight = Number.parseInt(style.paddingRight, 10) || 0;
const paddingTop = Number.parseInt(style.paddingTop, 10) || 0;
const paddingBottom = Number.parseInt(style.paddingBottom, 10) || 0;
return {
left: paddingLeft,
right: paddingRight,
top: paddingTop,
bottom: paddingBottom,
};
};
为什么需要获取父元素 Padding ?这里则是关于 BFC 的一些问题,
判断子元素什么时候需要隐藏并展示 tooltip,根据上图,当 Child container
的宽度 + 黄色区域的 padding > Parent container
的宽度后,生成 tooltip.
let range = document.createRange();
range.setStart(target, 0);
range.setEnd(target, target.childNodes.length);
const rangeWidth = range.getBoundingClientRect().width;
range.detach();
const { left, right } = getPadding(target);
const horizontalPadding = left + right;
document.createRange()
用来创建一个Range
对象,包含了startContainer
和 endContainer
,在这里我们使用 setStart
和 setEnd
来创建选择的 DOM 范围,用来拿到 rangeWidth
以方便后面的比较计算。
在使用范围后,调用 detach()
方法,以便从创建范围的文档中分离出该范围。
关于这部分内容,以及具体的 CSSOM视图相关的知识,可以查看张鑫旭大佬的文章,文章地址在这:CSSOM视图模式(CSSOM View Module)相关整理
封装分解:创建 tooltipContent#
生成 tooltip 的前置条件判断好了,tooltip 的内容要显示什么,本 Hooks 利用的是鼠标移入时获取自定义属性 data-title
并将其赋值为innerText
,根据 popper.js的文档,创建tooltipContent
和 arrowContent
。
const renderContent = (target, parent) => {
const tooltipContent = document.createElement('div');
const arrowContent = document.createElement('div');
arrowContent.className = ['ellipsis-tooltip-arrow'].join(' ');
arrowContent.setAttribute('data-popper-arrow', 'true');
tooltipContent.innerText = target.dataset.title;
tooltipContent.setAttribute('role', 'tooltip');
tooltipContent.appendChild(arrowContent);
tooltipContent.className = ['ellipsis-tooltip'].join(' ');
parent.setAttribute('aria-describedby', 'tooltip');
parent.appendChild(tooltipContent);
return {
tooltipContent,
};
};
同样的,在鼠标移出时,销毁 popperInstance、移除鼠标离开的监听事件。
popperInstance.destroy();
parent.removeChild(tooltipContent);
parent.removeAttribute('aria-describedby');
target.removeListener('mouseleave', removePopper);
封装分解:EllipsisPopper.vue 组件#
<template>
<div class="ellipsis" :data-title="text" @mouseenter="handleCellMouseEnter">
<span>{{ text }}</span>
</div>
</template>
<script setup>
import { useEllipsisPopper } from '@/hooks';
defineProps({
text: {
type: String,
required: true,
},
});
const { handleCellMouseEnter } = useEllipsisPopper({ placement: 'auto' });
</script>
因考虑将管理系统中,除表格之外的其他渲染内容,也统一使用动态展示 tooltip,搭配 Hooks 使用,封装 EllipsisPopper
组件。
至此,便理清了Hooks内需要的内容,另外,Hooks内仅考虑了单行文本溢出隐藏展示 tooltip,对于多行文本溢出隐藏后展示 tooltip 的需求并未考虑,相对应的,也没有实现如 Element Plus
更复杂的配置,Hooks 本身结合项目实际需求而言,未做更复杂的拓展。
最后,贴一下使用 EllipsisPopper
组件和useEllipsisPopper.js
后,文章初始的表格变化吧~
因实际项目需要兼容生态应用(钉钉、飞书)等,需要根据对应开发平台展示部分企业架构相关的字段,文章所示的
EllipsisPopper
组件仅便于理解,和实际业务组件脱敏处理提取的内容,如果不需要兼容生态应用,则可以直接给父元素(即定款展示区域)自定义属性data-title
,并加上@mouseenter="handleCellMouseEnter"
最后,贴一下完整代码~
useEllipsisPopper.js完整代码#
import { createPopper } from '@popperjs/core';
const getPadding = el => {
const style = window.getComputedStyle(el, null);
const paddingLeft = Number.parseInt(style.paddingLeft, 10) || 0;
const paddingRight = Number.parseInt(style.paddingRight, 10) || 0;
const paddingTop = Number.parseInt(style.paddingTop, 10) || 0;
const paddingBottom = Number.parseInt(style.paddingBottom, 10) || 0;
return {
left: paddingLeft,
right: paddingRight,
top: paddingTop,
bottom: paddingBottom,
};
};
const renderContent = (target, parent) => {
const tooltipContent = document.createElement('div');
const arrowContent = document.createElement('div');
arrowContent.className = ['ellipsis-tooltip-arrow'].join(' ');
arrowContent.setAttribute('data-popper-arrow', 'true');
tooltipContent.innerText = target.dataset.title;
tooltipContent.setAttribute('role', 'tooltip');
tooltipContent.appendChild(arrowContent);
tooltipContent.className = ['ellipsis-tooltip'].join(' ');
parent.setAttribute('aria-describedby', 'tooltip');
parent.appendChild(tooltipContent);
return {
tooltipContent,
};
};
export function useEllipsisPopper(options = {}) {
const handleCellMouseEnter = event => {
const target = event.target;
const parent = target.parentNode;
let range = document.createRange();
range.setStart(target, 0);
range.setEnd(target, target.childNodes.length);
const rangeWidth = range.getBoundingClientRect().width;
range.detach();
const { left, right } = getPadding(target);
const horizontalPadding = left + right;
if (Math.floor(rangeWidth + horizontalPadding) > target.clientWidth) {
const { tooltipContent } = renderContent(target, parent);
const popperInstance = createPopper(parent, tooltipContent, {
placement: options.placement ?? 'top',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
});
const removePopper = () => {
popperInstance.destroy();
parent.removeChild(tooltipContent);
parent.removeAttribute('aria-describedby');
target.removeListener('mouseleave', removePopper);
};
target.addEventListener('mouseleave', removePopper);
}
};
return {
handleCellMouseEnter,
};
}
需要额外补充的样式至项目内#
.ellipsis-tooltip {
z-index: 10;
display: inline-block;
background: #333333;
color: #ffffff;
padding: 5px 10px;
font-size: 13px;
border-radius: 4px;
}
.ellipsis-tooltip-arrow,
.ellipsis-tooltip-arrow::before {
position: absolute;
width: 6px;
height: 6px;
background: inherit;
}
.ellipsis-tooltip-arrow {
visibility: hidden;
}
.ellipsis-tooltip-arrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.ellipsis-tooltip[data-popper-placement^='top'] > .ellipsis-tooltip-arrow {
bottom: -3px;
}
.ellipsis-tooltip[data-popper-placement^='bottom'] > .ellipsis-tooltip-arrow {
top: -3px;
}
.ellipsis-tooltip[data-popper-placement^='left'] > .ellipsis-tooltip-arrow {
right: -3px;
}
.ellipsis-tooltip[data-popper-placement^='right'] > .ellipsis-tooltip-arrow {
left: -3px;
}