做前端时间久的同学应该都有这种体会:页面只要涉及键盘操作,代码就容易变得又散又乱。尤其是那种中后台管理系统,表格里要支持上下左右移动选中行、Ctrl+C复制、Delete删除、F2编辑,一整套操作写下来,每个页面都塞一段window.addEventListener('keydown', ...),事件回调里再堆一串if (e.key === ...),代码味道就非常差。
我最近在一个数据密集型项目里正好碰到同样的问题,所以干脆把键盘监听抽成了一个自定义 Hooks:useKeyPress。这也是我在项目里沉淀的系列 Hooks 中的第 30 个。
这个 Hook 做的事情很简单:传入一个目标按键(比如'a'、'ArrowUp'、'ctrl+k'),返回一个布尔值,告诉你这个键当前是否处于按下状态。你不需要再手动管理事件监听、不需要考虑清理函数、不用担心重复绑定,组件拿去直接用。它能解决前端日常开发里绝大部分键盘交互需求,适合写表格快捷键、轮播图方向控制、游戏式键盘操作、以及任何想用“状态”而非“事件”来驱动界面的场景。
1. 为什么我决定把键盘监听封装成 Hooks
1.1 从一段重复代码说起
不封装之前,我项目中多个页面都有类似这种代码:
const [isDown, setIsDown] = useState(false); useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'ArrowDown') setIsDown(true); }; const handleKeyUp = (e) => { if (e.key === 'ArrowDown') setIsDown(false); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []);这种写法的痛点很明显:
- 每个要监听键盘的组件都要重复写
useEffect、addEventListener、removeEventListener三件套。 - 按键多了以后,
if / else分支越叠越多,event 里混杂着e.key、e.code、e.keyCode各种判断,逻辑很容易出错。 - 忘写清理函数,组件卸载后事件还在,轻则报错,重则内存泄漏。
- 状态和事件深度耦合,想改成“按住方向键持续加速”这种需求,还得重构事件回调。
把键盘监听封装成 Hooks,本质上是把“事件驱动”转换成“状态驱动”。调用方只需要关心“现在哪个键按着”,完全不用碰原生事件。
1.2 Hook 的边界应该划在哪
设计这个 Hook 之前,我认真想过一个问题:它到底该多通用?
太薄了没意义,比如只封装一个addEventListener再原样返回,那和直接用原生事件没区别。太厚了又难用,比如硬要支持一套完整的事件处理系统、带优先级、带事件总线,大部分项目根本用不上。
我最终把边界定成了这样:
- 入参:一个查询条件,可以是单个键、多个键、带组合键修饰的按键串。
- 出参:一个布尔值数组(或单个布尔值),表示每个查询键当前是否处于按下状态。
- 附带能力:支持
keydown与keyup自动配对、支持重复触发处理、支持局部容器监听、支持输入框内自动抑制。
这个边界刚好覆盖了我在真实项目中遇到的 80% 场景。剩下的 20%,比如快捷键冲突、跨组件通信、快捷键录制这类需求,我会在后面的扩展小节里给出思路,但不会硬塞进核心实现。
1.3 状态驱动到底好在哪
用一个很简单的例子说明。在普通写法里,你要实现“按住 B 键加速跑”:
useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'b' && !e.repeat) { setSpeed(100); } }; const handleKeyUp = (e) => { if (e.key === 'b') { setSpeed(50); } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []);用了useKeyPress之后:
const isBPressed = useKeyPress('b'); useEffect(() => { setSpeed(isBPressed ? 100 : 50); }, [isBPressed]);第二种写法优雅太多了。isBPressed就是个普通状态,可以参与 derive state、可以进useEffect依赖、可以传给子组件、可以在渲染函数里直接控制样式。把一个系统级事件抽象成一个普通变量,整个代码的心智负担都下降了一大截。
2. 核心实现:一个能直接用的 useKeyPress
2.1 最简版:按下置真、抬起置假
第一版实现不需要考虑太多,直接按需求来:
import { useEffect, useState } from 'react'; function useKeyPress(targetKey) { const [isPressed, setIsPressed] = useState(false); useEffect(() => { const handleKeyDown = (e) => { if (e.key === targetKey) { setIsPressed(true); } }; const handleKeyUp = (e) => { if (e.key === targetKey) { setIsPressed(false); } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [targetKey]); return isPressed; }用法:
const isEnterPressed = useKeyPress('Enter'); const isAPressed = useKeyPress('a');这段代码虽然短,但已经覆盖了最核心的逻辑。不过直接拿它上生产会踩到几个坑,我一个个说。
2.2 useEffect 依赖到底写什么
useEffect的依赖数组里必须写targetKey,这一点很容易被忽略。有些同学图省事写成空数组[],组件挂载时用当时的targetKey绑定一次事件,后面targetKey变了,事件回调里用的还是旧值,按键判断就全部错乱。
比如你的业务里先是const key = 'a',后面因为某个操作把它改成'b',但依赖没更新,你按b永远不会触发。这个问题在真实项目里出现频率非常高。
但如果每次targetKey变化都重新绑定事件,又会带来另一个小问题:React 18 严格模式开发环境下 effect 会执行两次,事件被绑定再解绑再绑定,只要清理函数写对了,这个不是问题。所以核心原则是:依赖数组跟着入参走,事件回调里用到的每一个外部变量都必须出现在依赖里。
2.3 按住不松开:e.repeat 与 keyup 丢失
键盘事件里有一个很容易忽略的细节:按住一个键不松开时,系统会持续触发keydown,而且事件对象的e.repeat字段是true。
在《俄罗斯方块》或《打字练习》这类场景里,用户按住方向键不松,你希望持续响应还是只响应一次?两种需求都可能。所以我给 Hook 加了一个配置项shouldRepeat,默认false,按住只触发一次状态更新:
function useKeyPress(targetKey, options = {}) { const { shouldRepeat = false } = options; // ... const handleKeyDown = (e) => { if (e.repeat && !shouldRepeat) return; if (e.key === targetKey) { setIsPressed(true); } }; }另外还有一个更隐蔽的坑:某些系统或输入法环境下,keyup事件有概率丢失,导致布尔值永远停在true。最典型的场景是:用户按下Ctrl后,又按住了其它键,这时候松开Ctrl,某些浏览器会漏发Ctrl的keyup。这不常见,但我在实际使用中确实碰到过。
稳妥的做法是加一个兜底:每次keydown都重新确认目标键的状态,而不是单纯依赖keyup去复位。或者干脆在visibilitychange、window blur时把所有键位状态重置为false。手指离开电脑去接电话再回来,键盘状态早就乱了,这时候阻塞在“按下”状态非常难受。
2.4 返回布尔值还只是半成品
把isPressed返回出去,对大部分场景已经够了。但很快你会发现还有两个高频需求:
- 我想知道按住这个键的时候,是否同时按了 Shift。
- 我想在键位变化时拿到事件对象,做一些额外处理。
所以我稍微扩展了返回值,做成了一个带 ref 的版本。返回的不是单纯的布尔值,而是一个包含状态与事件快照的对象。这样组件里既有响应式状态,又能拿到原始信息用于日志或扩展逻辑:
function useKeyPress(targetKey, options = {}) { const [pressed, setPressed] = useState(false); const [eventInfo, setEventInfo] = useState(null); // ...事件绑定 const handleKeyDown = (e) => { if (e.repeat && !options.shouldRepeat) return; if (e.key === targetKey) { setPressed(true); setEventInfo({ key: e.key, code: e.code, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey, altKey: e.altKey, }); } }; return { pressed, eventInfo }; }不过要提醒一句:事件快照存进 state 会让组件多渲染一次。如果只是想做“按 Shift 走步进,不按 Shift 走默认量”这种逻辑,建议直接在回调里处理,不要把事件对象塞进 state。这个版本主要为了排查问题方便,生产环境我会做成可选返回。
3. 进阶扩展:组合键、多键与局部监听
3.1 组合键:Ctrl + Shift + K 怎么写
实际项目里,单键监听撑不起复杂交互,组合键是刚需。
组合键的实现思路不复杂:把所有修饰键状态和主键状态合在一起判断。我提供的 API 设计是:目标键写成'ctrl+k'、'shift+arrowUp'这种可读性强的字符串,内部解析成数组。
function parseKeyCombo(combo) { return combo.toLowerCase().split('+').map((s) => s.trim()); } function useKeyPress(combo) { const parts = parseKeyCombo(combo); const mainKey = parts[parts.length - 1]; const modifiers = { ctrl: parts.includes('ctrl'), shift: parts.includes('shift'), alt: parts.includes('alt'), meta: parts.includes('meta'), }; // ... }判断逻辑:
const isModifierMatch = (e) => { return ( e.ctrlKey === modifiers.ctrl && e.shiftKey === modifiers.shift && e.altKey === modifiers.alt && e.metaKey === modifiers.meta ); }; const handleKeyDown = (e) => { if (e.key.toLowerCase() === mainKey && isModifierMatch(e)) { setPressed(true); } };这里有一个关键决策:修饰键必须用“全等于”判断,而不是只判断“有没有按下”。为什么?因为如果你要监听'ctrl+k',用户同时按了ctrl+shift+k,这时候到底算不算触发?我的选择是:不算。在快捷键场景里,组合键要求精确匹配,否则用户想触发ctrl+shift+k时会连ctrl+k一起误触,业务上非常危险。
但如果你在做一个游戏,希望“按住 Shift 加速跑”这种宽松匹配,那可以把匹配模式配置化,比如matchModifiers: 'loose',只看需要的修饰键。两种模式我都实现了,默认严格匹配。
3.2 多键场景:WASD 怎么同时监听
我在项目中还遇到过竞技类小游戏的键盘控制需求:WASD 四个键必须同时监听,而且四路状态互不干扰,还要保证“同时按下 WD”两个键时输出正确。
简单粗暴的做法是调四次useKeyPress:
const isWPressed = useKeyPress('w'); const isAPressed = useKeyPress('a'); const isSPressed = useKeyPress('s'); const isDPressed = useKeyPress('d');这个方案没问题,每个键的状态独立维护,互不干扰。但代码行数偏多,而且如果后续要加跳跃键、蹲伏键、交互键,组件里会冒出七八个布尔变量,管理起来开始费劲。
我更喜欢传数组进去:
function useKeyPress(keys) { const [pressedSet, setPressedSet] = useState(() => new Set()); // 监听所有 keydown/keyup const handleKeyDown = (e) => { if (Array.isArray(keys) && keys.includes(e.key.toLowerCase())) { setPressedSet((prev) => { if (prev.has(e.key)) return prev; const next = new Set(prev); next.add(e.key); return next; }); } }; const handleKeyUp = (e) => { setPressedSet((prev) => { if (!prev.has(e.key)) return prev; const next = new Set(prev); next.delete(e.key); return next; }); }; return keys.map((key) => pressedSet.has(key)); }返回的是布尔值数组,顺序和入参一致。这样游戏主循环里可以直接解构:
const [isW, isA, isS, isD] = useKeyPress(['w', 'a', 's', 'd']);用 Set 的好处是天然支持并发状态,不用为每一个键单独维护一个useState,减少渲染次数。这个版本在实际游戏场景里很稳,实测下来哪怕四个键同时按,状态更新也不会互相覆盖。
3.3 局部监听:让 Hook 只在某个容器内生效
大部分键盘交互是全局的,但也有一类需求需要在局部容器内生效。比如页面里嵌了一个自定义右键菜单,你可能希望菜单打开时,只有按 Esc 才关闭;菜单外按什么键都不要紧。
局部监听的核心是事件委托。给目标容器加tabIndex,让它能获得焦点,然后监听它的keydown/keyup:
function useKeyPressInContainer(targetKey, containerRef) { const [isPressed, setIsPressed] = useState(false); useEffect(() => { const el = containerRef.current; if (!el) return undefined; const handleKeyDown = (e) => { if (e.key === targetKey) setIsPressed(true); }; const handleKeyUp = (e) => { if (e.key === targetKey) setIsPressed(false); }; el.addEventListener('keydown', handleKeyDown); el.addEventListener('keyup', handleKeyUp); return () => { el.removeEventListener('keydown', handleKeyDown); el.removeEventListener('keyup', handleKeyUp); }; }, [targetKey, containerRef]); return isPressed; }使用侧注意两点:容器必须有tabIndex属性,否则焦点根本进不去,事件永远不触发;容器内部的输入框子元素拿到焦点后,事件会冒泡到容器,这时候判断e.target === el就不够了,需要用el.contains(e.target)做边界判断,或者反过来只处理“焦点在容器本身”的情况。
我给 Hook 加了一个allowFocusInside配置项。如果设成false,只有容器自身聚焦时才响应按键,子输入框聚焦时不响应;设成true则所有子元素内的键盘操作都参与判断。默认是false,这个选择更符合常规直觉。
3.4 输入框失焦陷阱:全局快捷键把打字打断
全局键盘监听最伤人的场景就是输入框内打字。用户正在输入内容,按了一个a,结果你的全局快捷键检测到useKeyPress('a')为真,页面立刻跳转或者弹窗了,这对用户来说就是一场事故。
解决方案是加一个“可编辑元素检测”。在handleKeyDown里判断事件源是否为输入框、文本域,或contentEditable区域,如果是就跳过快捷键逻辑:
const isEditableTarget = (e) => { const tag = e.target.tagName; return ( tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable ); }; if (isEditableTarget(e)) return;我给useKeyPress暴露了一个配置项ignoreEditable,默认true。这样普通组件里使用 Hook 时,天然不会打断用户输入,不需要每个调用点都写一遍判断逻辑。这个细节非常提升用户体验,建议所有做全局快捷键的 Hook 都默认带上。
4. 实战场景:从快捷键菜单到键盘导航
4.1 场景一:中后台批量操作快捷键
某项目里有一个任务表格,需要支持 Ctrl+Enter 批量提交、Delete 删除选中项、Esc 取消选中。用useKeyPress写起来非常清爽:
const isSubmitPressed = useKeyPress('ctrl+enter'); const isDeletePressed = useKeyPress('delete'); const isCancelPressed = useKeyPress('escape'); useEffect(() => { if (isSubmitPressed) { batchSubmit(selectedRows); } }, [isSubmitPressed, selectedRows]); useEffect(() => { if (isDeletePressed) { batchDelete(selectedRows); } }, [isDeletePressed, selectedRows]); useEffect(() => { if (isCancelPressed) { clearSelection(); } }, [isCancelPressed]);这个方案最直观的优点是把“按键状态”和“业务逻辑”彻底分离。每个快捷键对应一个useEffect,触发条件一目了然,代码评审时不会再看到一大堆addEventListener包裹业务逻辑的混乱写法。
有一个细节值得说:useEffect依赖里放selectedRows是有意为之,因为批量提交操作需要最新的选中数据。但如果你不想因为数据变化导致快捷键重复触发,可以用useRef保存最新数据,useEffect只依赖按键状态。
4.2 场景二:轮播图方向控制与播放暂停
轮播图组件里,左右方向键切页、空格键暂停播放是常见需求。
const isLeftPressed = useKeyPress('arrowleft'); const isRightPressed = useKeyPress('arrowright'); const isSpacePressed = useKeyPress(' '); useEffect(() => { if (isLeftPressed) goPrev(); }, [isLeftPressed]); useEffect(() => { if (isRightPressed) goNext(); }, [isRightPressed]); useEffect(() => { if (isSpacePressed) togglePlay(); }, [isSpacePressed]);有一个细节:' '空格键的e.key是一个空格字符,不是'Spacebar',也不是'Space'。不同人很容易在这个值上踩坑。我建议实现里对空格做一次兼容,把'space'、' '、'Spacebar'都归一成空格。
这个需求里按住方向键连翻页还是按一次翻一页,取决于shouldRepeat的设置。轮播图场景我建议shouldRepeat: true,用户按住方向键不松时连续翻页,配合 CSS 的 transition 效果会很跟手。
4.3 场景三:游戏与打字训练中的帧级响应
做一个小游戏或者打字训练器时,键盘状态往往需要高频读取。这种情况下不适合把每一个键位都变成 React state,否则每按一次键就触发一次全局 render。
我常用的做法是把键盘状态同步到一个 ref 里,游戏循环直接读 ref 的当前值:
const keyMapRef = useRef({}); useEffect(() => { const handleKeyDown = (e) => { keyMapRef.current[e.code] = true; }; const handleKeyUp = (e) => { keyMapRef.current[e.code] = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []); // requestAnimationFrame 循环里直接读 const isRunning = keyMapRef.current['KeyW'];这种模式不经过 React 渲染流水线,在游戏场景里性能表现最好。不过它牺牲了一部分 React 声明式能力,适合放在项目里的工具层,不适合直接被 UI 组件消费。我的建议是两个版本都保留:日常 UI 交互用响应式useKeyPress,游戏循环用useKeyboardRef。
5. 常见问题与排查技巧实录
5.1 焦点问题:为什么快捷键“偶尔失灵”
如果你发现useKeyPress在页面上大部分区域有效,但点击到某个按钮或某个 div 之后再按就没反应了,大概率不是 Hook 的问题,而是焦点问题。
浏览器有一些默认行为,比如按下空格会触发焦点按钮的点击,按下方向键会在某些可聚焦元素上产生滚动。如果你的页面里发生了事件冒泡未捕获、或者preventDefault没调用,快捷键事件就被浏览器吃掉了。
排查思路很简单:在handleKeyDown里临时打印事件对象,看e.defaultPrevented是否为true,再逐步排除是浏览器默认行为还是其他脚本调用stopPropagation导致的问题。
5.2 keyCode 已废弃:code 与 key 如何取舍
老项目里大量使用e.keyCode === 38判断上方向键,但keyCode在标准里早就被标记为 deprecated。虽然浏览器仍然支持,但不建议在新代码里依赖它。
- 用
e.key,得到的是“用户按下这个键产生的字符”,比如按 Shift+a,e.key是'A'(大写),按 Dvorak 布局的键盘时e.key会跟随布局。 - 用
e.code,得到的是“物理按键在标准键盘上的位置”,比如同为左上角物理位置,e.code永远是KeyA,与键盘布局无关。
在构建快捷键系统时,我更推荐优先使用e.code,特别是游戏场景。按物理位置响应不发生错乱。但如果你做的是“中英文输入法切换后按 Ctrl+空格 仍能触发”,则需要用e.code忽略输入法干扰。
我的useKeyPress默认用e.key做精确匹配,同时把e.code作为备选匹配条件。配置项matchBy: 'key' | 'code' | 'both',默认'both'。这样入参'Meta+k'在 Mac 中文输入法状态下也能稳定触发。
5.3 依赖数组写错引发的监听失效
这是 Hooks 写多了以后最容易踩的坑。之前有个同事写:
const { pressed } = useKeyPress(targetKey);他把targetKey放在一个不稳定的对象里传入:
const target = { key: 'ctrl+k' }; const { pressed } = useKeyPress(target.key);这种没有问题。问题出在哪呢?他把整个对象传进去:
const { pressed } = useKeyPress(target);对象每次渲染都是新引用,useEffect每次渲染后都重新绑定事件,事件反复解绑再绑定。看起来功能没坏,但性能白白损耗,而且可能因为绑定窗口期产生事件丢失。
解决方案是内部对依赖做一次序列化比较,也就是只比较对象的字符串形式,比如JSON.stringify(keys),避免引用变化导致 repeated effect。序列化比较对数组、组合键字符串都很友好,但对包含函数或事件对象的参数无能为力,所以入参设计要尽量简单。
5.4 内存泄漏与 SSR 环境兼容
在服务端渲染(SSR)或静态生成的环境里,window对象在 Node 端是不存在的。直接把window.addEventListener写在useEffect里通常没问题,因为useEffect在 SSR 时不会执行。但如果有人在组件顶层调用了window,比如:
const isPressed = useKeyPress('a'); if (typeof window !== 'undefined') { // ... }这种写法就要小心了。组件在服务端渲染时window不存在,直接访问会报错。我的建议是:Hook 内部所有涉及浏览器 API 的操作都放到useEffect里,顶层代码只做纯函数封装,比如解析组合键字符串、判断修饰键匹配,这些操作不依赖浏览器环境。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 快捷键无任何反应 | 目标键名写错(如'Spacebar'应为' ') | 打印e.key对照检查 |
| 只在局部区域生效 | 事件绑定在容器上,焦点未进入容器 | 给容器加tabIndex,或改用全局监听 |
| 输入框打字触发快捷键 | 缺少可编辑元素判断 | 开启ignoreEditable配置 |
| 按住键状态不归位 | keyup丢失 | 在window blur或visibilitychange时重置状态 |
| 组合键重复触发 | e.ctrlKey判断不严格 | 改为全等于匹配修饰键状态 |
SSR 构建报window is not defined | 顶层访问了window | 把浏览器操作全部放进useEffect |
| 性能卡顿 | 每次按键都触发 React 渲染 | 游戏场景改用 ref 模式,或拆分状态粒度 |
6. 一个我踩过的小坑,分享出来
最后分享一个我在实际项目里踩过的坑。最初我把useKeyPress的targetKey直接用于事件回调,这本身没毛病,问题出在大小写。用户按Shift + a,e.key返回的是'A',而我传入的是'a',于是按键状态永远匹配不上。
后来我把e.key做了.toLowerCase()归一化,同时保留e.code的原始匹配。从那次之后,这个 Hook 就再也没有因为大小写问题出过 bug。如果你也要自己做键盘 Hook,建议一开始就把大小写归一化写进去,省得后面一个个踩。
另外还有一个实用技巧:给 Hook 增加一个onPressedChange回调参数,这样调用方既可以用返回值驱动 UI,也可以用回调处理副作用,两种模式自由选择。这个设计在快捷键录制、埋点上报场景里会帮上不少忙。
useKeyPress这个 Hook 不算复杂,但它把键盘交互这个高频场景用状态化的方式重新组织了一遍,让组件代码变得更干净、更可维护。如果屏幕前的你也在为满屏的addEventListener发愁,不妨动手试一下这个封装思路,迁移成本很低,收益却很直接。