“工具提示需要延迟,然后需要跳过它”——这句话看起来像绕口令,但我第一次在工作中接到类似需求时,一下就沉默了。表格列头要加 tooltip,鼠标悬停后提示不能立刻弹出来,哪怕用户只是快速划过几个按钮,也不能出现一串提示框在屏幕上闪来闪去。于是我开始加延迟:鼠标进入元素后等 300ms 再显示。结果又出现第二个问题:用户从按钮移到 tooltip 的半路上,提示突然消失了;或者鼠标在一行操作按钮里快速扫过,前面的 tooltip 延迟还没结束,后面的 timer 已经开始了,最后在错误的位置弹出一个不相关的提示。你需要的其实是两个能力:延迟显示,以及判断“现在这个操作不值得显示提示”并跳过它。
这个问题的本质,不是 setTimeout 加 clearTimeout 那几行代码。工具提示的延迟和跳过,本质上是一个“用户意图识别”问题:在意图还不明确之前延迟反馈,在意图已经明确不该出现时跳过反馈。真正值得设计和沉淀的,不是延迟本身,而是把延迟、取消、跳过、切换目标这些行为统一成一个可维护的交互状态机。
1. 先分清“工具提示需要延迟”到底在延迟什么
1.1 延迟显示:为什么不能鼠标一放上去就弹
如果你的 tooltip 没有延迟,最直观的感受是:用户只是在页面上扫读内容,鼠标指针正好经过了一个图标,提示立刻弹了出来,把旁边的文字挡住了。用户本来没有想看帮助信息,只是从 A 点移到 B 点,结果被迫接受了一次视觉打断。
这里面有一个交互设计里的常用概念:hover intent,悬停意图。鼠标进入元素并不会自动等于用户想获取更多信息,只有鼠标在元素上停留一段时间,才说明这里有“查看”的意图。所以 tooltip 的显示延迟不是为了让用户等,而是为了过滤掉那些“路过”型移动。
常见做法是设置 300ms 到 500ms 的显示延迟。鼠标移入后不会立即触发 timer,而是在 timer 到期后判断:用户是否还停留在同一个元素上,是否已经滚动页面,是否按下鼠标拖动。如果这些都正常,再显示 tooltip。
很多初学者会问:为什么不直接显示,反正只延迟几百毫秒?因为这几百毫秒是用户扫读过程中的“安全感窗口”。没有这个窗口,鼠标快速扫过一列按钮时,tooltip 会连续弹出又消失,视觉上非常不稳定。延迟其实是在给 UI 加一层“冷静期”。
1.2 延迟关闭:为什么还需要停留时间
显示延迟解决的是“不要过早出现”,关闭延迟解决的是“不要过早消失”。
一个典型场景:tooltip 里有关键信息,用户想把鼠标移进去读取,甚至想点击 tooltip 里的链接。但鼠标从源元素移动到 tooltip 的物理路径上,通常会先离开源元素。如果 mouseleave 事件触发后立刻隐藏 tooltip,用户永远没有机会把鼠标移进去。
所以我们需要一个隐藏延迟,也叫 hideDelay。鼠标离开源元素后,不立即隐藏,而是等一小段时间。如果用户在隐藏计时器结束前进入了 tooltip,或者回到源元素,就取消隐藏,继续保持可见。
关闭延迟的另一个作用是消除闪烁。鼠标快速移出又马上移回同一个元素时,如果显示和隐藏都是即时的,tooltip 会像呼吸灯一样闪烁。增加一个几十到一两百毫秒的隐藏延迟,可以在快速往返的情况下保持稳定。
但隐藏延迟不能设得过大。鼠标移走后,tooltip 还停在原地,会遮挡用户接下来要操作的内容。常见的 hideDelay 设置在 100ms 到 200ms 之间。如果 tooltip 内没有可交互内容,甚至可以设为 0。
1.3 跳过:哪些情况必须取消延迟并直接不显示
“跳过”和“延迟”是两种完全不同的行为。延迟是让提示晚一点出现,“跳过”是无论等多久,这次都不应该出现。
常见的跳过条件包括:
- 鼠标移动速度很快,只是从元素上“扫过”,没有驻留意图。
- 页面正在滚动,或者用户正在拖拽元素,此时 hover 状态是误触。
- 元素本身处于禁用状态,不应该给用户任何 hover 反馈。
- 用户之前刚看过一个 tooltip,短时间内又 hover 到另一个元素,继续弹窗会显得很吵。
- 浏览器标签页处于后台,document.hidden 为 true,任何延迟都应该被跳过。
- 触摸设备上,手指按压触发的是 touch 事件而不是 hover,如果还按 hover 逻辑处理,会出现点击时 tooltip 闪烁的问题。
跳过意味着要有一个显式的判断时机。不是等到 timer 到期后才发现条件不满足,而是在延迟窗口内持续检测这些条件,一旦条件满足,立即取消当前计时器,并把状态重置为“空闲”。这样才能保证后续交互不会被上一次残留的定时器干扰。
2. 把 tooltip 的交互抽象成一个状态机
2.1 简单 setTimeout 写法为什么容易出问题
一个最朴素的实现大概是这样的:
let timer; element.addEventListener('mouseenter', () => { clearTimeout(timer); timer = setTimeout(showTooltip, 300); }); element.addEventListener('mouseleave', () => { clearTimeout(timer); hideTooltip(); });这段代码在单一元素的简单场景下能用,但一旦进入真实页面,问题会逐渐暴露出来:
- 如果鼠标在 A 元素的延迟期内快速移到 B 元素,A 的 leave 清理了 timer,B 的 enter 重新开启 timer。这个过程本身没问题,但如果 A 和 B 使用不同实例,就可能在很短的间隙里出现 A 的 timer 和 B 的 timer 并存。
- 如果 tooltip 已经显示,鼠标离开了 A,但又在隐藏延迟期间回到了 A 的同级子元素,状态会被重置吗?代码里没有记录当前状态,只会机械地 clearTimeout 和 hide。
- 如果组件卸载时没有清理 timer,tooltip 可能在 DOM 节点已经移除后仍然被显示,导致报错或内存泄漏。
这些问题都源于同一个根因:把多个异步事件放在一个变量里管理,却没有区分“当前处于什么阶段”。一个更可靠的方式是引入状态机,让每个事件都根据当前状态决定下一步做什么。
2.2 状态机模型:从空闲到显示到等待隐藏
我们可以把 tooltip 的交互生命周期拆成几个明确状态:
- IDLE:初始状态,tooltip 隐藏,没有任何计时器。
- SCHEDULED_SHOW:已经收到 enter 事件,正在等待显示延迟结束,tooltip 还没出现。
- VISIBLE:tooltip 当前可见。
- SCHEDULED_HIDE:tooltip 可见,但已经收到 leave 事件,正在等待隐藏延迟结束。
除了这四个状态,还需要一个“跳过”标志,通常不单独做成一个长期状态,而是通过检测条件直接回到 IDLE。这样设计的好处是:每个事件发生时,我们只需要看当前状态和触发事件,就能唯一确定下一步操作。
状态转移的关键逻辑:
- IDLE + enter -> SCHEDULED_SHOW,启动显示定时器。
- SCHEDULED_SHOW + leave -> IDLE,取消显示定时器,不显示。
- SCHEDULED_SHOW + 延迟到期 -> VISIBLE,显示 tooltip。
- VISIBLE + leave -> SCHEDULED_HIDE,启动隐藏定时器。
- VISIBLE + enter(进入 tooltip 本身) -> VISIBLE,取消隐藏定时器,保持显示。
- SCHEDULED_HIDE + enter(回到源元素) -> VISIBLE,取消隐藏定时器。
- SCHEDULED_HIDE + 延迟到期 -> IDLE,隐藏 tooltip。
这个模型看着简单,但它能解决实际问题:快速划过、中途返回、进入 tooltip 等边界行为,都能被描述清楚。
2.3 事件如何驱动状态转移
真实项目中,事件来源不只是 mouseenter 和 mouseleave。你还需要处理:
- pointerenter / pointerleave:指针事件,适合现代浏览器统一处理鼠标和触控笔。
- focusin / focusout:键盘用户通过 Tab 切换焦点时需要触发。
- touchstart / touchend:触摸设备上可能需要长按显示。
- scroll / dragstart:页面滚动或拖拽时,应该跳过所有 hover 类型的提示。
- visibilitychange:标签页切换到后台时,隐藏 tooltip 并清理所有定时器。
状态机本身不关心事件来源是什么,它只关心收到的是 enter、leave 还是 skip。在事件绑定层,再把不同来源统一转换成这些语义事件。
这种抽象的另一个好处是,状态逻辑可以独立于 DOM 存在。你可以在不修改 UI 代码的情况下,为它增加单元测试。
2.4 用代码表示核心状态转移
下面是一个简化的状态机示意,用最小代码演示“延迟显示、延迟隐藏、跳过”的核心流程,适合作为理解基础。
class TooltipController { constructor({ showDelay = 300, hideDelay = 150 } = {}) { this.showDelay = showDelay; this.hideDelay = hideDelay; this.state = 'IDLE'; this.showTimer = null; this.hideTimer = null; this.currentTarget = null; } enter(target, context) { // 如果已经可见或正在隐藏中,直接恢复可见 if (this.state === 'VISIBLE' || this.state === 'SCHEDULED_HIDE') { this.clearTimers(); this.state = 'VISIBLE'; return; } this.clearTimers(); this.currentTarget = target; this.state = 'SCHEDULED_SHOW'; this.showTimer = setTimeout(() => { if (this.state === 'SCHEDULED_SHOW' && this.currentTarget === target) { this.state = 'VISIBLE'; context.showTooltip(target); } }, this.showDelay); } leave(context) { if (this.state === 'SCHEDULED_SHOW') { this.clearTimers(); this.state = 'IDLE'; return; } if (this.state === 'VISIBLE') { this.state = 'SCHEDULED_HIDE'; this.hideTimer = setTimeout(() => { if (this.state === 'SCHEDULED_HIDE') { this.state = 'IDLE'; context.hideTooltip(); } }, this.hideDelay); } } skip() { this.clearTimers(); this.state = 'IDLE'; } clearTimers() { clearTimeout(this.showTimer); clearTimeout(this.hideTimer); this.showTimer = null; this.hideTimer = null; } destroy() { this.clearTimers(); this.state = 'IDLE'; this.currentTarget = null; } }这个示例距离生产环境还差几步,但你已经可以看到状态机的骨架:所有分支都根据当前状态来决定下一步,所有定时器都有明确的取消时机。
注意:生产环境里一定要给每个异步回调增加 token 或 id 校验,否则快速切换 target 时,旧定时器的回调可能覆盖新 target 的显示逻辑。
3. 从单次 hover 到批量场景:处理边界条件
3.1 鼠标快速经过多个元素:跳过还是排队
页面里最常出现的不是单独一个按钮,而是一排按钮或一列表格。鼠标从第一个元素快速扫到最后一个元素,如果每个元素都使用独立的状态控制器,A 的显示延迟还没走完,B 的延迟又开始了。最终结果可能是 A 的定时器先触发,在错误的位置弹出一个已经不该出现的 tooltip。
处理思路有几种:
- 使用一个全局单例 controller,所有 tooltip 共享状态。新的 enter 必然会让上一个 target 的 pending 显示失效。
- 在延迟回调里检查当前 target 是否仍然与触发时的 target 一致。如果不一致,直接丢弃回调结果。
- 在全局指针移动处理里记录 lastMoveTime,只有当鼠标在某元素上停留时间超过阈值后,才允许显示逻辑启动。
后两个方案也可以组合。我的建议是:优先用“target token”保证异步回调不会串,再根据交互密度决定是否需要全局单例。
快速扫过多个元素时,最好的结果不是让前一个 tooltip 延迟显示,而是让所有 tooltip 都跳过。这需要你在 enter 逻辑中不仅启动定时器,还要持续监听 pointermove,如果发现鼠标仍在快速移动,就不断重置延迟计时。
3.2 从元素移动到 tooltip 上:延迟关闭的意义
tooltip 一旦可以被鼠标移入,就不仅是“提示”,也是一个小小的信息承载区域。用户可能想选中 tooltip 里的文本,或者点击里面的链接。此时隐藏延迟不只是为了避免闪烁,而是给用户保留一条从源元素到 tooltip 的“安全路径”。
实现上,最常见的做法是同时监听源元素和 tooltip 自身的 mouseenter 和 mouseleave。当鼠标从源元素移出,进入隐藏延迟期时,如果检测到进入 tooltip,取消隐藏定时器。当鼠标从 tooltip 内移出,进入源元素区域,同样要取消隐藏。
隐藏延迟到这里就成了一个“距离缓冲”。它需要覆盖鼠标从源元素移动到 tooltip 之间的那段时间。通常距离越近,缓冲可以越短。如果 tooltip 和源元素之间有明显空隙,hideDelay 就要适当加大,否则用户会在移动过程中触发 tooltip 隐藏。
3.3 键盘焦点和触摸屏要不要延迟
键盘用户使用 Tab 切换焦点时,焦点不会像鼠标那样快速连续经过多个元素。相比之下,焦点切换更像是“明确选择一个元素”。因此对 focus 事件使用过长的显示延迟反而会拖慢操作。一般对 focus 使用 0 到 100ms 的短延迟,或者完全不延迟。
触摸屏场景则更特殊。手指没有 hover 概念,touchstart 本身就有“按下”意图。如果你还沿用鼠标 hover 的显示延迟,用户点击按钮时可能会先看到一个 tooltip,然后又立刻触发了 click 行为,视觉上会很突兀。更合适的做法是把触摸触发模式改为“长按”,或者完全禁用 hover 类型 tooltip,只使用 click 后展示。
实际落地时,建议把触发方式做参数化,而不是在逻辑里写死。
3.4 组件卸载、异步销毁、清理定时器
我见过不少线上问题,不是 tooltip 显示逻辑错了,而是组件销毁后还残留了定时器:
- 页面路由切换后,旧页面上某按钮的 tooltip timer 仍然在运行,随后访问已经卸载的 DOM,导致报错。
- 弹窗组件关闭后,弹窗里某个元素上正在等待显示的 tooltip 没有被清理,下次打开弹窗时会意外弹出上一次的提示。
- 多次打开和关闭同一个组件,累积的定时器越来越多,页面越来越卡。
解决方法很简单:在组件卸载或指令解除绑定的生命周期里调用 destroy 或 clearTimers。状态机设计里必须保证 destroy 能够取消所有 pending 定时器,并把状态重置为 IDLE。
4. 实操落地:从最小实现到可复用 Hook 或指令
4.1 原生 JS 示例:带 token 校验的完整控制器
前面给出的状态机缺少多 target 竞态保护。这里补充一个带 token 的版本,这是生产实现里比较常用的结构。
function createTooltipController({ showDelay = 300, hideDelay = 150 } = {}) { let state = 'IDLE'; let timer = null; let target = null; let token = 0; function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } } function showFor(currentTarget, ctx) { state = 'VISIBLE'; target = currentTarget; ctx.showTooltip(currentTarget); } function hide(ctx) { state = 'IDLE'; target = null; ctx.hideTooltip(); } return { enter(currentTarget, ctx) { const myToken = ++token; clearTimer(); if (state === 'VISIBLE' || state === 'SCHEDULED_HIDE') { state = 'VISIBLE'; target = currentTarget; return; } state = 'SCHEDULED_SHOW'; target = currentTarget; timer = setTimeout(() => { if (myToken === token && state === 'SCHEDULED_SHOW' && target === currentTarget) { showFor(currentTarget, ctx); } }, showDelay); }, leave(ctx) { const myToken = ++token; clearTimer(); if (state === 'SCHEDULED_SHOW') { state = 'IDLE'; target = null; return; } if (state === 'VISIBLE') { state = 'SCHEDULED_HIDE'; timer = setTimeout(() => { if (myToken === token && state === 'SCHEDULED_HIDE') { hide(ctx); } }, hideDelay); } }, skip() { token++; clearTimer(); state = 'IDLE'; target = null; }, destroy() { token++; clearTimer(); state = 'IDLE'; target = null; } }; }token 的作用是让每一次 enter/leave/skip 都拥有新的“身份”。旧的异步回调即使触发了,也会因为 token 不一致被丢弃。这样即使鼠标快速在不同元素之间切换,也不会出现旧定时器覆盖新状态的问题。
4.2 React/Vue 中的封装思路
在 React 中,不需要重复造底层状态机,可以用 hook 把它包装起来。
import { useEffect, useRef, useState, useCallback } from 'react'; function useTooltip({ showDelay = 300, hideDelay = 150 } = {}) { const [visible, setVisible] = useState(false); const controllerRef = useRef(null); if (!controllerRef.current) { controllerRef.current = createTooltipController({ showDelay, hideDelay }); } const enter = useCallback((target) => { controllerRef.current.enter(target, { showTooltip: () => setVisible(true), hideTooltip: () => setVisible(false) }); }, []); const leave = useCallback(() => { controllerRef.current.leave({ showTooltip: () => setVisible(true), hideTooltip: () => setVisible(false) }); }, []); useEffect(() => { const controller = controllerRef.current; return () => controller.destroy(); }, []); return { visible, enter, leave }; }Vue 里则可以封装成自定义指令,把状态逻辑放在指令内部,通过 binding.value 接收参数,在 unmounted 钩子里调用 destroy。
4.3 参数设计建议
不是所有 tooltip 都用同一套延迟。给设计保留参数空间,是这类交互组件能长期使用的重要前提。
| 参数 | 默认值 | 说明 |
|---|---|---|
| showDelay | 300ms | 鼠标进入后显示提示的延迟时间 |
| hideDelay | 150ms | 鼠标离开后隐藏提示的延迟时间 |
| interactive | false | 是否允许鼠标移入 tooltip 进行操作 |
| trigger | hover | 触发方式,可以是 hover、focus、click、longPress |
| skipOnMove | true | 鼠标仍在移动时是否跳过显示 |
| skipOnScroll | true | 页面滚动时是否立即隐藏并跳过 |
| disabled | false | 禁用提示 |
| placement | top | 提示显示的方位,如 top、bottom、left、right |
这些参数要尽量支持按调用处覆盖。全局默认一套,局部调用时按需调整。
建议:不要在业务代码里到处手写工具提示状态管理。把状态机、事件绑定、清理逻辑收拢到一个 controller/hook/directive 里,页面里只关心“把 enter 和 leave 绑定到哪”。
4.4 如何验证效果
实现完之后,不要只靠手摸。可以用下面这组路径自测:
- 慢速悬停目标元素,确认在显示延迟后提示出现。
- 快速扫过多个元素,确认没有任何提示弹出。
- 显示提示后,鼠标快速离开,确认在隐藏延迟后消失。
- 显示提示后,鼠标慢慢移向 tooltip,确认不会在路径上消失。
- 使用键盘 Tab 聚焦元素,确认触达提示,且不会被 mouseenter 的延迟误伤。
- 在手机模拟器或真实设备上触摸,确认没有多余的 hover 闪烁。
- 打开页面后切换到其他标签页,再切回来,确认没有残留 tooltip。
- 在路由切换后,确认旧页面的 tooltip timer 被清理。
5. 排查链路:tooltip 延迟失效/跳过失效时先查哪几层
5.1 先看现象
遇到问题,先分辨现象属于哪一类:
- 延迟不生效,鼠标一进就显示。
- 延迟太久,用户停留很久才显示。
- 不该出现时出现,快速扫过仍然弹窗。
- 闪烁,刚显示又消失,刚消失又显示。
- 残留,鼠标移走很久,tooltip 还在。
- 位置不对,提示出现在旧位置或错误元素上。
- 组件卸载后报错,或触发了不存在 DOM。
每一种现象都对应不同排查方向。不要一上来就调参数。
5.2 输入层:事件源、target、触发方式
先确认事件是否绑定在正确的元素上。常见问题包括:
- 事件绑定在父级,导致鼠标经过子元素时触发多次 enter。
- disabled 元素本身不会触发 mouseenter,需要检查是否把事件绑定到了外层容器。
- 鼠标事件和指针事件混用,多次触发同一个状态。
- 滚动容器内部元素发生位置变化,鼠标没有离开但目标元素已经移动,导致 enter/leave 错乱。
打印一份事件日志,把每次 enter/leave/timer 触发时间记录下来,是最快的定位方式。
5.3 状态层:定时器是否被意外覆盖、是否异步竞态
如果延迟不生效,多半是定时器被立即清理了。比如 enter 后立刻又触发 leave,或滚动事件监听的 skip 把状态重置回 IDLE。
如果弹窗在错误元素上出现,检查 token 机制是否到位。旧 timer 回调是否可以在 target 切换后仍触发。
如果隐藏延迟失效,检查 enter 是否在 VISIBLE 状态下被调用,且没有正确取消隐藏定时器。很多闪烁问题其实是进入 tooltip 的事件没有触发取消隐藏,导致 tooltip 先隐藏,又因为鼠标还在源元素上再次显示。
5.4 环境层:iframe、滚动容器、CSS pointer-events、z-index
tooltip 定位异常或事件丢失,很多时候不是 JavaScript 逻辑问题,而是 CSS 和环境问题。
- tooltip 被另一个元素遮挡,鼠标实际上没有进入 tooltip,进入的是挡在 tooltip 前面的元素。
- tooltip 使用 transform 定位,但在某些滚动容器内没有更新位置。
- iframe 场景下,鼠标移出 iframe 后,父页面无法感知 leave。
- pointer-events:none 可能会导致无法进入 interactive tooltip。
- 触控设备上 hover 状态会被 touch 事件隐式模拟,导致闪烁。
这些环境问题要和状态问题分开排查。先看 DOM 和 CSS,再改 JavaScript。
5.5 边界层:组件卸载、路由切换、多实例互扰
最常见的问题是组件结束时没有销毁 controller。可以在每次路由切换时记录一下日志,看看是否还有旧 timer 回调在运行。
多实例互扰通常发生在全局单例里没有清理 target。如果一个全局 controller 接到了 A 的 enter,但在延迟期用户又滚动到 B 列表,且 B 列表也绑定了同一个 controller,那么 A 的 target 仍然会被记录。更好做法是每个 tooltip 实例拥有独立 controller,但所有 instance 共享一个全局“抑制开关”,由滚动、拖拽、后台切换来触发。
6. 这个需求真正的难点不是延迟,而是“跳过”的意图识别
6.1 为什么“跳过”比“延迟”更难
延迟只是把显示时间往后推,但用户快速扫过某个元素时,可能也会在元素上停留 300ms。如果只靠“延迟 300ms”,你仍然无法区分用户是在认真看按钮,还是因为移动速度慢所以恰好停留了 300ms。
所以“跳过”必须依赖于更多信号:
- 最后一次移动时间距离现在有多久。
- 鼠标移动速度是否超过阈值。
- 当前是否在滚动或拖拽。
- 文档是否处于可见状态。
- 之前是否已经显示过同类的 tooltip。
这些信号需要组合判断。状态机的 skip 事件不是一个单一函数,而是一套规则。
6.2 从鼠标轨迹判断意图:位移、驻留、离开方向
一个常见的意图检测策略是:在元素上监听 pointermove,只计算鼠标在目标区域内的有效停留时间。如果鼠标一直在移动,即使没有离开元素,延迟计时器也会被重置。
简单实现思路:
let lastMoveTime = Date.now(); element.addEventListener('pointermove', () => { lastMoveTime = Date.now(); }); function shouldSuppressShow() { return Date.now() - lastMoveTime < 120; }这个 120ms 的意思不是延迟显示 120ms,而是“如果鼠标刚刚移动过,就不应该认为这是一个稳定悬停”。只有鼠标连续静止超过 120ms,才开始正常进入显示延迟。
更高级的场景可以看鼠标移动方向和速度,比如鼠标正在快速向下滚动菜单,那么即使它悬停在某个菜单项上,也不应该立即显示子菜单。这类需求适合在做复杂菜单系统时引入,对于普通页面 tooltip 来说,先做好“移动重置计时器”就足够了。
6.3 工程经验:默认值怎么设
我处理过几种场景后,有一个比较稳的起步配置:
- 静态文本 tooltip:showDelay 300ms,hideDelay 100ms,interactive false。
- tooltip 内有链接或按钮:showDelay 300ms,hideDelay 200ms,interactive true。
- 图表 canvas 或数据密集区域:showDelay 500ms,hideDelay 100ms,同时必须监听 pointermove 重置计时器。
- 键盘焦点:不设 showDelay,或设置 0-100ms。
这个配置不是绝对标准,但它能避免大多数“一进来就闪”和“想点 tooltip 点不到”的问题。
6.4 适用边界:什么场景不需要复杂设计
并不是所有 tooltip 都需要这套状态机。如果你的项目满足下面任意一条,直接用简单延时即可:
- tooltip 只是替代 title 属性的静态文案。
- 页面上可悬停元素很少,最多一两个。
- 页面不需要键盘焦点触发,也没人在工具提示上点击。
- 你使用的组件库已经内置成熟的 tooltip 交互,且它的默认行为满足业务。
复杂状态机的价值,在处理大量可悬停元素、快速移动、table 场景、图表场景和交互型 tooltip 时才会体现。在只放一个“帮助”图标的页面上写状态机,属于过度设计。
6.5 长期价值:把它变成可复用组件
如果你在多个项目里写过多遍 tooltip,你会发现最难的不是显示,而是把所有边界条件放回一个可控模型里。
状态机方案最大的长期价值,是它把晦暗的异步交互变成了可枚举的转移表。定时器不再是散落的setTimeout,而是受状态约束的步骤。你只需要维护一张表:当前状态 + 触发事件 -> 下一个状态 + 动作。
这也解释了为什么一开始那句话值得思考:“工具提示需要延迟,然后需要跳过它”。延迟是让反馈更有分寸,跳过是让反馈更聪明。两者合在一起,才是交互反馈里真正值得沉淀的能力。下次再处理 hover 弹层、菜单展开、自动补全这类交互时,不妨先画一版状态机,再动代码。