首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >自定义 Tooltip 制作

自定义 Tooltip 制作

原创
作者头像
繁依Fanyi
发布2025-05-09 16:21:40
发布2025-05-09 16:21:40
3200
举报

事情的起因其实很简单。某天我在项目中给一个按钮加了个 title="删除该条记录",本以为万事大吉,结果设计师悠悠地飘过来说:“这个提示太丑了,能不能搞个像 Ant Design 那种 hover 提示?稍微延迟点,再漂亮点,别那么生硬。”我苦笑了一下,心里明白,这玩意是得自己撸一套 Tooltip 了。

思考与初步设计

我第一反应是找组件库,但又不能引入太重的库,仅仅为了一个 Tooltip 显得有点杀鸡用牛刀。于是决定自己写一个轻量的 Tooltip 系统,目标是做到以下几点:

  1. 悬停时延迟显示 Tooltip;
  2. Tooltip 随鼠标移动;
  3. 样式支持完全自定义;
  4. 使用方式尽可能简洁,比如只需加一个 data-tooltip 属性即可。

这样既不破坏现有 HTML 结构,又方便后期拓展。

我先画了个简单的交互流程图来梳理一下实现逻辑:

落地实现:构建 Tooltip 基础逻辑

先从最核心的逻辑写起。我决定用原生 JavaScript 实现,因为这样更轻量,也更容易理解。我创建了一个 tooltip.js 模块,核心思路很明确:对所有含有 data-tooltip 属性的元素添加事件监听。

代码语言:js
复制
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]') 才解决。

样式与现代 UI 设计

样式我也没打算将就,参考了 Ant Design 和 Material Design 的风格,精心写了一套 CSS,使 Tooltip 看起来既现代又不突兀。

代码语言:css
复制
.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,就能很好地实现相对于鼠标的绝对定位。

增强用户体验:边界判断与节流

最初版本虽然能用了,但存在两个问题:

  1. 当鼠标移到页面边缘时,Tooltip 有可能显示在屏幕外;
  2. mousemove 触发频率太高,性能堪忧。

于是我对这两个问题做了增强处理。先是边缘判断,加入如下逻辑:

代码语言:js
复制
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 永远不会被“吃掉”。然后是节流处理,我简单实现了一个节流函数:

代码语言:js
复制
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,用户体验顿时流畅了不少。


模块封装:把 Tooltip 写成可复用模块

虽然前面的实现已经具备完整功能,但在项目中直接塞一段匿名函数还是显得不够优雅。为了便于复用与维护,我把所有逻辑封装成一个 TooltipManager 类,并以模块形式导出,整个结构如下:

代码语言:js
复制
// 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);
      }
    };
  }
}

只需要在入口文件中简单调用:

代码语言:js
复制
import TooltipManager from './tooltip.js';

const tooltip = new TooltipManager();
tooltip.init();

这样就具备完整封装性,日后还可以加上配置项支持、动画开关、触发事件自定义等。


扩展性设计:支持 HTML 内容、多行文本、手动触发等

在某些场景中,用户希望 Tooltip 能显示复杂 HTML,比如:

代码语言:html
复制
<div data-tooltip="<b>提示</b><br>这是多行内容" data-tooltip-html="true">点我</div>

为此我加入 data-tooltip-html="true" 属性,如果启用,就用 innerHTML 而不是 innerText

代码语言:js
复制
const isHtml = this.target.getAttribute('data-tooltip-html') === 'true';
if (isHtml) {
  this.tooltipEl.innerHTML = text;
} else {
  this.tooltipEl.innerText = text;
}

同理,还可以支持手动触发模式,比如通过 JS 主动控制 Tooltip 的显示与隐藏,这部分我增加了 API:

代码语言:js
复制
show(text, x, y) { ... }
hide() { ... }

以支持更复杂的交互,比如表单验证失败时主动弹出提示等。


UI 打磨:样式动态适配和响应式细节优化

样式方面,我最终决定将主题样式抽离出来,提供一个类名入口用于覆盖。例如:

代码语言:css
复制
.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 配合使用也毫无障碍。


浏览器兼容性测试与性能验证

这一部分我做了相当细致的测试。实际使用中发现:

  • IE11 不支持 closest(),需要 polyfill;
  • 低端设备在大量 mousemove 下仍然有一定性能损耗,节流必须开启;
  • iOS 上 Tooltip 不一定好用,因为没有传统 hover,但通过 touchstart 模拟可解决;

为了更好地体现 Tooltip 在大批量数据中应用的稳定性,我写了个 1000 个按钮的 stress test 页面,跑下来发现只要不滥用 mousemove 和过度渲染 DOM,性能是可以接受的。


最终使用示例

来一段完整的示例:

代码语言:html
复制
<button data-tooltip="这是一个很棒的提示框">悬停试试</button>
<button data-tooltip="<b>多行内容</b><br>支持 HTML" data-tooltip-html="true">高级提示</button>

配合模块引用即可使用:

代码语言:js
复制
import TooltipManager from './tooltip.js';

const tooltip = new TooltipManager({ delay: 300 });
tooltip.init();

整套体验下来,用户再也不用忍受生硬的 title 弹窗,UI 也瞬间“高级”了起来。

开发回顾与思考

这次 Tooltip 的开发虽然看似简单,但其实涉及了非常多前端常用知识点:事件绑定优化、DOM 动态创建与移除、节流函数、防抖思维、响应式样式适配、兼容性测试、模块封装等等。每一个细节都有值得推敲的地方,尤其是边缘判断与性能优化,在大项目中极其重要。

更重要的是,这次我深刻体会到了“从需求出发”的价值——从设计师的一句“不够美观”出发,到前端实现的一点点雕琢,这种由内而外的推动力,是推动一个细节变得“刚刚好”的源泉。

未来这套 Tooltip 模块还可以继续演化,比如:

  • 增加 <Tooltip> 组件支持 React/Vue 等;
  • 提供动画出现/消失效果;
  • 支持 delay-close、hover 锁定等高级功能;
  • 国际化支持、Accessibility 支持(如 aria-label)……

但最核心的逻辑,其实已经很完善。

如果你也遇到了原生 title 不够好看的问题,不妨尝试写一个属于自己的 Tooltip 系统,这不仅能提升用户体验,也能锻炼你对 UI 交互的理解。这个小小的悬浮提示,其实背后藏着一整套前端“微交互”的哲学。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 思考与初步设计
  • 落地实现:构建 Tooltip 基础逻辑
  • 样式与现代 UI 设计
  • 增强用户体验:边界判断与节流
  • 模块封装:把 Tooltip 写成可复用模块
  • 扩展性设计:支持 HTML 内容、多行文本、手动触发等
  • UI 打磨:样式动态适配和响应式细节优化
  • 浏览器兼容性测试与性能验证
  • 最终使用示例
  • 开发回顾与思考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档