我是在某个后台管理系统里被逼着写下这个useKeyPress的。那个页面同时有表格、有弹窗、有快捷保存需求列出来以后我数了一下光键盘事件监听就要写七八处每处都是同样的useEffect套addEventListener再套removeEventListener。当时就想这种重复几乎每个项目都跑不掉索性抽成一个独立 Hook于是就有了这一版useKeyPress。这篇内容会讲清楚为什么按键监听适合被封装成自定义 Hook、一个能直接抄走的最小实现长什么样、组合键怎么处理、我在真实项目里踩过的几个坑以及轮播翻页、编辑器保存、游戏按键控制三个实际场景。适合刚把useEffect用熟、想开始沉淀自己 Hooks 工具箱的 React 开发者。1. 从「监听代码遍地开花」到「一行调用」useKeyPress 到底封装了什么1.1 业务代码里的重复劳动先还原一下没封装之前的样子。某个组件里要监听 Esc 关闭弹窗大多数人会写出这样的代码useEffect(() { const onKeyDown (event: KeyboardEvent) { if (event.key Escape) { setOpen(false) } } window.addEventListener(keydown, onKeyDown) return () { window.removeEventListener(keydown, onKeyDown) } }, [])这段代码本身没错问题在于它只是这一处的解法。一两个组件这么写还能忍组件一多问题就全暴露出来了第一监听和清理必须成对出现。有经验的开发者会记得在return里清理但实际项目里总是有人漏掉结果就是组件卸载之后监听器还挂在window上回调里继续执行setState。旧版 React 会在控制台打警告新版 React 虽然不报错了但内存泄漏和意外状态更新依然真实存在。第二按键字符串很难记牢。Escape、Esc、ArrowLeft、Left、Space、 这些看起来像又都不是同一个值。我见过同事把ArrowLeft写成Left排查了半天才发现是字符串对不上。这种错误在原生监听写法里几乎无法避免因为每个组件都要重新写一遍判断。第三回调闭包经常过期。如果按键回调里读的是组件最新的 state而监听函数只在一开始绑定了一次那它捕获到的就是第一次渲染时的旧值。很多人的第一反应是往依赖数组里塞变量结果监听器被反复解绑重绑性能倒还好关键是代码变得很难读。这三件事单独看不严重叠在一起就足以让你怀疑人生。useKeyPress要做的就是把这套绑定-判断-清理-保鲜的逻辑全部收敛到一个函数里业务侧只关心我要监听哪个键和按下了做什么。1.2 Hook 应该暴露哪种接口状态型与命令型的取舍动手封装之前我先花了一点时间想接口设计。因为这个 Hook 会被多个项目复用接口一旦定得不顺手后面改起来很别扭。实现中我看到社区里两种主流设计设计方向参数形式返回值适用场景状态型useKeyPress(ArrowLeft)boolean是否按下渲染层判断按键状态比如游戏控制、按住移动命令型useKeyPress(Escape, handler)void只需要在按下瞬间执行动作比如关闭弹窗组合型useKeyPress([ctrl, s], handler)void快捷键体系需要修饰键配合我当时的判断是两种都要但不要塞进同一个函数里。如果让一个useKeyPress又返回状态、又接受回调、还要处理组合键心智负担会很重。所以我拆成主函数useKeyPress(targetKey, options)和辅助函数useKeyPressState(targetKey)。前者负责按键事件驱动动作后者负责把按键状态暴露给渲染层。这样每个函数只有一个清晰职责测试也好写。另一个决策点是监听目标放在哪个元素上。我选择默认绑window原因很简单大多数快捷键是全局的绑定在具体 DOM 元素上会出现焦点丢失问题。比如你把监听绑在某个按钮上用户点了一下页面其他区域焦点离开按钮按键事件就接不到了。全局监听配合event.target判断才是最稳的底子。2. 第一版实现能跑通核心逻辑的 useKeyPress 长什么样2.1 最小实现一段干净的 useEffect 封装先给一个最直接的版本它只做一件事监听keydown和keyup同步维护一个布尔状态。import { useEffect, useState } from react function useKeyPressState(targetKey: string): boolean { const [pressed, setPressed] useState(false) useEffect(() { const downHandler (event: KeyboardEvent) { if (event.key targetKey) { setPressed(true) } } const upHandler (event: KeyboardEvent) { if (event.key targetKey) { setPressed(false) } } window.addEventListener(keydown, downHandler) window.addEventListener(keyup, upHandler) return () { window.removeEventListener(keydown, downHandler) window.removeEventListener(keyup, upHandler) } }, [targetKey]) return pressed }使用方式很直观const ArrowPressed useKeyPressState(ArrowRight) useEffect(() { if (ArrowPressed) { handleNext() } }, [ArrowPressed])这个版本的优点是几乎没有坑依赖数组只有targetKey监听函数每次都是新闭包不会读旧值——因为targetKey变化时整个 effect 会重新执行。keydown和keyup的处理器里只做了setPressed(true/false)不涉及任何外部变量所以闭包问题天然不存在。但它的缺点也很明显如果你在setPressed(true)之后立刻检查pressed拿到的还是旧值。这是因为useState的更新是异步的状态要等到下一次渲染才生效。想要拿到即时按键结果你得回到事件处理器里而不是依赖返回值。2.2 用 useState 还是 useRef 存按键状态第一版里我用的是useState因为它要驱动渲染层——游戏里的角色需要实时看到位移表格翻页需要按下方向键立刻触发状态变化这些场景没有重新渲染就不行。但如果你只是想在某个事件处理函数里查一下当前按键状态useState反而是负担。状态每次变化都会触发整棵组件树重新渲染哪怕你关心的只是按键的瞬时值。这种场景应该用useRefconst pressedRef useRef(false) useEffect(() { const downHandler (event: KeyboardEvent) { if (event.key ArrowRight) { pressedRef.current true } } const upHandler (event: KeyboardEvent) { if (event.key ArrowRight) { pressedRef.current false } } // ...监听与清理 }, [])useRef的更新是同步的你在事件处理函数里写入pressedRef.current true下一行立刻就能读出来。代价是它不会触发渲染。所以实践里我经常是两个一起用useState给渲染层看useRef给逻辑层即时读。这不算过度设计因为两者面向的消费者完全不同。2.3 key 与 code一个经常被忽略的语义差异写第一个可用版本的时候我还没意识到event.key和event.code的差异会坑人。直到有个做国际化产品的同事来找我他说在法语键盘布局下按WASD控制游戏角色时角色完全不听话。一排查才发现法语键盘的A键位置和Q键互换了event.key跟着物理布局变但event.code始终指向物理键位。维度event.keyevent.code含义输入字符/语义值物理按键位置示例a、ArrowLeft、EscapeKeyA、ArrowLeft、Escape键盘布局影响受布局影响法语键盘下A与Q会互换不受布局影响永远是物理键位适用场景字符输入、外文字符快捷键游戏操作、位置型快捷键WASD、方向键我在最终还是把默认用event.key但允许调用方显式指定event.code做进了实现里。做法是给useKeyPress加一个可选的mode参数或者直接让调用方传一个matchKey函数type KeyMatcher (event: KeyboardEvent) boolean function useKeyPress(matcher: KeyMatcher, handler: (event: KeyboardEvent) void) { const matcherRef useRef(matcher) useEffect(() { matcherRef.current matcher }, [matcher]) useEffect(() { const onKeyDown (event: KeyboardEvent) { if (matcherRef.current(event)) { handler(event) } } window.addEventListener(keydown, onKeyDown) return () window.removeEventListener(keydown, onKeyDown) }, [handler]) }用matcher函数不仅解决了布局问题也顺便解决了多键匹配的问题——你想匹配Enter和NumpadEnter时写一个event event.key Enter || event.key NumpadEnter就好。对于中文用户还有个隐藏福利中文输入法下event.key在部分浏览器里会返回Process这样的值用event.code反而稳定。这个后面在踩坑部分细说。3. 组合键与修饰键把 Hook 从「单键状态机」升级成「快捷键系统」3.1 用按键集合记录当前键盘状态单键监听很简单真正让useKeyPress变得实用的是组合键CtrlS保存、CtrlShiftD批量删除、AltEnter打开编辑器。如果你只是单独监听s那用户按CtrlS时你的回调也会触发这是肯定不对的。我的方案是维护一个当前按下按键的集合。keydown时把event.key塞进集合keyup时再删掉。判断组合键是否成立时检查集合里是否同时包含目标键和所有修饰键。function useKeyPressCombo(keys: string[], handler: (event: KeyboardEvent) void) { const pressedKeysRef useRefSetstring(new Set()) const handlerRef useRef(handler) const keysRef useRef(keys) useEffect(() { handlerRef.current handler keysRef.current keys }) useEffect(() { const downHandler (event: KeyboardEvent) { const pressedKeys pressedKeysRef.current pressedKeys.add(event.key) const isComboActive keysRef.current.every((key) pressedKeys.has(key)) if (isComboActive) { handlerRef.current(event) } } const upHandler (event: KeyboardEvent) { pressedKeysRef.current.delete(event.key) } const resetHandler () { pressedKeysRef.current.clear() } window.addEventListener(keydown, downHandler) window.addEventListener(keyup, upHandler) window.addEventListener(blur, resetHandler) return () { window.removeEventListener(keydown, downHandler) window.removeEventListener(keyup, upHandler) window.removeEventListener(blur, resetHandler) } }, []) // eslint-disable-next-line react-hooks/exhaustive-deps return pressedKeysRef }这里有一个很关键的细节为什么用useRef保存按键集合而不是useState因为每次按键都会改这个集合如果用useState每次keydown都会触发一次组件渲染——按一下Ctrl渲染一次按下S又渲染一次松开再各渲染两次。一个快捷键操作带起四次无意义渲染对性能是不必要的消耗。useRef读写都是同步的完全够用。3.2 只在「按下边界」触发一次组合快捷键的收敛逻辑组合键监听还有一个容易被忽略的需求用户按住CtrlS不放会不会触发多次保存会。因为keydown事件长按时会自动重复派发一秒钟可能触发好几十次保存请求。业务上保存是幂等的还好如果触发的是插入记录发送请求这种操作那就等着被投诉吧。我的处理办法是维护一个是否已经是按下状态的标记只在从没按下变成按下的那一瞬间触发回调let wasPressed false const downHandler (event: KeyboardEvent) { const isComboActive isComboPressed() if (isComboActive !wasPressed) { wasPressed true handlerRef.current(event) } } const upHandler (event: KeyboardEvent) { if (wasPressed) { wasPressed false } }这段逻辑单独拿出来很容易懂但放到真实组件里时要注意一个边界如果用户按住了Ctrl再按S此时组合键激活只算一次可如果他把S松开了、Ctrl还按着键盘集合里Ctrl依然存在下一次再按S时应该重新触发一次吗取决于业务需求。保存类快捷键通常希望每次重新按下S都触发保存所以我把wasPressed的复位放在keyup里而不是等到所有键全部松开。这样只要S键松开了就算Ctrl一直按着下一次按S也能触发。4. 我踩过的坑长按重复、窗口失焦按键卡死与默认行为冲突4.1 长按触发多次e.repeat 过滤器前面提到长按会重复触发keydown这个问题的经典解法是检查event.repeat。KeyboardEvent.repeat是浏览器提供的一个只读布尔值长按时为true正常点击为false。在回调第一行加个判断就完事const downHandler (event: KeyboardEvent) { if (event.repeat) return // 业务逻辑 }但这里我要多说一句不是说所有场景都要过滤repeat。游戏里按住方向键持续移动恰恰需要重复触发只是你要控制触发频率而不是完全禁止。所以我在实现里留了一个allowRepeat选项默认false游戏场景调成true。这个选项很小却让 Hook 的适用面拓宽了一倍。实测中event.repeat在所有现代浏览器里行为一致移动端浏览器偶尔有差异但网页应用主要跑在桌面端影响不大。4.2 窗口失焦后的按键「卡死」这是我实际排错时间最长的一个 bug症状很奇葩用户按着W键不松手鼠标点了一下浏览器外部区域再回来后松开W游戏角色还在持续前进。原因是窗口失焦时浏览器不再派发keyup事件。你的按键集合里W永远留着pressed状态永远为true。解法很简单监听blur事件时清空所有按键状态const resetHandler () { pressedKeysRef.current.clear() setPressed(false) } window.addEventListener(blur, resetHandler)注意blur绑在window上而不是document上。因为窗口级别的blur才是真正的用户切走了document的blur在某些浏览器里不会在窗口失焦时触发。另外如果页面被嵌在 iframe 里还需要处理visibilitychange但一般业务用不上这里不展开。4.3 默认行为冲突空格滚动、CtrlS 保存弹窗最后一个高频坑是浏览器默认行为。举三个最典型的例子空格键页面默认向下滚动。如果你用空格做游戏中跳跃keydown回调里必须event.preventDefault()否则角色一边跳页面一边滚。CtrlS浏览器默认弹出保存网页对话框。做编辑器的人如果忘了preventDefault按下保存快捷键就会看到那个烦人的原生弹窗。方向键页面在有滚动容器时上下方向键会触发滚动。做表格翻页、轮播图时必须拦截。preventDefault加在哪里很讲究。我习惯放在匹配成功之后而不是监听器一进来就无脑阻止。因为无脑阻止会影响用户正常输入比如在输入框里按方向键移动光标也是合法的全局阻止会让输入体验变差。const downHandler (event: KeyboardEvent) { if (!matcherRef.current(event)) return event.preventDefault() handlerRef.current?.(event) }另外提醒一句中文输入法环境下event.key s可能会被输入法拦截导致按CtrlS时event.key是Process。这种场景下用event.code KeyS匹配更可靠。这也是为什么我最终选择把matcher设计成函数而不是字符串——字符串方案在这里会再次踩坑。5. 进阶优化回调永远新鲜、全局共享监听器与高频读取5.1 回调永远不新鲜用 ref 保住最新 handler开头我说过闭包过期是原生监听的老大难。第一版useKeyPress的实现里如果依赖数组写[handler]组件每次渲染都会解绑再重绑监听器。组件高频更新时这个操作虽说不上致命但会白白浪费性能还可能在某次解绑重绑的间隙漏掉一次按键事件。更干净的方案是用 ref 保存最新的 handler监听器只绑定一次const handlerRef useRef(handler) useEffect(() { handlerRef.current handler }, [handler]) useEffect(() { const onKeyDown (event: KeyboardEvent) { if (matcherRef.current(event)) { handlerRef.current(event) } } window.addEventListener(keydown, onKeyDown) return () window.removeEventListener(keydown, onKeyDown) }, [])这样监听器从组件挂载到卸载只绑定一次回调却永远是最新的。这个方法在 React 生态里非常常见不止useKeyPressuseInterval、useEventListener这类 Hook 都靠它解决闭包过期问题。核心思路就一句可变值放 ref函数内部只读 ref.current。5.2 多个 Hook 共享同一个 window 监听器页面里十个组件各写一个useKeyPress意味着window上挂了十个keydown监听器。功能没问题但我总觉得不优雅。于是做了个进阶版本模块级维护一个按键事件分发器所有 Hook 实例都向它注册回调真正绑定在window上的只有一份监听器。用发布订阅的思路实现type KeyHandler (event: KeyboardEvent) void const keyHandlers new SetKeyHandler() let isListening false function startListening() { window.addEventListener(keydown, (event) { keyHandlers.forEach((handler) handler(event)) }) isListening true } function addKeyHandler(handler: KeyHandler) { if (!isListening) startListening() keyHandlers.add(handler) } function removeKeyHandler(handler: KeyHandler) { keyHandlers.delete(handler) }useKeyPress内部在useEffect里调用addKeyHandler清理时调用removeKeyHandler。这个方案对性能的改善其实不大但它在代码结构上有价值以后想追加一个全局按键行为只需注册新的 handler不用再碰原生事件绑定。比如全局Esc关闭所有弹窗就可以做成一个独立模块。如果你只是写内部项目、不打算做成一款通用 Hook 库这层优化可以跳过不算偷懒。5.3 高频场景状态快照 节流读取按键事件的频率本身不高真正高的是你读取按键状态的频率。游戏里每帧都要问现在W按着没有每帧都调useState的返回值会引发渲染。更好的做法是把按键状态存到 ref然后在requestAnimationFrame循环里读取const keysRef useRef({ left: false, right: false, up: false }) useEffect(() { const rafHandler () { if (keysRef.current.left) moveLeft() if (keysRef.current.right) moveRight() animationId requestAnimationFrame(rafHandler) } const animationId requestAnimationFrame(rafHandler) return () cancelAnimationFrame(animationId) }, [])这种状态写入用 ref渲染循环里读快照的模式比每按一键就 setState 再触发渲染高效得多。做编辑器里的滚动加速、做游戏里的连续移动都很实用。如果你的按键回调里埋了接口请求记得加一层节流或防抖不然CtrlTab这种高频切换会瞬间把一个窗口塞满请求。6. 实战应用表格翻页、编辑器保存与游戏移动控制6.1 后台表格方向键翻页我在那个后台系统里用useKeyPress做的第一件事就是表格快捷键翻页。表格每页 20 条产品希望用户能左手按方向键切页、右手握着鼠标操作数据。实现起来很轻松useKeyPress( (event) event.key ArrowLeft || event.code ArrowLeft, () { if (currentPage 1) setCurrentPage((page) page - 1) } ) useKeyPress( (event) event.key ArrowRight || event.code ArrowRight, () { if (currentPage totalPages) setCurrentPage((page) page 1) } )注意这里加了一层event.key ArrowLeft || event.code ArrowLeft的冗余判断。虽然两者在方向键上几乎总是一致但event.code保证在特殊键盘布局下也能识别物理方向键。实际用下来唯一要注意的是焦点弹窗里也会触发翻页。我最后的处理是在弹窗打开时给按键监听加一个全局条件——if (isModalOpen) return优先级交给业务层控制。6.2 编辑器里的 CtrlS 保存与 Esc 关闭编辑器的快捷键是最典型的组合键场景。CtrlS保存要拦截默认行为Esc关闭面板要全局监听但用户在输入框打字时不能误触。我把这两条组合起来写const isEditingRef useRef(false) useKeyPress( (event) (event.ctrlKey || event.metaKey) event.code KeyS, (event) { event.preventDefault() saveContent() } ) useKeyPress( (event) event.code Escape, () { if (isEditingRef.current) { isEditingRef.current false setIsModalOpen(false) } }, { ignoreWhenTargetIsInput: true } )ctrlKey || metaKey是为了兼容 Mac 的 Command 键。macOS 上大部分用户习惯用 Command 代替 Ctrl这条几乎成了编辑器快捷键的标配。ignoreWhenTargetIsInput是我在 Hook 里加的一个选项当event.target是input、textarea或带有contenteditable属性的节点时直接忽略按键避免用户打字时按 Esc 把编辑器面板关掉。6.3 游戏控制WASD 按住连续移动的写法最后说游戏场景。网页小游戏控制移动时玩家希望的是按住 W 一直往前进而不是每按一次前进一格。这里不能过滤repeat也不能每帧触发一次setState。正确组合是用useKeyPressState拿到状态把状态写进 ref用requestAnimationFrame循环读取const [wPressed] useKeyPressState(KeyW) const wPressedRef useRef(false) useEffect(() { wPressedRef.current wPressed }, [wPressed]) useEffect(() { let animationId: number const loop () { if (wPressedRef.current) { moveForward() } animationId requestAnimationFrame(loop) } animationId requestAnimationFrame(loop) return () cancelAnimationFrame(animationId) }, [])这里我故意用event.code而不是event.key——玩家用的是固定物理键位和键盘布局无关。逻辑虽然绕了一层 ref但换来的是稳定的 60fps 更新频率不会因为键盘事件频率波动导致角色卡顿。如果嫌useEffectref的两步同步麻烦可以把 Hook 内部暴露一个getPressedSnapshot方法直接取pressedRef.current省掉中间那层 state。最后补一句实际体会写了这么多版本回头看useKeyPress最重要的设计原则只有一条Hook 的职责边界永远是把键盘事件翻译成业务需要的东西而不是在 Hook 内部塞业务判断。匹配逻辑、缓存回调、清理监听这些属于 Hook要不要阻止默认行为、按了之后调哪个接口、是否忽略输入框这些应该通过参数或回调交回给业务侧。守住这条边界这个 Hook 才不会变成另一个到处贴胶带的一次性工具。
阅读完成 · 觉得有帮助?