这是一个「按 Esc 关闭弹窗」,它悄悄地做错了事:
tsx
function Modal({ onClose }: { onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return <div role="dialog">...</div>;
}
如果父组件传的是内联的 onClose={() => setOpen(false)}------而它几乎总是------那么 onClose 每次渲染都是一个新函数,于是这个 effect 会在父组件每一次渲染 时都把监听拆掉再重新挂上。把 onClose 从依赖里删掉来止住这种抖动,你就换来了另一个 bug:监听器从此永远持有第一次渲染的 onClose,关闭弹窗调用的是一个过期闭包。
这一局靠依赖数组是赢不了的,因为你想要的两件事直接冲突:只订阅一次 ,但永远执行最新的处理器 。解法是把两者拆开------用一个稳定的身份去注册监听,再通过一个始终保持最新的 ref 去调用。useEventListener(来自 @reactuses/core)就是把这个拆分封装好了。这篇文章讲它底层到底做了什么、指定目标的四种写法、每种写法下 TypeScript 究竟推断出什么(这部分会让不少人意外)、哪些 options 不会触发重挂,以及上线前值得知道的两个坑。
快速上手
bash
npm install @reactuses/core
tsx
import { useEventListener } from "@reactuses/core";
function Modal({ onClose }: { onClose: () => void }) {
useEventListener("keydown", (e) => {
if (e.key === "Escape") onClose();
});
return <div role="dialog">...</div>;
}
修复就这些。没有依赖数组,父组件不用 useCallback,也没有需要你记着写的清理。监听在组件挂载时被加到 window 上一次,卸载时移除;你传进去的箭头函数每次渲染都会重新创建,但这无所谓,因为监听器从不重新注册------它调用的永远是最新那个。e 是 KeyboardEvent,推断出来的,不用手写注解。
签名是四个参数,其中三个可选:
tsx
useEventListener(eventName, handler, target?, options?);
target 默认是 window。options 就是你平时传给 addEventListener 的那个 boolean | AddEventListenerOptions。
它到底做了什么
实现短到可以整段读完,而且值得读,因为每一行都在回答上面的某个问题:
tsx
function useEventListener(eventName, handler, element, options = {}) {
const savedHandler = useLatest(handler);
const { key: elementKey, ref: elementRef } = useStableTarget(element, defaultWindow);
useDeepCompareEffect(() => {
const targetElement = getTargetElement(elementRef.current, defaultWindow);
if (!(targetElement && targetElement.addEventListener)) return;
const eventListener = (event) => savedHandler.current(event);
on(targetElement, eventName, eventListener, options);
return () => off(targetElement, eventName, eventListener);
}, [eventName, elementKey, options]);
}
这里塞进了四个决策:
处理器放在 ref 里,不放在依赖里。 useLatest 让 savedHandler.current 在每次提交渲染后都指向最新的处理器,而真正注册到 DOM 上的是一个转发给它的薄包装。所以你传的处理器每次渲染都可以是全新的闭包------内联箭头函数不只是被允许,它就是预期的用法------而 addEventListener 只会被调用一次。这就是「订阅一次、执行最新」的拆分,也是 handler 被刻意排除在依赖列表之外的原因。
依赖列表是深比较的。 这个 effect 是 useDeepCompareEffect 而不是 useEffect,所以每次渲染新建但内容相同的 options 对象不算变化。直接内联写 useEventListener("scroll", onScroll, ref, { passive: true }) 完全没问题:渲染三次,addEventListener 只调用一次。把内容改成 { passive: false } 它才会重新注册,这正是你想要的。
目标是在 effect 内部、也就是 commit 阶段解析的。 getTargetElement 在 effect 体里执行而不是在渲染期间,所以 ref 类型的目标此时已经被 React 填好了------渲染期间 ref.current 还是 null,只有到 commit 阶段才是真实节点。这就是「监听挂上了」和「悄无声息什么都没发生」之间的差别。
在服务端它是空函数。 导出的是 isBrowser ? implementation : noop,所以 SSR 期间不会碰 window,你也不用自己写 typeof window === "undefined" 的守卫。监听会在 hydration 之后、在 effect 里挂上,和其他浏览器订阅一样。
指定目标的四种写法
target 接受四种形态,选对它基本就是这个 API 的全部:
tsx
// 1. 省略 → window
useEventListener("resize", () => setWidth(window.innerWidth));
// 2. 返回元素的函数 → document,或任何需要延迟查找的东西
useEventListener("visibilitychange", () => setActive(!document.hidden), () => document);
// 3. 一个 ref
const boxRef = useRef<HTMLDivElement>(null);
useEventListener("wheel", (e: WheelEvent) => e.preventDefault(), boxRef, { passive: false });
// 4. 任何你手上已有的 EventTarget
useEventListener("message", (e: MessageEvent) => handle(e.data), worker);
第 2 种存在的理由是:服务端在模块求值阶段拿不到 document;而且直接把临时查到的元素传进去,会带来「每次渲染身份都变」的问题。包装函数在 commit 阶段被解析,effect 依赖的是它的返回结果 ,所以 () => document 在关键意义上是稳定的。
第 4 种是大家最容易忘的:EventTarget 不只是 DOM 元素。Worker、WebSocket、EventSource、MediaQueryList、window.visualViewport、BroadcastChannel、一个 <audio> 元素、navigator.serviceWorker,甚至 AbortSignal------全都是事件目标,全都能用这个 hook,并且自动清理。(常见的那几个,库里已经有专门的 hook:useEventSource、useBroadcastChannel、useMediaQuery、useNetwork。)
TypeScript 到底推断出什么
这部分值得说精确,因为这个 hook 带了六个重载,而它们给你的东西并不一样。以下结论是对着当前类型定义验证过的:
| 目标写法 | e 被推断为 |
|---|---|
省略(window) |
精确的 WindowEventMap 类型------"keydown" → KeyboardEvent ✅ |
裸 HTMLElement / Element / Document |
精确的事件类型------"click" → MouseEvent ✅ |
| ref 对象 | any ⚠️ |
函数 目标(() => document) |
any ⚠️ |
后两种会落到通用重载上,那个重载把处理器标成 (...p: any) => void。不会报错------但你恰好在 ref 最常用的地方丢掉了补全和类型检查。修法是加一个注解,没有任何代价:
tsx
// ⚠️ e 是 any
useEventListener("click", (e) => console.log(e.clientX), buttonRef);
// ✅ e 是 MouseEvent,有类型检查
useEventListener("click", (e: MouseEvent) => console.log(e.clientX), buttonRef);
同一片区域还有两个相关的锋利边缘。第一,e 是原生 DOM 事件,不是 React 的 SyntheticEvent------e.target 没有类型,e.currentTarget 是 EventTarget | null,也不存在事件池的问题。第二,只有当目标命中那几个有类型的重载时,事件名称 才受约束;用 ref 或函数目标时名称就是普通 string,所以像 "keydwon" 这样的拼写错误能顺利编译,然后挂上一个永远不会触发的监听。如果某个监听看起来是死的,先查拼写,再查别的。
几种模式
键盘快捷键
window 监听的典型场景。一个快捷键一个 hook,或者一个处理器里 switch,都行------因为它们都不会重新注册:
tsx
function useShortcut(combo: (e: KeyboardEvent) => boolean, run: () => void) {
useEventListener("keydown", (e) => {
if (combo(e)) {
e.preventDefault();
run();
}
});
}
function CommandBar() {
const [open, setOpen] = useState(false);
useShortcut((e) => (e.metaKey || e.ctrlKey) && e.key === "k", () => setOpen(true));
useShortcut((e) => e.key === "Escape", () => setOpen(false));
// ...
}
注意这里的组合方式:useEventListener 很适合作为你自己 的 hook 的基础原语;而且因为处理器是用 ref 持有的,run 和 combo 可以是闭包在最新 state 上的内联函数,不需要任何 memo 化的仪式。如果你只需要修饰键本身,useKeyModifier 已经在跟踪它们了。
非 passive 的 wheel 与 touch 监听
这是 JSX 属性真的做不到的场景。React 把 onWheel、onTouchStart 作为 passive 监听挂在根节点上,所以在里面调用 e.preventDefault() 会打出一条 console 警告,然后什么也不做。要真正拦住滚动或缩放,你需要在元素上注册一个 { passive: false } 的真实监听:
tsx
function ZoomCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
useEventListener(
"wheel",
(e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault(); // 有效------这个监听是真正的非 passive
setZoom((z) => clamp(z * (1 - e.deltaY / 500), 0.5, 4));
},
canvasRef,
{ passive: false },
);
return <div ref={canvasRef} style={{ transform: `scale(${zoom})` }} />;
}
反过来同样有用:给高频的 scroll 或 touchmove 监听标上 { passive: true },让浏览器知道它滚动前不必等你的处理器。
React 没有提供 props 的 window / document 事件
resize、online/offline、visibilitychange、beforeunload、hashchange、storage、document 级别的 paste------这些都没有 JSX 对应物,而且每个都只要一行:
tsx
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEventListener("online", () => setOnline(true));
useEventListener("offline", () => setOnline(false));
return online;
}
在动手写之前,先看看库里是不是已经有了------useWindowSize、useOnline、useDocumentVisibility、usePageLeave、useTextSelection 全都是这个 hook 的薄封装,只是状态管理已经替你做好了。
给高频事件限流
scroll、mousemove、resize、pointermove 的触发频率远高于你想重新渲染的频率。要包的是处理器,不是监听:
tsx
function ScrollSpy() {
const [y, setY] = useState(0);
const onScroll = useThrottleFn(() => setY(window.scrollY), 100);
useEventListener("scroll", onScroll);
return <progress value={y} max={document.body.scrollHeight} />;
}
「最多每 N 毫秒一次」用 useThrottleFn,「等用户停下来再执行」用 useDebounceFn。两者身份都稳定,所以监听依然只注册一次。如果你要的就是滚动位置,useScroll 和 useWindowScroll 已经把这件事做对了。
值得知道的坑
-
ref 目标依赖的是 ref 本身,不是
ref.current。 effect 的依赖项是那个 ref 对象 ,它在组件生命周期内是稳定的。所以当 ref 背后的 DOM 节点被换掉时------某个条件分支挂载了一个真正不同的元素、key变了、列表重排------监听会留在那个已经脱离文档的旧节点上,不会跟过去。元素类型和位置匹配时 React 通常会复用同一个 DOM 节点,所以这个坑很少踩到,但一旦踩到就很难想明白。修法是把节点本身变成依赖:用 callback ref 把它存进 state。tsxconst [node, setNode] = useState<HTMLElement | null>(null); useEventListener("click", (e: MouseEvent) => handle(e), node); return show ? <button ref={setNode}>A</button> : <span ref={setNode}>B</span>;这样节点变化时目标身份也跟着变,监听就会重新注册到新元素上。
-
监听是在绘制之后挂上的,不是在渲染期间。 它是个 effect,所以从首次绘制到 effect 执行之间有一个窗口------通常是一帧------监听还不存在。对用户驱动的事件无所谓(没人按键有那么快),但这意味着你没法用它去捕获挂载期间就触发的事件。如果某个元素从第一帧起就必须带上监听,那是 layout effect 和 JSX 属性该管的事。
-
直接传
document或元素没问题------除非它是条件性的。useEventListener("click", h, someState ? elA : elB)会在元素变化时重新注册,这是对的。但useEventListener("click", h, document.getElementById("x"))会在每次渲染时做一次 DOM 查询,并且在服务端返回null;这种情况用函数形式() => document.getElementById("x")。 -
它不返回
off()句柄。 和 VueUse 的版本不同,这里没有手动停止函数------生命周期就是组件的生命周期。如果你需要按需启停监听,在处理器内部用一个 ref 或一段 state 来做闸门(if (!enabledRef.current) return),这比反复重新注册还便宜。 -
一个 hook 只管一个事件。 没有数组形式。写成
useEventListener("mousedown", h)和useEventListener("touchstart", h)两次调用就是惯用法------hook 很便宜,而且这让依赖比较保持简单。 -
它天生 SSR 安全,所以别再加守卫。 不需要
typeof window判断,不需要包一层useEffect,不需要动态 import。在服务端它什么都不做。
什么时候别用它
useEventListener 是个原语。如果已经有为某件事专门写的 hook,它会替你处理状态、边界情况和清理,而这些你自己重写一遍多半会漏:
- 判断点击落在元素外部 →
useClickOutside或useClickAway(它们处理了「mousedown 在内部、mouseup 在外部」这种你自己写容易搞错的情况)。 - 悬停、长按、双击、拖拽 →
useHover、useLongPress、useDoubleClick、useDraggable。 - 元素尺寸或可见性 →
useResizeObserver、useElementSize、useIntersectionObserver。这些是观察器,不是事件;挂在window上的resize监听没法告诉你某个元素的尺寸变了。 - 事件本身有 JSX 属性,而且目标就是你自己的元素 → 直接用
onClick。React 的委托处理器更便宜,也更就近。只有当你需要window/document、需要非 passive 监听、或者 React 没暴露这个事件时,才动用真实监听。 - 你其实只是想要一个稳定的函数身份 → 那是
useEvent,跟监听没关系。
要点回顾
useEffect+addEventListener这对组合逼你做一个假选择:把处理器放进依赖、每次渲染都重新订阅,或者不放进去、然后调用过期闭包。useEventListener的解法是注册一个稳定的包装函数,再转发给一个useLatestref,于是内联箭头处理器是免费的,addEventListener每个目标只调用一次。- options 是深比较的,所以内联的
{ passive: true }不会触发重挂;目标在 commit 阶段解析,所以 ref 能正常工作;整个 hook 在 SSR 期间是空操作。 window和裸元素目标下 TypeScript 能推断出精确的事件类型,ref 和函数目标会退化成any------那两种情况给处理器参数加个注解,并且当心事件名拼写错误,那些重载不会帮你抓。- 把它当作构建你自己 hook 的原语。如果你要监听的东西已经有专门的 hook,就用那个。
useEventListener、useLatest、useClickOutside,以及另外 110+ 个 SSR 安全、TypeScript 优先的 hook 都在 @reactuses/core 里------装一次,可 tree-shake,没有需要照看的依赖。
bash
npm install @reactuses/core