1. 一个按键从手指到代码,中间隔了五六层翻译
keyCode 这个词,但凡写过前端表单快捷键、做过游戏手柄映射、或者被自动化测试的键盘输入坑过的人,多少都跟它打过照面。它看起来只是事件对象上的一个数字,实际上是一条从上世纪八十年代一路继承下来的“翻译链”的终点:手指按下键帽,键盘内部的主控芯片扫描到矩阵里的行列坐标,编成一串扫描码送给主机;操作系统驱动把它翻译成虚拟键码;浏览器再把它翻译成事件对象上的 keyCode。所以严格说,keyCode 并不是键盘按键的“身份证”,它只是这条链路上某一层的编号被沿用下来,被写进了早期网页规范,然后一直保留到今天。
这篇文章要解决几个具体问题:为什么每个键都需要一个唯一编号,这个唯一性到底在哪个层面成立;keyCode 的底层原理链条是怎么走的;在实际项目里它还能不能用、什么时候必须换成 event.code 或 event.key;以及那些文档里不会写的坑,比如全键盘输入法拦截、小键盘回车和主回车打架、外接 102 键键盘报出奇怪数字。内容适合三类人看:做 Web 交互和快捷键系统的开发者、做自动化测试和输入回放的同学、以及单纯想搞清楚“我按了一个键,电脑到底发生了什么”的好奇型选手。基础差一点也没关系,我会从键盘矩阵怎么扫描讲起,一路讲到浏览器事件对象。
先说一句结论性的判断,方便你带着预期往下读:keyCode 提供了“语义键位”的唯一性,但丢掉了“物理位置”的唯一性,所以它在今天只适合做兼容兜底,不适合当主键标识。真正该用的东西叫 event.code,它标识的是“你按的是键盘上哪个位置的键”,与字符、与输入法、与系统布局都无关。
1.1 keyCode 的三层身份:物理坐标、虚拟键位、文本字符
很多人把这三件事混成一件事,于是各种 bug 就来了。第一层是物理坐标:键盘矩阵里第 3 行第 5 列那个交叉点,它只回答“哪个位置被压下去了”。第二层是虚拟键位:操作系统驱动把它翻译成一个有名字的键,比如“回车”“左 Shift”“F5”,这一层关注的是“这是一个什么功能的键”。第三层是文本字符:输入法和布局把它变成 “a”“A”“中”“;”,这一层才是用户真正看到的东西。
keyCode 主要活在第二层,但它的数值定义又明显带着第一层的痕迹——字母区 A 到 Z 直接就是 65 到 90,数字 0 到 9 是 48 到 57,也就是 ASCII 码。这个设计在当时很省事:字符和键位能用同一套数字表示。代价是它天然跟“US 布局”绑死,一旦用户切到别的布局,或者用的是非标准键盘,这层映射就会露出破绽。
举个最典型的例子:小键盘上的回车和主键区的大回车,keyCode 都是 13。对一个“回车即发送”的聊天框来说这没问题,但对一个“把小键盘回车绑定为换行、主回车绑定为提交”的编辑器来说,这就是灾难,因为它根本区分不出来。要区分只能看 event.code,一个是 “Enter”,一个是 “NumpadEnter”。
1.2 为什么“唯一编号”这件事从硬件就开始了
回到最底层。键盘不是一堆独立的按钮各自拉一根线到主机,那样 104 个键就要 104 根线,成本和控制芯片引脚都不允许。实际做法是把按键排成矩阵:比如 8 行 × 18 列,行线和列线交叉,每个交叉点放一个机械轴或薄膜触点。主控芯片按顺序把每一行拉低电平,同时读所有列的电平状态,某一列读到低电平,就说明“这一行这一列交叉处的键被按下了”。
这套扫描逻辑要求每个键在矩阵里占据一个唯一的 (行, 列) 坐标,否则两个键会永远同时触发。这就是唯一性最原始的来源——不是为了给软件用,而是硬件电路本身的寻址需求。你可以把它理解成小区快递柜:每个格口都有一个唯一编号,快递员才能准确投递;如果两个格口共用一个编号,系统就不知道该开哪扇门。
扫描频率一般在 1000Hz 到 8000Hz 之间(也就是常说的 1ms 到 0.125ms 轮询一次),一次完整扫描会产生一份“当前所有按键状态”的位图。主控芯片把这份位图和上一次的位图做异或,就知道哪些键是新按下的、哪些是新松开的,然后只把这些变化编成报文发出去——这就是为什么你按住一个键不动,主机不会收到无限重复的“按下”事件,重复字符是由操作系统层的自动重复机制生成的。
2. 底层原理拆解:那串字节是怎么变成 keyCode 的
2.1 从矩阵坐标到扫描码:硬件层的第一次编码
主控芯片扫到坐标之后,要把它转成一个双方约定好的数字,这个数字就是扫描码(scancode)。老式 PC 键盘用的是 set 1/set 2 扫描码集,一个键按下叫“通码”,松开叫“断码”,通常是在通码上加 0x80。比如 Esc 的通码是 0x01,断码就是 0x81。有些键一个字节不够,要先发一个 0xE0 前缀表示“这是扩展键”,比如右 Ctrl、方向键、小键盘回车都走这条路径。
这套机制的遗留影响非常深。你在写底层键盘钩子或者做硬件级按键监听时会发现,方向键和数字小键盘共用一套基础码,靠 0xE0 前缀区分;小键盘的 NumLock 打开和关闭时,同一个物理键会发出完全不同的扫描码序列。这正是“一个物理键可能对应多个逻辑键”的源头,也是后来各种映射表必须写得很长的原因。
到了 USB 时代,扫描码换了个名字叫HID Usage ID,定义在 HID Usage Tables 的 Keyboard/Keypad 页(Usage Page 0x07)里。这套编号更整齐:字母 a 到 z 是 0x04 到 0x1D,数字 1 到 0 是 0x1E 到 0x27,回车 0x28,Esc 0x29,退格 0x2A,Tab 0x2B,空格 0x2C。USB 键盘上报的是一个 8 字节的报告描述符,第 0 字节是修饰键位图(左 Ctrl、左 Shift、左 Alt、左 GUI、右 Ctrl、右 Shift、右 Alt、右 GUI 各占一位),后面最多 6 个字节放普通键的 Usage ID,全 0 表示没有按键。
注意:这个“最多 6 键”的限制就是常说的 6KRO(6 键无冲)。它跟键盘矩阵的鬼键问题是两码事——前者是 USB 协议报文长度决定的,后者是电路设计决定的。很多标称“全键无冲”的键盘,是靠把多个按键拆到多个 HID 接口上报,或者在 USB 报文里做特殊处理来实现的。
2.2 鬼键与全键无冲:唯一性在电路层的现实妥协
矩阵扫描有个绕不开的物理缺陷,叫鬼键。假设三个键构成矩阵上的一个矩形三个角,同时按下时,电流会从第四个角“绕路”流过,导致芯片误判第四个键也被按下了。表现就是:你同时按 W、A、S,屏幕上却像是 W、A、S、D 一起触发。
解决办法是在每个按键上串一个二极管,让电流只能单向流动,绕路路径被切断,这就是“全键无冲”的硬件基础。没有二极管的薄膜键盘通常只能做到 2 到 6 键无冲,厂商会做“矩阵盲区规避”,把常用的游戏键位(WASD、方向键、空格、Shift)刻意排到不容易冲突的行列上。
这段硬件知识对软件开发者有什么用?用处很直接:当你收到“用户同时按了三个键但只有两个生效”的反馈时,先别怀疑代码,先让用户换个键盘试试。我遇到过不止一次,测试同学报“组合键偶尔失灵”,最后查出来是他那把办公薄膜键盘在特定三键组合下出现了鬼键。这种情况你在代码里怎么改都没用,只能换硬件或者改键位设计。
2.3 操作系统的虚拟键码:Windows 的 VK、Linux 的 evdev、macOS 的 keycode
HID 报文到了操作系统,驱动会把它翻译成平台自己的虚拟键码。Windows 上是 VK_* 常量,VK_A 是 0x41,VK_RETURN 是 0x0D,VK_SHIFT 是 0x10,VK_F1 是 0x70;Linux 上走 evdev,键码是另外一套数字(KEY_A 是 30,KEY_ENTER 是 28);macOS 又是一套(kVK_ANSI_A 是 0,kVK_Return 是 36)。
这三套编号互不相同,但都有一个共同特点:它们描述的是“功能键位”而不是“字符”。你在 Windows 上按 A 键,不管当前是中文输入法还是英文,驱动报给上层的一直是 VK_A;输入法是在更上层把这个虚拟键码结合当前状态翻译成字符的。这就是为什么全局快捷键能在输入法激活时依然生效——它工作在这一层,压根不关心你打出来的是什么汉字。
浏览器做的事情,本质上是拿这套虚拟键码去查表,填进 KeyboardEvent 的 keyCode 字段。早期浏览器各写各的表,导致同一个键在不同浏览器里 keyCode 不一样。最出名的历史遗留是分号键:Firefox 长期报 59(那是 ASCII 里分号的码),其他浏览器报 186。类似的还有等号键(Firefox 报 61,其他报 187)、减号键等。后来规范做了统一,但老版本留下的兼容判断代码至今还活在很多项目里。
2.4 浏览器里的三兄弟:key、code、keyCode 各管什么
现代浏览器在 keydown 事件上会同时给你三个值,理解它们的差异是写对键位逻辑的前提。
| 属性 | 含义 | 示例 | 受布局影响 | 受 Shift/CapsLock 影响 | 受输入法影响 | 现状 |
|---|---|---|---|---|---|---|
event.key | 逻辑字符或键名 | "a"、"A"、"中"、"Enter"、"F5" | 是 | 是 | 是 | 标准,推荐用于展示 |
event.code | 物理位置标识 | "KeyA"、"Digit1"、"ShiftLeft"、"NumpadEnter" | 否 | 否 | 否 | 标准,推荐用于键位绑定 |
event.keyCode | 旧版数字编号 | 65、13、16 | 部分 | 否 | 会变成 229 | 已废弃,仅作兼容 |
event.which | 更老的兼容字段 | 同 keyCode | 部分 | 否 | 会变成 229 | 已废弃 |
这张表里最值得盯的是“受输入法影响”那一列。在中文输入法激活状态下敲字母,keydown 事件的 keyCode 可能直接报 229(对应 Windows 的 VK_PROCESSKEY),event.key 是"Process",而 event.code 依然是准确的"KeyA"。如果你用 keyCode 做快捷键,中文用户会告诉你“快捷键时灵时不灵”;如果你用 code,这个问题根本不存在。这是我踩过最值钱的一个坑,后面还会细说。
event.code的命名规则也值得记一下:字母是KeyA到KeyZ,主键区数字是Digit0到Digit9,小键盘数字是Numpad0到Numpad9,功能键F1到F12,左右修饰键靠后缀区分(ShiftLeft/ShiftRight、ControlLeft/ControlRight、AltLeft/AltRight、MetaLeft/MetaRight),标点是英文名(Backquote、Minus、Equal、BracketLeft、Backslash、Semicolon、Quote、Comma、Period、Slash)。
3. 唯一性的真相:它在哪一层成立,在哪一层根本不成立
3.1 同一个 keyCode 对应多个物理键,这是设计而不是 bug
很多人第一次发现左右 Shift 的 keyCode 都是 16 时会愣一下。类似的情况还有:左右 Ctrl 都是 17,左右 Alt 都是 18,左右 Meta(Win/Cmd)都是 91 或 92(历史上各浏览器不统一,Chrome 上左 Win 是 91、右 Win 是 92,Safari 上两个都是 91 或 93,混乱程度相当可观)。小键盘回车和主回车都是 13,小键盘的加减乘除和主键区的符号键也各自撞车。
这说明 keyCode 的唯一性粒度是**“功能键位”,不是“物理按键”**。对于快捷键系统这是够用的,因为没人会想给左 Shift 和右 Shift 绑两个不同的功能。但对于游戏、音游、以及需要精细控制的外设场景,这就不够了——玩家会想用左 Shift 冲刺、右 Shift 蹲下,你必须在 code 层面区分。
我的做法是:在事件入口处同时读 code 和 key,用一个内部 keyId 统一表示。内部 keyId 优先取 code(保证物理唯一),取不到再退化到 keyCode 拼一个前缀(比如legacy:65),这样老浏览器也能跑,新浏览器行为正确。
3.2 布局切换会让同一个物理键报出不同的 key 和 keyCode
键盘布局这东西,对开发者来说经常是个抽象概念,直到你遇到一个用 AZERTY 布局的法国用户。在 AZERTY 上,按物理位置 Q 那一格的键,打出的是 “a”;按 A 那一格的键,打出的是 “q”。这时候:
event.code报"KeyQ"(物理位置没变)event.key报"a"(字符变了)event.keyCode报 65(跟着字符走了)
Dvorak 布局、Colemak 布局、以及各种语言的自定义布局都会造成类似偏移。这意味着任何用 keyCode 或 key 存进用户配置的快捷键,在用户换布局或者换机器之后都可能失效或者错乱。而用 code 存的快捷键,跟着的是物理位置,用户换布局后手感不变——虽然他打出字符的位置变了,但“按这个位置的键触发这个功能”这个直觉是一致的。
这里有个细节值得注意:event.code的命名是基于“US 布局下这个位置的键叫什么”,所以"KeyQ"这个字符串在 AZERTY 机器上代表的仍然是 QWERTY 键盘上 Q 的那个物理位置。这个约定有点绕,但只要记住“code 认位置不认字符”就不会错。
3.3 keyCode 的退场与 key/code 的分工
keyCode 在 DOM 规范里已经被标记为废弃,keypress事件更是直接移除。但“废弃”不等于“立刻不能用”,主流浏览器至今仍然保留它,因为海量的老代码依赖它。现实是:你新写的代码不应该再用它,但你在维护老系统时还得看得懂它。
我把这三个属性的分工总结成一句话:code 用来绑键位,key 用来显示,keyCode 只在处理老浏览器兼容时作为兜底。展示层为什么不用 code?因为给用户看"KeyA"不如看"A"直观,给用户看"Semicolon"不如看";"直观。所以键位设置界面的做法是:捕获时存 code,渲染时用当前事件或一次模拟事件把 code 翻译成 key 显示。
4. 使用场景盘点:什么时候你非得有一个稳定键标识
4.1 快捷键引擎:一套编号支撑整个可配置体系
只要你的产品有“用户自定义快捷键”功能,就绕不开键位标识。要考虑的问题比想象中多:一个快捷键由主键加若干修饰键组成,修饰键要区分左右吗?同一组快捷键在不同操作系统上默认值一样吗?用户设置冲突了怎么提示?配置存在本地还是云端?跨设备同步时怎么保证语义一致?
我做过的一个截图工具就吃过亏:早期用 keyCode 存配置,结果 Mac 用户的 Cmd 键 keyCode 被不同浏览器报成了不同的数字,同一个配置文件在 Chrome 和 Safari 里读到的是两个键,用户切浏览器就失效。后来改成 code(MetaLeft),问题当场消失。再后来加上“左/右修饰键合并”的选项(默认合并,高级用户可拆开),才算真正稳了。
修饰键合并这件事也有讲究。事件对象上的ctrlKey、shiftKey、altKey、metaKey四个布尔值只告诉你“有没有按”,不告诉你“按的哪一边”。要区分左右必须自己维护一个修饰键状态表,在 keydown/keyup 时按 code 记下来,而且要处理窗口失焦时按下状态没重置的问题——用户按着 Shift 切走了窗口,回来时你的状态表里 Shift 还是“按下”,所有输入都被当成大写处理。
4.2 游戏与实时交互:物理位置是唯一正确的选择
游戏是最能体现 code 价值的场景。WASD 四键在物理键盘上是一个固定的方块,玩家肌肉记忆记的是位置不是字母。如果玩家用了别的布局,用 key 判断会导致“按 W 打出 Z”这种荒谬情况——角色不往前走,反而打出一行字。
更进一步的是音游和格斗游戏,它们会用到左右 Shift、左右 Ctrl 的区分,甚至需要处理“同一个键按下和抬起的精确时间戳”。这类场景对 keyCode 的错误尤其敏感,因为 keyCode 丢失了左右信息,会让玩家觉得“两个键绑定成了一个”。
还有一个游戏开发常遇到的坑:keydown的自动重复。玩家按住方向键,浏览器会持续触发 keydown(重复率取决于系统设置),如果代码里写的是“keydown 时速度加一”,角色会瞬间加速到离谱。正确做法是用一个 Set 记录当前按下的 code,keydown 时只做“首次按下”的逻辑(判断 Set 里是否已存在),keyup 时移除,主循环里读 Set 算方向。这个模式我在每个游戏项目里都会写一遍。
4.3 自动化测试与操作回放:编号必须可序列化、可重放
自动化测试里的键盘输入,本质是把“人按键”翻译成“可序列化的指令”。Playwright 用的是page.keyboard.press('KeyA'),Puppeteer 类似,Selenium 用Keys枚举。它们几乎都优先接受 code 风格的字符串,因为这是唯一能稳定复现的东西。
我见过一种很坑的回放方案:录制用户操作时存了 keyCode 数组,回放时用dispatchEvent手动合成事件。结果在两个地方崩了:一是合成事件的isTrusted是 false,被页面的安全校验拦掉;二是不同浏览器的 keyCode 映射不一致,回放出来的字符错位。修的办法是切到 Playwright 的 keyboard API,它走的是浏览器原生输入通道,能正确触发输入法和组合事件。
如果你的自动化场景涉及富文本编辑器、画布应用、或者需要处理 IME 组合输入,还要额外注意compositionstart、compositionupdate、compositionend这三个事件。单纯发 keydown/keyup 是不足以模拟中文输入的,必须走完整的组合事件序列,否则编辑器会认为你在输入过程中取消了一次组合。
4.4 无障碍与特殊输入设备:键标识是映射层的锚点
无障碍场景里,键位标识的作用被放大了。屏幕阅读器、头部追踪设备、单键开关(switch access)、脚踏板这些替代输入设备,本质上都是把某个物理输入映射成一个标准键位。它们需要一个稳定的锚点来挂钩。
还有一类场景是“重映射工具”,比如让用户把 CapsLock 改成 Ctrl、把某个键改成组合宏。这类工具工作在操作系统的驱动或钩子层,处理的是扫描码或虚拟键码,比浏览器层更底层。你在网页里没法拦截 CapsLock 的默认行为(它不会触发 keydown 的默认动作),也没法阻止某些系统级组合键。这一点要有预期:Web 层的键盘控制能力是有边界的,超过边界的需求只能靠桌面端程序或者浏览器扩展。
4.5 输入法、密码框与安全输入:key 是最不可信的那个
输入法激活时的行为差异,前面提过一次,这里展开说。中文、日文、韩文输入法在组合状态下,keydown 的key会是"Process",keyCode是 229,code仍然准确。这就是为什么国际化的产品如果要做“输入时拦截某键”,必须同时判断event.isComposing或者监听组合事件,否则用户在打字打到一半时按 Esc 想取消候选词,你的代码却把整个弹窗关掉了。
密码框是另一个特殊区域。某些浏览器在密码输入框上会限制部分键盘事件的信息量,或者对修饰键组合做特殊处理。做“密码强度实时提示”时,不要去读event.key拼字符,那是自找麻烦;应该监听input事件读input.value,键盘事件只用来做 CapsLock 状态提示这类辅助功能。
CapsLock 状态检测本身也有讲究,event.getModifierState('CapsLock')是标准做法,但它只在键盘事件里有值,页面加载时想主动查一次是拿不到的。我的处理是:页面加载时先不提示,等用户第一次在密码框里按键时再判断,避免误报。
5. 落地实操:键位方案怎么选、怎么写、怎么存
5.1 一张表定下选型,别在项目中期改主意
选型这件事最好在项目初期定死,中途改会牵动配置格式、迁移逻辑、用户已保存的键位。下面这张表是我自己用的判断依据。
| 需求 | 推荐标识 | 理由 | 不推荐的替代 |
|---|---|---|---|
| 用户自定义快捷键 | event.code | 物理位置稳定,跨布局一致 | keyCode(左右不分、布局敏感) |
| 界面展示按键名称 | event.key | 用户看得懂,跟随本地布局 | code(显示KeyA很怪) |
| 游戏角色控制 | event.code | 肌肉记忆基于位置 | key(布局一变全乱) |
| 输入字符内容 | input事件 +value | 能拿到输入法组合后的最终文本 | keyCode(组合态是 229) |
| 老浏览器兼容兜底 | event.keyCode | 老环境仅此可用 | — |
| 全局系统级热键 | 平台虚拟键码 | 工作在驱动/系统层 | 浏览器事件(拦截不到) |
| 自动化回放 | code 风格字符串 | 可序列化、跨环境一致 | 坐标点击(脆弱) |
这张表的用法很简单:一个功能只选一个主标识,其余的全部通过映射层转换。最忌讳的是不同模块各用一套,最后配置里混着 code、keyCode、key 三种格式,迁移脚本写到怀疑人生。
5.2 一段可复用的按键归一化代码
下面这段是我在多个项目里反复改出来的入口函数,作用是把一次键盘事件归一成一个内部 keyId,同时保留展示用的标签。核心思路是:优先 code,退化到 keyCode 加前缀,修饰键单独处理左右合并。
const MODIFIER_CODES = new Set([ 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight' ]); // 把事件归一成内部标识;mergeModifier 为 true 时左右修饰键合并 function normalizeKey(event, { mergeModifier = true } = {}) { let id = event.code; // 老环境或无 code 时退化,加前缀避免与 code 撞名 if (!id) { if (event.keyCode) { id = `legacy:${event.keyCode}`; } else { id = `key:${event.key || 'unknown'}`; } } if (mergeModifier && MODIFIER_CODES.has(id)) { id = id.replace(/Left$|Right$/, ''); } return { id, label: formatLabel(event, id), // 展示用,优先 key ctrl: !!event.ctrlKey, shift: !!event.shiftKey, alt: !!event.altKey, meta: !!event.metaKey, composing: event.isComposing || event.keyCode === 229 }; } function formatLabel(event, id) { // key 适合展示,但要过滤掉 'Process' 这类无意义值 const k = event.key; if (k && k !== 'Process' && k !== 'Unidentified') { return k.length === 1 ? k.toUpperCase() : k; } // 退化:把 code 转成人类可读 return id.replace(/^Key/, '').replace(/^Digit/, ''); }配套的冲突检测,用组合字符串做 key 就行。顺序固定为 Ctrl、Alt、Shift、Meta 加主键,这样Ctrl+Shift+A无论用户先按哪个修饰键都能命中同一个键。
function buildShortcutKey(k, { mergeModifier = true } = {}) { const parts = []; if (k.ctrl) parts.push('Ctrl'); if (k.alt) parts.push('Alt'); if (k.shift) parts.push('Shift'); if (k.meta) parts.push('Meta'); parts.push(k.id); return parts.join('+'); } // 注册表:Map<shortcutKey, handler> const registry = new Map(); function register(shortcut, handler) { if (registry.has(shortcut)) { // 冲突要显式抛错,不要静默覆盖 throw new Error(`快捷键冲突: ${shortcut}`); } registry.set(shortcut, handler); }注意:捕获快捷键时一定要在 keydown 里判断
event.repeat,按住不放时浏览器会持续触发 keydown,不判断的话一次长按会触发几十次。另外别忘了event.preventDefault(),否则浏览器默认行为(比如 Ctrl+S 保存网页)会和你的快捷键打架。
5.3 键位配置的持久化与迁移
存配置这件事,我踩过的坑比写逻辑还多。总结几条硬规矩:
第一,存储结构要带版本号。配置对象里放一个schemaVersion字段,加载时先看版本,版本不匹配就走迁移函数。没有版本号,你以后想从 keyCode 迁到 code 都不知道哪些是老数据。
第二,每条快捷键存三样东西:内部 id(用于匹配)、展示标签(用于显示,允许过期)、平台标记(mac 和 win 的默认修饰键不同)。展示标签过期没关系,加载时用一条映射表重新生成,或者直接用兜底规则把 code 转成人类可读形式。
第三,默认键位要按平台给。Windows/Linux 用 Ctrl 打头,macOS 用 Meta 打头,用户第一次打开时看到的应该是符合直觉的默认值。检测平台别再用 userAgent 字符串硬匹配了,navigator.userAgentData?.platform或navigator.platform(虽然也标记废弃但兼容性好)都可以,取不到就默认按 Ctrl 处理。
第四,合并修饰键的选项要留出来。普通用户不需要区分左右 Shift,但少数高级用户(音游玩家、剪辑软件用户)会需要。默认合并,提供一个“高级键位设置”开关让他们拆开,这样既不增加认知负担,又不堵死专业需求。
6. 常见问题与排查技巧实录
6.1 一张速查表,覆盖八成现场问题
| 现象 | 大概率原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 中文输入法下快捷键失效 | 用了 keyCode,组合态报 229 | 打印event.key看是否Process | 改用event.code |
| 左右 Shift 无法区分 | keyCode 对修饰键不区分左右 | 打印event.code | 用ShiftLeft/ShiftRight |
| 小键盘回车和主回车冲突 | 都是 keyCode 13 | 看 code 是否有Numpad前缀 | 用 code 区分绑定 |
| 按住键触发几十次 | 没判event.repeat | 打日志看是否连续 keydown | 加 repeat 判断或改状态机 |
| 换键盘布局后快捷键错位 | 配置存的是 key/keyCode | 检查配置里的字段类型 | 迁移到 code 并写版本迁移 |
| 某些组合键完全收不到事件 | 被系统或浏览器占用 | 换组合试、查浏览器保留键 | 让用户改键位,别硬刚 |
| 窗口失焦后修饰键状态残留 | 没监听 blur 重置状态 | 检查状态表 | 监听window.blur清空按下集合 |
| 外接 102 键键盘反斜杠报怪值 | keyCode 映射不一致 | 打印 code 看是否IntlBackslash | 统一按 code 处理 |
| 密码框里拿不到字符 | 浏览器安全限制 | 看能否读input.value | 改监听input事件 |
| 组合输入过程中 Esc 误关弹窗 | 没判组合态 | 看event.isComposing | 组合态时忽略业务快捷键 |
6.2 几个只有踩过才知道的细节
浏览器保留组合键是没有商量余地的。Ctrl+W 关标签页、Ctrl+T 开新标签、Ctrl+N 开新窗口、Cmd+Q 退出,这些组合你在网页里preventDefault是拦不住的。做快捷键设计时,把这类组合从可选列表里直接排除,比让用户设完发现没用要好得多。我曾经做过一个“为什么我的快捷键不生效”的客服工单统计,前三名全是撞了浏览器保留键。
修饰键的状态要靠自己维护,别信事件对象。event.shiftKey这类布尔值在某些边界情况下会“卡住”:用户按着 Shift 用 Alt+Tab 切走,回来时事件对象可能还报着 shift 为 true,或者你的状态表里残留着按下记录。稳妥做法是维护一个Set记录当前按下的 code,window的blur事件里清空它,键盘事件里以这个 Set 为准,事件对象的布尔值只用来做交叉校验。
event.code在虚拟键盘和远程桌面下可能不准。屏幕键盘、某些远程控制软件、以及部分移动端外接键盘,上报的 code 可能是空值或者奇怪的字符串。这种情况必须有兜底路径,也就是我前面代码里那个legacy:前缀。你不需要猜它是什么键,只需要保证“同一个来源每次上报的标识一致”,键位就能正常工作和保存。
别在 keydown 里做重活。keydown 是高频事件,按住不放每秒能触发几十次,里面做 DOM 查询、状态更新、网络请求都会卡。正确的分层是:keydown/keyup 只更新状态,真正的业务逻辑放到requestAnimationFrame或防抖之后的回调里。游戏循环和快捷键引擎我都用这个模式,帧率曲线明显更平。
测试键位功能时,一定要用真实的物理键盘,别只用自动化工具。自动化工具发的是合成事件,很多真实世界的问题它测不出来:输入法组合、系统级拦截、修饰键左右区分、外接键盘的特殊键位。我现在的做法是列一张“手测清单”,每次改键位模块都过一遍:中文输入法下按快捷键、左右 Shift 各试一次、小键盘回车单独试一次、按住不放看是否重复触发、切窗口回来试一次。这五个动作能覆盖绝大部分回归问题。
最后分享一个我最近才体会到的小经验:做键位系统,最贵的成本不在实现,而在给用户一个能自己救自己的出口。无论你的映射做得多完善,总会有用户用到你没见过的键盘、没适配的系统、没预料到的输入法。所以一定要有一个“恢复默认键位”的按钮,以及一个能看到“我这一按到底上报了什么”的调试面板。后者你可以藏在设置页的角落,但它能省掉大量的客服沟通——用户把面板上的 code 值发给你,问题基本就定位了一半。我在实际项目中把这个调试面板做了出来,键位相关的反馈量直接降了大半,因为用户自己就能看出“哦,我这个键上报的是 IntlBackslash,怪不得”。