1. 背景
在 el-table中的 show-overflow-tooltip 功能很好用,但是不用el-table时,也想要这个效果怎么办?用el-tooltip这个组件吧,内容没超出长度时也有提示,可是想要的效果是:只有在超出长度后才出现tooltip效果,怎么办?今天咱们就来实现下这个功能吧!
2. 如何使用呢?
在html中 容器需要有以下样式:
.content {
width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis
}
// 在标签上使用代码如下:
```vue
<div v-overflow-tooltip class="content ">这里有很长的文字</div>
3. 如何实现呢?
需要写一个自定义指令,并全局注册下就ok啦,代码如下所示
- directive/overflowTooltip.ts 如下所示
import type { App, Directive, DirectiveBinding } from 'vue';
interface OverflowTooltipOptions {
// 基础配置
content?: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
effect?: 'dark' | 'light';
// 功能配置
maxLines?: number;
maxWidth?: number | string;
disabled?: boolean;
// 性能配置
debounce?: number;
}
interface DirectiveState {
isOverflow: boolean;
resizeObserver: ResizeObserver | null;
checkTimer: ReturnType<typeof setTimeout> | null;
originalTitle: string | null;
tooltipEl: HTMLElement | null;
}
const stateMap = new WeakMap<HTMLElement, DirectiveState>();
// 默认配置
const defaultOptions: OverflowTooltipOptions = {
placement: 'top',
effect: 'dark',
maxLines: 1,
debounce: 100,
disabled: false,
};
// 合并配置
const mergeOptions = (binding: DirectiveBinding<string | OverflowTooltipOptions>): OverflowTooltipOptions => {
const options = { ...defaultOptions };
if (typeof binding.value === 'string') {
options.content = binding.value;
} else if (typeof binding.value === 'object') {
Object.assign(options, binding.value);
}
// 处理修饰符
if (binding.modifiers) {
if (binding.modifiers.top) options.placement = 'top';
if (binding.modifiers.bottom) options.placement = 'bottom';
if (binding.modifiers.left) options.placement = 'left';
if (binding.modifiers.right) options.placement = 'right';
if (binding.modifiers.light) options.effect = 'light';
if (binding.modifiers.dark) options.effect = 'dark';
if (binding.modifiers.disabled) options.disabled = true;
}
return options;
};
// 获取元素文本内容
const getElementText = (el: HTMLElement): string => {
return el.textContent?.trim() || el.innerText?.trim() || '';
};
// 检查单行溢出
const checkSingleLineOverflow = (el: HTMLElement): boolean => {
if (!el.offsetParent || el.offsetWidth === 0) {
return false;
}
const style = window.getComputedStyle(el);
const hasEllipsis = style.whiteSpace === 'nowrap' && style.overflow === 'hidden' && style.textOverflow === 'ellipsis';
if (hasEllipsis) {
return el.scrollWidth > Math.floor(el.clientWidth);
}
return el.scrollWidth > el.clientWidth;
};
// 检查多行溢出
const checkMultiLineOverflow = (el: HTMLElement, maxLines: number): boolean => {
if (!el.offsetParent || el.offsetWidth === 0) {
return false;
}
const style = window.getComputedStyle(el);
const lineHeight = parseInt(style.lineHeight) || 20;
const maxHeight = maxLines * lineHeight;
// 创建临时元素测量
const tempEl = document.createElement('div');
tempEl.style.cssText = `
position: absolute;
visibility: hidden;
width: ${el.clientWidth}px;
font: ${style.font};
line-height: ${style.lineHeight};
padding: ${style.padding};
white-space: normal;
word-wrap: break-word;
`;
tempEl.textContent = getElementText(el);
document.body.appendChild(tempEl);
const actualHeight = tempEl.offsetHeight;
document.body.removeChild(tempEl);
return actualHeight > maxHeight;
};
// 创建自定义 tooltip 元素
const createTooltipElement = (el: HTMLElement, content: string, options: OverflowTooltipOptions): HTMLElement => {
const tooltip = document.createElement('div');
tooltip.className = `overflow-tooltip ${options.effect}`;
tooltip.textContent = content;
tooltip.style.cssText = `
position: fixed;
z-index: 9999;
padding: 8px 12px;
font-size: 12px;
line-height: 1.2;
border-radius: 4px;
max-width: 400px;
word-wrap: break-word;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
white-space: pre-wrap;
word-break: break-all;
`;
// 设置暗色/亮色主题
if (options.effect === 'dark') {
tooltip.style.backgroundColor = '#303133';
tooltip.style.color = '#fff';
} else {
tooltip.style.backgroundColor = '#fff';
tooltip.style.color = '#606266';
tooltip.style.boxShadow = '0 2px 12px 0 rgba(0, 0, 0, 0.1)';
}
// 先添加到 body 获取实际尺寸
document.body.appendChild(tooltip);
const tooltipRect = tooltip.getBoundingClientRect();
// 计算位置
const rect = el.getBoundingClientRect();
let top: number, left: number;
switch (options.placement) {
case 'bottom':
top = rect.bottom + 10;
left = rect.left + (rect.width - tooltipRect.width) / 2;
break;
case 'left':
top = rect.top + (rect.height - tooltipRect.height) / 2;
left = rect.left - tooltipRect.width - 10;
break;
case 'right':
top = rect.top + (rect.height - tooltipRect.height) / 2;
left = rect.right + 10;
break;
default: // top
top = rect.top - tooltipRect.height - 10;
left = rect.left + (rect.width - tooltipRect.width) / 2;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
// 添加小箭头
const arrow = document.createElement('div');
arrow.className = 'overflow-tooltip-arrow';
arrow.style.cssText = `
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
`;
switch (options.placement) {
case 'bottom':
arrow.style.top = '-12px';
arrow.style.left = '50%';
arrow.style.transform = 'translateX(-50%)';
arrow.style.borderBottomColor = options.effect === 'dark' ? '#303133' : '#fff';
break;
case 'left':
arrow.style.top = '50%';
arrow.style.right = '-12px';
arrow.style.transform = 'translateY(-50%)';
arrow.style.borderLeftColor = options.effect === 'dark' ? '#303133' : '#fff';
break;
case 'right':
arrow.style.top = '50%';
arrow.style.left = '-12px';
arrow.style.transform = 'translateY(-50%)';
arrow.style.borderRightColor = options.effect === 'dark' ? '#303133' : '#fff';
break;
default: // top
arrow.style.bottom = '-12px';
arrow.style.left = '50%';
arrow.style.transform = 'translateX(-50%)';
arrow.style.borderTopColor = options.effect === 'dark' ? '#303133' : '#fff';
}
tooltip.appendChild(arrow);
return tooltip;
};
// 显示 tooltip
const showTooltip = (el: HTMLElement, state: DirectiveState, options: OverflowTooltipOptions) => {
if (!state.isOverflow || state.tooltipEl) return;
const content = options.content || getElementText(el);
if (!content) return;
// 移除之前的 tooltip
hideTooltip(el, state);
// 创建新的 tooltip
const tooltip = createTooltipElement(el, content, options);
// document.body.appendChild(tooltip);
state.tooltipEl = tooltip;
// 触发显示动画
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
};
// 隐藏 tooltip
const hideTooltip = (el: HTMLElement, state: DirectiveState) => {
if (state.tooltipEl) {
const tooltip = state.tooltipEl;
tooltip.style.opacity = '0';
setTimeout(() => {
if (document.body.contains(tooltip)) {
document.body.removeChild(tooltip);
}
state.tooltipEl = null;
}, 200);
}
};
// 检查溢出状态
const checkOverflow = (el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>): boolean => {
const state = stateMap.get(el);
if (!state) return false;
const options = mergeOptions(binding);
if (options.disabled) {
// 如果禁用,移除 tooltip
hideTooltip(el, state);
el.removeAttribute('data-overflow-tooltip');
if (state.originalTitle !== null) {
el.setAttribute('title', state.originalTitle);
} else {
el.removeAttribute('title');
}
state.isOverflow = false;
return false;
}
let isOverflow = false;
if (options.maxLines && options.maxLines > 1) {
isOverflow = checkMultiLineOverflow(el, options.maxLines);
} else {
isOverflow = checkSingleLineOverflow(el);
}
state.isOverflow = isOverflow;
const content = options.content || getElementText(el);
if (isOverflow) {
// 内容溢出时,保存内容并设置状态
el.setAttribute('data-overflow-tooltip', content);
// 临时移除原生 title 以免干扰
el.removeAttribute('title');
} else {
// 内容不溢出时,移除 tooltip
hideTooltip(el, state);
el.removeAttribute('data-overflow-tooltip');
if (state.originalTitle !== null) {
el.setAttribute('title', state.originalTitle);
} else {
el.removeAttribute('title');
}
}
return isOverflow;
};
// 防抖检查
const debouncedCheckOverflow = (el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) => {
const state = stateMap.get(el);
if (!state) return;
const options = mergeOptions(binding);
const delay = options.debounce || 100;
if (state.checkTimer) {
clearTimeout(state.checkTimer);
}
state.checkTimer = setTimeout(() => {
checkOverflow(el, binding);
}, delay);
};
// 事件处理
const setupEventListeners = (el: HTMLElement, options: OverflowTooltipOptions) => {
const handleMouseEnter = () => {
const state = stateMap.get(el);
if (!state) return;
// 防抖显示
if (state.checkTimer) {
clearTimeout(state.checkTimer);
}
state.checkTimer = setTimeout(() => {
const currentOptions = mergeOptions({ value: options } as DirectiveBinding<string | OverflowTooltipOptions>);
showTooltip(el, state, currentOptions);
}, 100);
};
const handleMouseLeave = () => {
const state = stateMap.get(el);
if (!state) return;
if (state.checkTimer) {
clearTimeout(state.checkTimer);
}
hideTooltip(el, state);
};
el.addEventListener('mouseenter', handleMouseEnter);
el.addEventListener('mouseleave', handleMouseLeave);
el.addEventListener('focus', handleMouseEnter);
el.addEventListener('blur', handleMouseLeave);
// 保存事件处理器以便清理
(el as any)._overflowTooltipHandlers = {
mouseenter: handleMouseEnter,
mouseleave: handleMouseLeave,
focus: handleMouseEnter,
blur: handleMouseLeave,
};
};
// 清理事件监听器
const cleanupEventListeners = (el: HTMLElement) => {
const handlers = (el as any)._overflowTooltipHandlers;
if (handlers) {
el.removeEventListener('mouseenter', handlers.mouseenter);
el.removeEventListener('mouseleave', handlers.mouseleave);
el.removeEventListener('focus', handlers.focus);
el.removeEventListener('blur', handlers.blur);
delete (el as any)._overflowTooltipHandlers;
}
};
// 销毁指令
const destroy = (el: HTMLElement) => {
const state = stateMap.get(el);
if (!state) return;
// 清理定时器
if (state.checkTimer) clearTimeout(state.checkTimer);
// 清理观察器
if (state.resizeObserver) {
state.resizeObserver.disconnect();
state.resizeObserver = null;
}
// 清理事件监听器
cleanupEventListeners(el);
// 移除 tooltip
hideTooltip(el, state);
// 恢复原始 title
el.removeAttribute('data-overflow-tooltip');
if (state.originalTitle !== null) {
el.setAttribute('title', state.originalTitle);
} else {
el.removeAttribute('title');
}
// 清理状态
stateMap.delete(el);
};
// 主指令
export const vOverflowTooltip: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) {
// 保存原始 title
const originalTitle = el.getAttribute('title');
el.removeAttribute('title');
// 创建状态
const state: DirectiveState = {
isOverflow: false,
resizeObserver: null,
checkTimer: null,
originalTitle,
tooltipEl: null,
};
stateMap.set(el, state);
const options = mergeOptions(binding);
// 初始检查
debouncedCheckOverflow(el, binding);
// 设置事件监听
setupEventListeners(el, options);
// 监听窗口大小变化
if ('ResizeObserver' in window) {
state.resizeObserver = new ResizeObserver(() => {
debouncedCheckOverflow(el, binding);
});
state.resizeObserver.observe(el);
}
// 监听内容变化(MutationObserver)
const observer = new MutationObserver(() => {
debouncedCheckOverflow(el, binding);
});
observer.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
(el as any)._mutationObserver = observer;
},
updated(el: HTMLElement, binding: DirectiveBinding<string | OverflowTooltipOptions>) {
debouncedCheckOverflow(el, binding);
},
beforeUnmount(el: HTMLElement) {
destroy(el);
// 清理 MutationObserver
const observer = (el as any)._mutationObserver;
if (observer) {
observer.disconnect();
delete (el as any)._mutationObserver;
}
},
};
// 安装函数
export const installOverflowTooltip = (app: App) => {
// 注册全局样式
app.directive('overflow-tooltip', vOverflowTooltip);
};
export default {
install: installOverflowTooltip,
};
- main.ts 中全局注册
import { createApp } from 'vue';
import pinia from '/@/stores/index';
import App from './App.vue';
import router from './router';
import { directive } from '/@/directive';
const app = createApp(App);
// 全局自定义指令挂载
directive(app);
app
.use(pinia) // pinia 存储
.use(router) // 路由
.mount('#app');
4. 看下效果
超出的展示tooltip提示,未超出的不展示。完美解决~

image.png