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 行,你大概會發現一兩個自己在自家程式碼庫裡重寫了多年的。