
事情的起因其实很简单。某天我在项目中给一个按钮加了个 title="删除该条记录",本以为万事大吉,结果设计师悠悠地飘过来说:“这个提示太丑了,能不能搞个像 Ant Design 那种 hover 提示?稍微延迟点,再漂亮点,别那么生硬。”我苦笑了一下,心里明白,这玩意是得自己撸一套 Tooltip 了。
我第一反应是找组件库,但又不能引入太重的库,仅仅为了一个 Tooltip 显得有点杀鸡用牛刀。于是决定自己写一个轻量的 Tooltip 系统,目标是做到以下几点:
data-tooltip 属性即可。这样既不破坏现有 HTML 结构,又方便后期拓展。
我先画了个简单的交互流程图来梳理一下实现逻辑:

先从最核心的逻辑写起。我决定用原生 JavaScript 实现,因为这样更轻量,也更容易理解。我创建了一个 tooltip.js 模块,核心思路很明确:对所有含有 data-tooltip 属性的元素添加事件监听。
document.addEventListener('DOMContentLoaded', () => {
let tooltipEl = null;
let timer = null;
document.body.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
timer = setTimeout(() => {
const text = target.getAttribute('data-tooltip');
tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip';
tooltipEl.innerText = text;
document.body.appendChild(tooltipEl);
tooltipEl.style.position = 'fixed';
tooltipEl.style.pointerEvents = 'none';
}, 500); // 延迟显示
});
document.body.addEventListener('mousemove', (e) => {
if (tooltipEl) {
tooltipEl.style.left = `${e.clientX + 10}px`;
tooltipEl.style.top = `${e.clientY + 10}px`;
}
});
document.body.addEventListener('mouseout', (e) => {
if (timer) clearTimeout(timer);
timer = null;
if (tooltipEl) {
tooltipEl.remove();
tooltipEl = null;
}
});
});在这段代码中,几个点值得注意:
setTimeout 实现了延迟;mousemove 中不断更新 left/top,确保 Tooltip 跟随;mouseout 中清理了计时器和 Tooltip 元素,防止内存泄露。最初我没有加 closest(),结果发现当鼠标移到元素的子节点时,Tooltip 会提前消失,这是个坑,后来改成了 closest('[data-tooltip]') 才解决。
样式我也没打算将就,参考了 Ant Design 和 Material Design 的风格,精心写了一套 CSS,使 Tooltip 看起来既现代又不突兀。
.tooltip {
padding: 6px 10px;
background-color: #222;
color: #fff;
border-radius: 4px;
font-size: 13px;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
z-index: 9999;
white-space: nowrap;
transition: opacity 0.2s ease;
opacity: 0.9;
}我特意给了 pointer-events: none,这是个小技巧,可以让 Tooltip 不会因为“自己”挡住了鼠标而触发额外事件。再配合 position: fixed,就能很好地实现相对于鼠标的绝对定位。
最初版本虽然能用了,但存在两个问题:
mousemove 触发频率太高,性能堪忧。于是我对这两个问题做了增强处理。先是边缘判断,加入如下逻辑:
const updateTooltipPosition = (e) => {
const offset = 12;
const { clientX: x, clientY: y } = e;
const { innerWidth: winW, innerHeight: winH } = window;
const tooltipRect = tooltipEl.getBoundingClientRect();
let left = x + offset;
let top = y + offset;
if (left + tooltipRect.width > winW) {
left = x - tooltipRect.width - offset;
}
if (top + tooltipRect.height > winH) {
top = y - tooltipRect.height - offset;
}
tooltipEl.style.left = `${left}px`;
tooltipEl.style.top = `${top}px`;
};这样 Tooltip 永远不会被“吃掉”。然后是节流处理,我简单实现了一个节流函数:
function throttle(fn, delay) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn.apply(this, args);
}
};
}
const throttledMove = throttle(updateTooltipPosition, 16); // 每帧约 60fps最后在 mousemove 中使用这个版本的函数代替原始 updateTooltipPosition,用户体验顿时流畅了不少。
虽然前面的实现已经具备完整功能,但在项目中直接塞一段匿名函数还是显得不够优雅。为了便于复用与维护,我把所有逻辑封装成一个 TooltipManager 类,并以模块形式导出,整个结构如下:
// tooltip.js
export default class TooltipManager {
constructor({ delay = 500, offset = 12 } = {}) {
this.delay = delay;
this.offset = offset;
this.tooltipEl = null;
this.timer = null;
this.target = null;
this.moveHandler = this.throttle(this.handleMouseMove.bind(this), 16);
}
init() {
document.body.addEventListener('mouseover', this.handleMouseOver.bind(this));
document.body.addEventListener('mouseout', this.handleMouseOut.bind(this));
}
handleMouseOver(e) {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
this.target = target;
this.timer = setTimeout(() => {
const text = target.getAttribute('data-tooltip');
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'tooltip';
this.tooltipEl.innerText = text;
document.body.appendChild(this.tooltipEl);
this.tooltipEl.style.position = 'fixed';
this.tooltipEl.style.pointerEvents = 'none';
this.tooltipEl.style.zIndex = '9999';
document.body.addEventListener('mousemove', this.moveHandler);
}, this.delay);
}
handleMouseMove(e) {
if (!this.tooltipEl) return;
const { clientX: x, clientY: y } = e;
const { innerWidth, innerHeight } = window;
const rect = this.tooltipEl.getBoundingClientRect();
let left = x + this.offset;
let top = y + this.offset;
if (left + rect.width > innerWidth) left = x - rect.width - this.offset;
if (top + rect.height > innerHeight) top = y - rect.height - this.offset;
this.tooltipEl.style.left = `${left}px`;
this.tooltipEl.style.top = `${top}px`;
}
handleMouseOut() {
clearTimeout(this.timer);
this.timer = null;
if (this.tooltipEl) {
this.tooltipEl.remove();
this.tooltipEl = null;
}
document.body.removeEventListener('mousemove', this.moveHandler);
this.target = null;
}
throttle(fn, delay) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn(...args);
}
};
}
}只需要在入口文件中简单调用:
import TooltipManager from './tooltip.js';
const tooltip = new TooltipManager();
tooltip.init();这样就具备完整封装性,日后还可以加上配置项支持、动画开关、触发事件自定义等。
在某些场景中,用户希望 Tooltip 能显示复杂 HTML,比如:
<div data-tooltip="<b>提示</b><br>这是多行内容" data-tooltip-html="true">点我</div>为此我加入 data-tooltip-html="true" 属性,如果启用,就用 innerHTML 而不是 innerText:
const isHtml = this.target.getAttribute('data-tooltip-html') === 'true';
if (isHtml) {
this.tooltipEl.innerHTML = text;
} else {
this.tooltipEl.innerText = text;
}同理,还可以支持手动触发模式,比如通过 JS 主动控制 Tooltip 的显示与隐藏,这部分我增加了 API:
show(text, x, y) { ... }
hide() { ... }以支持更复杂的交互,比如表单验证失败时主动弹出提示等。
样式方面,我最终决定将主题样式抽离出来,提供一个类名入口用于覆盖。例如:
.tooltip {
--tooltip-bg: rgba(0,0,0,0.8);
--tooltip-color: #fff;
--tooltip-padding: 8px 12px;
--tooltip-radius: 6px;
background: var(--tooltip-bg);
color: var(--tooltip-color);
padding: var(--tooltip-padding);
border-radius: var(--tooltip-radius);
font-size: 13px;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.95;
transition: opacity 0.2s ease;
}用户就可以自定义样式主题,比如深色背景、圆角风格等,和 Tailwind 配合使用也毫无障碍。
这一部分我做了相当细致的测试。实际使用中发现:
closest(),需要 polyfill;mousemove 下仍然有一定性能损耗,节流必须开启;hover,但通过 touchstart 模拟可解决;为了更好地体现 Tooltip 在大批量数据中应用的稳定性,我写了个 1000 个按钮的 stress test 页面,跑下来发现只要不滥用 mousemove 和过度渲染 DOM,性能是可以接受的。
来一段完整的示例:
<button data-tooltip="这是一个很棒的提示框">悬停试试</button>
<button data-tooltip="<b>多行内容</b><br>支持 HTML" data-tooltip-html="true">高级提示</button>配合模块引用即可使用:
import TooltipManager from './tooltip.js';
const tooltip = new TooltipManager({ delay: 300 });
tooltip.init();整套体验下来,用户再也不用忍受生硬的 title 弹窗,UI 也瞬间“高级”了起来。

这次 Tooltip 的开发虽然看似简单,但其实涉及了非常多前端常用知识点:事件绑定优化、DOM 动态创建与移除、节流函数、防抖思维、响应式样式适配、兼容性测试、模块封装等等。每一个细节都有值得推敲的地方,尤其是边缘判断与性能优化,在大项目中极其重要。
更重要的是,这次我深刻体会到了“从需求出发”的价值——从设计师的一句“不够美观”出发,到前端实现的一点点雕琢,这种由内而外的推动力,是推动一个细节变得“刚刚好”的源泉。
未来这套 Tooltip 模块还可以继续演化,比如:
<Tooltip> 组件支持 React/Vue 等;但最核心的逻辑,其实已经很完善。
如果你也遇到了原生 title 不够好看的问题,不妨尝试写一个属于自己的 Tooltip 系统,这不仅能提升用户体验,也能锻炼你对 UI 交互的理解。这个小小的悬浮提示,其实背后藏着一整套前端“微交互”的哲学。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。