vue3自定义指令合集-单例v-tooltip
2026年1月28日 18:17
背景
在实际项目中,Tooltip 往往会遇到这些问题:
- 大多数使用组件形式实现(有时候项目中对于tooltip没有过多要求,使用UI组件的话,需要包一层组件,有点臃肿)
- 每个元素一个 Tooltip,实例过多,性能差
- 页面滚动 / 容器滚动后,Tooltip 位置不更新
- Tooltip 抖动、闪烁、频繁销毁重建
- 指令解绑不彻底,事件和监听器泄漏
目标
- 全局只创建 一个 Tooltip 实例
- 使用指令
v-tooltip即插即用 - 支持
top / right / bottom / left - 自动跟随目标元素位置变化
- 无闪烁、无内存泄漏
- TypeScript 类型安全
造个轮子,代码放在仓库了,各位大佬自取:gitee.com/Gitfubobo/v…
使用方式
<button v-tooltip="'删除后无法恢复'">删除</button>
<button v-tooltip.right="'更多操作'">操作</button>
![]()
整体设计思路
Tooltip 用单例统一管理,指令只负责绑定 DOM,这里的单例是指DOM单例,页面上只存在一个DOM
核心拆分
-
TooltipSingleton
- 负责 Tooltip 的创建、更新、销毁
import tooltipV from './tooltip.vue';
/**
* @Author: fubobo
* @Description: 单例
* @return {TooltipSingleton}
*/
public static getInstance(): TooltipSingleton {
if (!TooltipSingleton.instance) {
TooltipSingleton.instance = new TooltipSingleton();
}
return TooltipSingleton.instance;
}
// 创建dom挂载tooltip组件
private createInstance() {
if (!this.tooltipInstance) {
this.rootEl = document.createElement('div');
document.body.appendChild(this.rootEl);
this.app = createApp(tooltipV);
this.tooltipInstance = this.app.mount(this.rootEl);
}
}
-
v-tooltip 指令
- 负责 DOM 事件绑定与生命周期
// 指令对象
const tooltipDirective: Directive<HTMLElement, string> = {
mounted(el, binding) {
TooltipSingleton.getInstance().mount(el, binding);
},
updated(el, binding) {
TooltipSingleton.getInstance().updated(el, binding);
},
unmounted(el) {
TooltipSingleton.getInstance().unmount(el);
},
};
-
位置监听工具
- 负责监听滚动 / resize / DOM 变化
/**
* @Author: fubobo
* @Description: 根据目标元素和tooltip进行位置计算
* @return 坐标
* @param {HTMLElement} targetEl
* @param {Placement} placement
*/
private calculatePosition(targetEl: HTMLElement, placement: Placement) {
....
return { x, y };
}
/**
* 监听元素距离窗口顶部的距离变化
* @param element 目标元素
* @param callback 距离变化回调
* @param options 配置项
* @returns 停止监听的函数
*/
const observeElementViewportTop = (
element: HTMLElement,
callback: PositionCallback,
options?: {
throttle?: number; // 节流时间(默认 100ms)
observeAncestors?: boolean; // 是否监听祖先元素尺寸变化
}
) => {
....
return () => {
window.removeEventListener('scroll', handleWindowEvent);
window.removeEventListener('resize', handleWindowEvent);
mutationObserver.disconnect();
resizeObserver.disconnect();
};
};
为什么一定要用「单例 Tooltip」
如果每个 v-tooltip 都创建一个组件:
- DOM 数量指数级增长
- 每个 Tooltip 都监听 scroll / resize
- 事件难以统一管理
带来的好处
- 整个应用 只存在一个 Tooltip
- hover 不同元素 → 只是更新内容和位置
- 性能稳定、结构清晰
Tooltip 实例的创建与销毁
创建(只会执行一次)
private createInstance() {
this.rootEl = document.createElement('div');
document.body.appendChild(this.rootEl);
this.app = createApp(tooltipV);
this.tooltipInstance = this.app.mount(this.rootEl);
}
销毁(最后一个指令卸载时)
private destroyInstance() {
this.app.unmount();
document.body.removeChild(this.rootEl);
}
Tooltip 定位计算逻辑
核心思路:
使用目标元素位置 + Tooltip 自身尺寸计算最终坐标
const tooltipRect = tooltip.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
switch (placement) {
case 'top':
x = targetRect.x + targetRect.width / 2 - tooltipRect.width / 2;
y = targetRect.y - tooltipRect.height - 10;
}
监听「元素位置变化」
仅靠 mouseenter 是完全不够的:
- 页面滚动
- 容器滚动
- DOM transform
- 父级尺寸变化
解决方案:多维度监听
observeElementViewportTop(el, callback, {
observeAncestors: true,
});
监听来源包括:
| 监听方式 | 解决问题 |
|---|---|
| scroll / resize | 页面和窗口变化 |
| MutationObserver | class / style 改变 |
| ResizeObserver | 元素或父级尺寸变化 |
📌 Tooltip 始终贴着目标元素,不会错位
关键点
- Tooltip 自身维护 hover 状态
- 延迟隐藏(100ms)
- 体验更自然
使用 WeakMap 优化性能
- 不阻止 GC
- DOM 销毁后自动释放
- 从根源避免内存泄漏
class TooltipSingleton {
...
private eventHandlers = new WeakMap<
HTMLElement,
{
enter: EventListener;
leave: EventListener;
}
>(); // 存储元素的事件处理器(弱引用防止内存泄漏)
private directiveInfo = new WeakMap<
HTMLElement,
{
text: string;
placement: Placement;
}
>(); // 存储元素关联的指令信息(弱引用)
}
解决了哪些问题
| 问题 | 是否解决 |
|---|---|
| Tooltip 重复创建 | ✅ |
| 滚动后位置错乱 | ✅ |
| hover 闪烁 | ✅ |
| 内存泄漏 | ✅ |
| TypeScript 类型不安全 | ✅ |
| 指令更新无效 | ✅ |
| 支持DOM传值 | ✅ |
代码仓库
框框造个轮子,代码放在仓库了,各位大佬自取:gitee.com/Gitfubobo/v…