起因
在日常的前端开发中,悬浮展示是一个非常常见的场景,比如下拉菜单、提示框和弹出菜单等。如果你是第一次实现这些功能,可能会写出如下代码:
// DO NOT USE THIS CODE
referenceElement.addEventListener("mouseenter", () => { // show content});referenceElement.addEventListener("mouseleave", () => { // hide content});这段代码看起来很简单,用于显示 tooltip 是没有问题的。然而,在下拉菜单这种需要交互的场景中,当用户从主菜单移动鼠标到子菜单时,子菜单会迅速消失。
为了避免这个问题,我们可以将子菜单的 DOM 放在主菜单内部,这样鼠标移出到子菜单上时,就不再会触发 mouseleave 事件了。

然后就出现了上述情况,为什么还是不行?原来设计师为了美观,在主菜单和子菜单之间留了一点空白,导致鼠标还未到达子菜单上时就触发了 mouseleave 事件。
延迟
我们可以加一点延迟,这样用户移动鼠标时,悬浮菜单不会立刻消失。顺便,有了延迟之后我们就可以把悬浮元素以 portal 的方式挂载到 body 上,可以一劳永逸地解决 z-index 问题。当然记得要给悬浮元素也绑定鼠标事件。
const DELAY = 300;const abortController = new AbortController();let timeoutId: number | undefined;
abortController.signal.addEventListener("abort", () => { // hide content});
referenceElement.addEventListener("mouseenter", () => { clearTimeout(timeoutId); // show content});floatingElement.addEventListener("mouseenter", () => { clearTimeout(timeoutId);});
referenceElement.addEventListener("mouseleave", () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => abortController.abort(), DELAY);});floatingElement.addEventListener("mouseleave", () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => abortController.abort(), DELAY);});细节补充
如果你的 referenceElement 比较大或者这个这个元素可能是中途渲染的,可能会出现鼠标一直在 referenceElement 的内部的情况。此时仅在这个元素内部移动鼠标并不会触发 mouseenter 事件。因此,我更推荐使用 mouseover 事件而不是 mouseenter。
另外,为了防止在添加事件监听器时,用户的鼠标已经在元素上,我们可以使用下面的代码判断并手动触发 hover 事件。
// see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ouconst alreadyHover = element.matches(":hover");if (alreadyHover && !abortController.signal.aborted) { // When the element is already hovered, we need to trigger the callback manually onHoverChange(new MouseEvent("mouseover"));}封装
每次使用都要写这么多代码实在太麻烦,我们可以将上述代码封装成一个函数,方便在其他地方使用。
查看代码
/** * Call the `whenHoverChange` callback when the element is hovered. * * After the mouse leaves the element, there is a 300ms delay by default. * * Note: The callback may be called multiple times when the mouse is hovering or hovering out. * * See also https://floating-ui.com/docs/useHover * * @example * ```ts * let hoverTooltip: HTMLElement | null = null; * const { setReference, setFloating } = whenHover(isHover => { * if (!isHover) { * hoverTooltip?.remove(); * return; * } * const hoverTooltip = document.createElement('div'); * document.body.append(hoverTooltip); * setFloating(hoverTooltip); * }, { hoverDelay: 500 }); * * const referenceElement = document.querySelector('.reference'); * setReference(referenceElement); * ``` */function whenHover( whenHoverChange: (isHover: boolean, event?: Event) => void, { leaveDelay = 300, alwayRunWhenNoFloating = true }: WhenHoverOptions = {},) { /** * The event listener will be removed when the signal is aborted. */ const abortController = new AbortController(); let hoverState = false; let hoverTimeout = 0; let referenceElement: Element | undefined; let floatingElement: Element | undefined;
const onHover = (e: Event) => { clearTimeout(hoverTimeout); if (!hoverState) { hoverState = true; whenHoverChange(true, e); return; } // Already hovered if ( alwayRunWhenNoFloating && (!floatingElement || !floatingElement.isConnected) ) { // But the floating element is not ready // so we need to run the callback still whenHoverChange(true, e); } };
const onHoverLeave = (e: Event) => { clearTimeout(hoverTimeout); hoverTimeout = window.setTimeout(() => { hoverState = false; whenHoverChange(false, e); }, leaveDelay); };
const addHoverListener = (element?: Element) => { if (!element) return; // see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ou const alreadyHover = element.matches(":hover"); if (alreadyHover && !abortController.signal.aborted) { // When the element is already hovered, we need to trigger the callback manually onHover(new MouseEvent("mouseover")); } element.addEventListener("mouseover", onHover, { signal: abortController.signal, }); element.addEventListener("mouseleave", onHoverLeave, { signal: abortController.signal, }); }; const removeHoverListener = (element?: Element) => { if (!element) return; element.removeEventListener("mouseover", onHover); element.removeEventListener("mouseleave", onHoverLeave); };
const setReference = (element?: Element) => { // Clean previous listeners removeHoverListener(referenceElement); addHoverListener(element); referenceElement = element; };
const setFloating = (element?: Element) => { // Clean previous listeners removeHoverListener(floatingElement); addHoverListener(element); floatingElement = element; };
return { setReference, setFloating, dispose: () => { abortController.abort(); }, };}进阶
上面的实现已经可以解决大部分问题了。如果你想要更好的用户体验,就会注意到在嵌套菜单的场景,用户很容易不小心移出菜单区域,导致菜单关闭。

解决方案
1. Safe Bridge
当悬浮菜单和其关联区域之间存在一小片空白时,我们可以创建一个虚拟的安全区域。这样,即便鼠标悬停在两片区域中间,依然会被判定为悬浮在菜单上。

2. Safe Triangles
这个方案与 Safe Bridge 类似,只是在悬浮菜单和关联区域之间创建一个安全三角形区域。当鼠标悬停在这个三角形区域内时,也会判定为悬浮在菜单上。

最终实现
上述两种方案都需要计算悬浮元素和参考元素的位置,以及安全区域的大小。完整实现相对复杂,感兴趣的朋友可以参考以下链接查看完整代码。
hover - toeverything/blocksuite
另外,如果你正在使用 React 开发,Floating UI 已经封装了这些场景。你可以直接使用这个成熟的解决方案 useHover - Floating UI 来处理悬停问题。
References
- feat(page): safe triangle for whenHover API - blocksuite
- https://www.smashingmagazine.com/2023/08/better-context-menus-safe-triangles/
- https://floating-ui.com/docs/usehover#safepolygon
- https://github.com/shoelace-style/shoelace/pull/1600
- Breaking down Amazon’s mega dropdown
- a couple of pieces of glass sitting on top of a table - unsplash