React 指针 Hook:Hover、长按、双击、刮擦和点击外部,告别那些经典 bug

指针事件是 React 中最少被认真讨论的部分,因为大家默认它"早就被解决了"。它没有。标准答案------onMouseEnteronClick、给双击加一个 setTimeout、用 window 监听器实现点击外部------在 demo 里都能跑,到了生产环境就全坏。光标越过子元素时它会闪烁。触摸结束 300ms 后它会触发一个 iOS 幽灵点击。它看不到 portal 渲染出去的元素。它把一次双击当成两次单击,因为第二次点击的处理器在第一次还没被取消之前就先跑了。

DOM 事件模型就这样。浏览器在移动端和桌面端用了不同的手势管线,dblclick 规范比 React 还老,而 composedPath() 是穿过 shadow 边界与 portal 唯一可靠的方法。这些都不会变。能变的是:你的应用里每个组件是不是都要从头重写一遍这些 workaround。

ReactUse 提供六个小而专的指针 hook,正好补上这些缺口。本文逐个拆解:朴素版本里的 bug、hook 是怎么改的、以及一个你真的会写出来的组件示例。如果你看过关于 ref 逃生舱的那篇,有个细节会眼熟------这些 hook 内部大多用了 useLatest,让监听器在回调身份变动时依然稳定。

为什么指针事件是沼泽

举个两行例子。一个点击外部就关闭的下拉菜单:

tsx 复制代码
function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handler(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, []);

  return <div ref={ref}>{open && <Menu />}</div>;
}

四个问题。第一,没有 touchstart 监听,移动端关不掉。第二,contains 不跨 portal------如果 <Menu /> 渲染到了 document.body,点菜单项反而会把菜单关掉。第三,handler 用的是 Element.contains 而不是 composedPath(),所以 shadow root 里的任何东西都被当作"外部"。第四,handler 闭包了初次的 setOpen;父组件传新的 onClose 进来,监听器还是在调老的那个,因为 effect 只在挂载时绑定了一次。

每个问题都是一行就能修。每个一行的修复加起来,就是 hook 为什么写出来是 25 行而不是 5 行。这就是整个论点。

1. useHover ------ 不会闪烁的悬停状态

useHover 返回一个布尔值,代表光标当前是否在目标元素内。签名就是你自己会写的样子:

tsx 复制代码
import { useRef } from 'react';
import { useHover } from '@reactuses/core';

function Tooltip({ children, label }: { children: React.ReactNode; label: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const hovered = useHover(ref);

  return (
    <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
      {children}
      {hovered && <div className="tooltip">{label}</div>}
    </div>
  );
}

两个细节。hook 监听的是 mouseentermouseleave,不是 mouseovermouseoutmouseover 会冒泡,光标跨进任何子元素都会再触发一次,结果你大部分时间都在 truefalse 之间闪。mouseenter 不冒泡------光标进入外层元素时触发一次,离开时触发一次,不管底下嵌了几层子节点。这也是 CSS :hover 在嵌套元素上不会闪的原因:浏览器其实造好了正确的原语,只是把它藏在一个不那么显眼的事件名后面。

另一个细节:useHover 接收的是 target ref,而不是 callback ref。hook 通过 ReactUse 的 BasicTarget 辅助类型解析目标,所以你可以传 ref、DOM 节点,或者返回这两者之一的函数------当目标元素来自另一个 hook(比如 useDraggable)时很有用。

2. useMousePressed ------ 按下状态,还告诉你按的来源

hovered 告诉你指针是不是在元素上方。useMousePressed 告诉你指针有没有按在元素上------并把鼠标、触摸、拖拽区分成不同的来源,让你可以对每种做不同的反应。

tsx 复制代码
import { useRef } from 'react';
import { useMousePressed } from '@reactuses/core';

function PressyButton({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLButtonElement>(null);
  const [pressed, source] = useMousePressed(ref, { touch: true, drag: false });

  return (
    <button
      ref={ref}
      className={pressed ? 'pressed' : ''}
      data-source={source} // 'mouse' | 'touch' | null
    >
      {children}
    </button>
  );
}

返回元组里有两个值:布尔值,以及一个 sourceType,值为 'mouse' | 'touch' | null。来源比看上去重要得多。触摸按压不应该走 hover 风格的过渡动画,因为用户的手指正好挡住了元素。拖拽开始时的按压不应该触发按钮的 onClick------你可以用 source 决定要不要忽略这次释放。hook 自己处理监听器清理,包括容易忘掉的 dragendtouchcancel;如果你曾上线过一个"用户拖出去之后还卡在按下态"的按钮,这就是这个 hook 关掉的 bug。

监听目标的选择也有讲究。mousedown 绑在元素上,但 mouseupmouseleave 绑在 window 上。这是故意的:如果用户按在按钮上、却在外面松开,你也要能看到这次释放。把 mouseup 绑在元素自己上就会错过这种情况------按钮会一直保持"按下"态,直到用户回来再点一次。

3. useLongPress ------ 长按不带 iOS 幽灵点击

长按就是按住一段可配置的时间后再触发。朴素写法是 mousedown 起一个 setTimeout,mouseup 时清掉:

tsx 复制代码
function LongPressable({ onLongPress }: { onLongPress: () => void }) {
  const timer = useRef<number>();
  return (
    <div
      onMouseDown={() => { timer.current = window.setTimeout(onLongPress, 500); }}
      onMouseUp={() => clearTimeout(timer.current)}
    />
  );
}

桌面没问题。在 iOS Safari 上,用户从长按上抬起手指后,系统会在 300ms 后再触发一个合成的 click 事件------"幽灵点击"------它会触发用户手指落到的下一个元素上的某个无关 handler。修复办法是给被按住的元素挂一个一次性的 touchend 监听器并 preventDefault,而 useLongPress 已经替你做完了这些簿记:

tsx 复制代码
import { useLongPress } from '@reactuses/core';

function MessageBubble({ message }: { message: Message }) {
  const [showActions, setShowActions] = useState(false);

  const longPress = useLongPress(
    () => setShowActions(true),
    { delay: 500, isPreventDefault: true },
  );

  return (
    <div className="bubble" {...longPress}>
      {message.text}
      {showActions && <ActionSheet onClose={() => setShowActions(false)} />}
    </div>
  );
}

hook 返回一组事件处理器对象------onMouseDownonMouseUponMouseLeaveonTouchStartonTouchEnd------你把它展开到元素上,监听器布线就走在 React 的合成事件系统里,而不是裸 addEventListener。这点很重要:合成事件能和 React 的状态更新正确批处理;长按打开一个弹窗,不会像手写 addEventListener 那样多出两次渲染。

isPreventDefault 默认 true,除了滚动场景外几乎都该开着。需要关掉它的一种典型场景是:长按的目标同时可能是用户想滚动经过的东西,比如长按某个列表项打开上下文菜单,但垂直滑动应该继续滚动列表。

4. useDoubleClick ------ 单击 vs 双击,不竞态

浏览器有 dblclick 事件,但它是在两次 click 之外 再触发一次,不是替代。如果你同时挂 onClickonDoubleClick,每次双击都会顺带触发两次单击 handler。标准修法是开一个去抖窗口------数 click 数,等过了间隔,再按数量分发是单击还是双击:

tsx 复制代码
import { useRef } from 'react';
import { useDoubleClick } from '@reactuses/core';

function FileRow({ file }: { file: File }) {
  const ref = useRef<HTMLDivElement>(null);

  useDoubleClick({
    target: ref,
    latency: 250,
    onSingleClick: () => selectFile(file),
    onDoubleClick: () => openFile(file),
  });

  return <div ref={ref} className="row">{file.name}</div>;
}

useDoubleClick 接收一个 target、两个回调和一个 latency。点一下,等 latency 毫秒;期间没别的就是单击。latency 内点两下,就是双击,单击回调不会再触发。默认 300ms 和大多数桌面文件管理器对齐;UI 要更利索可以压到 200ms,面向年长用户或触摸优先的界面可以拉到 500ms。

hook 也会对 touchend 调用 preventDefault,把 iOS 的"双击缩放"行为提前拦下来,否则用户双击一条列表项的时候,页面会被缩放。这种默认行为你不会注意到,直到它缺席,然后内测同学开始报 bug。

5. useClickOutside ------ 点击外部就关闭,穿透 portal

useClickOutside(也以 useClickAway 的别名导出,兼容旧 API 命名)就是"用户点到别处就关掉"的那个 hook。朴素的 contains 在 portal 和 shadow DOM 上会失效;hook 用的是 composedPath(),它会走完事件经过的完整路径,包括穿过 shadow 边界和 portal 回到它的逻辑父节点。

tsx 复制代码
import { useRef, useState } from 'react';
import { useClickOutside } from '@reactuses/core';

function Popover({ trigger, children }: { trigger: React.ReactNode; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useClickOutside(ref, () => setOpen(false));

  return (
    <div ref={ref} className="popover-root">
      <button onClick={() => setOpen((o) => !o)}>{trigger}</button>
      {open && <div className="popover-content">{children}</div>}
    </div>
  );
}

hook 同时监听 mousedowntouchstart,不是 clickmousedownmouseupclick 之前触发,意思是按压一发生下拉就关------比 click 事件触发到目标元素上的任何 handler 都还早。手感是对的。如果你听的是 click,目标元素上的 click handler 会先跑、然后下拉才关;要是这个 handler 还顺手打开了一个 modal,你就会看到 modal 闪一下、然后下拉的关闭再涌过来。

第三个参数是 enabled 布尔。菜单隐藏时传 false,完全不跑监听器------小事,但页面上要是有五十个下拉,你就有五十个全局 mousedown 监听器,代价会累积。

要注意的一点:hook 通过 useLatest 闭包 handler,所以即便你每次渲染都传一个新函数,监听器也保持稳定。也就是说你可以放心写 useClickOutside(ref, () => setOpen(false)) 这种内联写法,不用担心监听器重绑------和 ref 逃生舱 那篇详细讲过的是同一个套路。

6. useScratch ------ 拖拽过程中元素内相对坐标

useScratch 是任何"需要知道拖拽时指针在元素哪里 "的 UI 的主力------颜色选择器、签名板、框选、需要像素级精确跟踪的滑块滑块。hook 返回一个 state 对象,包含按压起点位置、当前位置、与上一帧的增量、是否正在 scratching。

tsx 复制代码
import { useRef } from 'react';
import { useScratch } from '@reactuses/core';

function ColorPicker() {
  const ref = useRef<HTMLDivElement>(null);
  const { x, y, isScratching } = useScratch(ref);

  const hue = x != null ? (x / 240) * 360 : 0;

  return (
    <div
      ref={ref}
      style={{
        width: 240,
        height: 24,
        background: 'linear-gradient(to right, red, yellow, lime, cyan, blue, magenta, red)',
        position: 'relative',
        cursor: 'crosshair',
      }}
    >
      {x != null && (
        <div
          style={{
            position: 'absolute',
            left: x - 2,
            top: 0,
            width: 4,
            height: 24,
            background: isScratching ? '#000' : '#444',
            pointerEvents: 'none',
          }}
        />
      )}
    </div>
  );
}

两个实现细节值得知道。第一,位置更新走的是 useRafState,React 最多每帧重渲染一次------手指 120Hz 划过元素,组件还是按 60Hz 渲染。没有 rAF 批处理的话,一次快速拖动会按每个 mousemove 来一次渲染,高 DPI 触屏上一秒就是上百次。

第二,hook 把 mousemovemouseup 监听器挂在 document 上,只有 mousedown 挂在元素上。这也是 useMousePressed 监听 window 的原因------按压一旦开始,拖拽就可能离开原来的包围盒,你仍然要跟踪。监听器要是挂在元素上,用户往外拖几个像素手势就断了。

回调------onScratchonScratchStartonScratchEnd------通过 useLatest ref 读取,所以你可以传捕获组件 state 的闭包而不打破 memoization。签名板模式很典型,onScratch 需要用最新的 strokeColor 往 canvas 上画。

组装起来:一个上下文菜单

一个把这些 hook 里的四个组合在一起的小例子。长按打开上下文菜单,菜单点击外部关闭,触发器在按压期间显示按下态,菜单项支持双击执行"默认动作":

tsx 复制代码
import { useRef, useState } from 'react';
import {
  useLongPress,
  useMousePressed,
  useClickOutside,
  useDoubleClick,
} from '@reactuses/core';

function ContextMenuItem({ label, onSelect }: { label: string; onSelect: () => void }) {
  const ref = useRef<HTMLLIElement>(null);
  useDoubleClick({
    target: ref,
    latency: 200,
    onSingleClick: () => {/* 与 hover 等价:不做事 */},
    onDoubleClick: onSelect,
  });
  return <li ref={ref}>{label}</li>;
}

function ContextTarget({ items }: { items: Array<{ label: string; onSelect: () => void }> }) {
  const triggerRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);
  const [open, setOpen] = useState(false);

  const [pressed] = useMousePressed(triggerRef, { drag: false });
  const longPress = useLongPress(() => setOpen(true), { delay: 400 });

  useClickOutside(menuRef, () => setOpen(false), open);

  return (
    <>
      <div
        ref={triggerRef}
        className={`target ${pressed ? 'pressed' : ''}`}
        {...longPress}
      >
        按住我
      </div>
      {open && (
        <ul ref={menuRef} className="menu">
          {items.map((item) => (
            <ContextMenuItem key={item.label} {...item} />
          ))}
        </ul>
      )}
    </>
  );
}

四个 hook,调用方各十行代码。不用它们的等价组件,在你处理完 iOS 幽灵点击、portal 友好的点击外部、rAF 批处理的按下态、单击双击分发之后,大概要 120 行。十行意图 vs 一百行管线------这个比例就是把库装上、而不是把同一份 workaround 粘到十个组件里的理由。

什么时候用哪个

你想响应的是
光标进入 / 离开某个元素 useHover
指针当前是否按在某个元素上 useMousePressed
长按 N 毫秒(尤其是移动端) useLongPress
单击 vs 双击,不会被双触发 useDoubleClick
元素之外任何地方的点击(下拉、modal、弹层) useClickOutside
拖拽时指针在元素内的位置 useScratch

两条非规则。如果你想要一个能跟着指针移动的可拖元素(浮层面板、便签),用 useDraggable ------useScratch 给你坐标但不会动元素。如果你想要的是焦点而不是按压,用 useFocususeActiveElement;"按下的按钮"和"获得焦点的按钮"是两回事,而且通常你两者都要。

安装

bash 复制代码
npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

六个 hook 都能单独 tree-shake------import useHover 不会把 useScratch 一起拖进来。每个都带 TypeScript 类型,客户端渲染应用与 SSR 框架(Next.js、Remix、Astro)都能用;需要 DOM 的监听器在服务端会 no-op,hook 在 hydration 之前返回安全默认值。

相关 Hook

如果指针交互是你的瓶颈,有两篇相邻的 ReactUse 文章值得一读。Observer hook 那篇 讲了 useIntersectionObserveruseResizeObserveruseMutationObserver------当"用户做了 X"应该变成"元素进入了 Y 状态"时,它们就是正确的原语。ref 逃生舱 那篇讲了 useLatestuseEvent,本文里每个 hook 内部都用它们来保持闭包安全;理解它们之后,这些手势 hook 的源码会好读得多。

reactuse.com 浏览全套,或者直接打开上面任一 hook 的源码------大多数都不到 40 行,你大概会发现一两个自己在自家代码库里重写了多年的。

相关推荐
Csvn11 小时前
前端技术 - WebAssembly
前端·d3.js
咕噜咕噜啦啦12 小时前
从spring到spring boot——JAVA项目开发
java·前端·spring boot·后端·spring
Bigger12 小时前
Agent 循环:AI 助手的思考引擎
前端·ai编程·claude
yqcoder12 小时前
数据的“洁癖”管家:深入解析 JavaScript Set
开发语言·javascript·ecmascript
专注VB编程开发20年12 小时前
b4a用VB语言开发安卓APP-图片缩放库ZoomImageView讲解-双指缩放 + 单指拖动核心源码
android·java·前端
之歆12 小时前
Day16_JavaScript Event 对象深度解析(上篇)
开发语言·javascript·ecmascript
Mike_jia12 小时前
ShipShipShip:构建你的专属产品发布中心与社区互动平台
前端
a11177612 小时前
闪卡网页 第五人格 html 开源
前端·html
冴羽yayujs12 小时前
前端周报:Google I/O 发布 Agentic Web、TypeScript 6.0 正式版、npm 安全新策略
前端·javascript·前端开发·前端学习·前端周报