2026年5月25日

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

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

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

workaround。

ReactUse 提供六个小而专的指针 hook,正好补上这些缺口。本文逐个拆解

bug、hook 是怎么改的、以及一个你真的会写出来的组件示例。如果你看过关于 ref 逃生舱的那篇,有个细节会眼熟——这些 hook 内部大多用了 useLatest,让监听器在回调身份变动时依然稳定。

为什么指针事件是沼泽

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

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 返回一个布尔值,代表光标当前是否在目标元素内。签名就是你自己会写的样子:

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 告诉你指针有没有按在元素上——并把鼠标、触摸、拖拽区分成不同的来源,让你可以对每种做不同的反应。

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 时清掉:

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 已经替你做完了这些簿记:

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 数,等过了间隔,再按数量分发是单击还是双击:

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 回到它的逻辑父节点。

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 监听器,代价会累积。

要注意的一点

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

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

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

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 里的四个组合在一起的小例子。长按打开上下文菜单,菜单点击外部关闭,触发器在按压期间显示按下态,菜单项支持双击执行”默认动作”:

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;“按下的按钮”和”获得焦点的按钮”是两回事,而且通常你两者都要。

安装

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 行,你大概会发现一两个自己在自家代码库里重写了多年的。