ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

cheet.js 源码解析:键盘序列彩蛋的状态机实现与实战

cheet.js 源码解析:键盘序列彩蛋的状态机实现与实战 1. 从一个键盘彩蛋说起cheet.js 到底在解决什么问题第一次接触 cheet.js 是在做一个内部工具站的时候产品同学跑过来问“能不能加个彩蛋就是那种输入一串按键屏幕上掉下来一堆东西的效果。”当时我脑子里第一反应是监听keydown把按键拼成字符串然后跟预设序列比对。听起来三行代码就能搞定但真正动手才发现坑不少用户按得快了会丢键、按了Shift之后keyCode变了、序列中间夹杂无关按键要不要重置、页面里已经有输入框在接收键盘事件怎么办。这些问题堆在一起就不是三行代码能收场的了。cheet.js 就是冲着这类需求来的一个轻量库。它的定位很明确给网页加“科乐美秘技”式的键盘序列彩蛋。所谓科乐美秘技就是那个经典的“上上下下左右左右BA”序列很多老游戏里输入它就能解锁隐藏内容。cheet.js 把这个模式抽象出来让你用一行配置就能注册一个按键序列触发时执行回调。它解决的核心问题有三个一是把杂乱的keyCode映射统一成可读的按键名二是用状态机的方式做序列检测保证顺序和连续性三是处理全局键盘监听与页面已有交互的冲突。这篇文章适合两类人看。一类是手上有彩蛋需求、想直接拿库来用的开发者我会把配置方式和常见坑讲清楚另一类是想搞明白“序列检测”这类逻辑该怎么写的同学我会把 cheet.js 的源码思路拆开从keyCode映射表讲到状态机的推进与重置再给出一个可以自己手写的最小实现。读完你既能直接用也能自己造一个。2. 整体设计思路为什么是“映射表 状态机”这套组合2.1 核心需求拆解序列检测到底难在哪很多人觉得序列检测就是字符串匹配input.indexOf(target) ! -1就完事了。但键盘输入和字符串有个本质区别字符串是离散的、完整的而键盘事件是流式的、可能被打断的。用户在输入序列的过程中随时可能按到别的键也可能中途停顿很久还可能按住某个键不放触发重复事件。这些情况字符串匹配都处理不了。我把序列检测的难点归纳成四条。第一是顺序性必须严格按 A→B→C 的顺序按成 A→C→B 不算。第二是连续性序列中间插入无关按键时是忽略继续还是重置从头来这决定了用户体验。第三是归一化同一个物理按键在不同修饰键状态下keyCode可能不同比如按 Shift 再按数字 1keyCode是 49 还是 33 取决于浏览器和键盘布局得统一处理。第四是边界控制什么时候开始监听、什么时候停止、多个序列之间会不会互相干扰。cheet.js 的设计基本就是围绕这四条来的。它没有用字符串匹配而是用了一个逐字符推进的状态机维护一个当前匹配到序列第几位的指针每来一个按键就和目标序列对应位置的按键比对匹配就指针加一不匹配就根据策略决定重置还是保持。这个思路比字符串匹配灵活得多因为每一步都可以插入自定义逻辑。2.2 方案选型为什么不用现成的键盘库市面上处理键盘事件的库不少有做快捷键绑定的有做输入法兼容的为什么 cheet.js 要自己造轮子我分析下来有几个原因。快捷键库通常关注的是“组合键”比如 CtrlS它的模型是“修饰键 主键”的集合匹配而序列检测关注的是“时间上的先后顺序”两者模型不一样。用快捷键库去实现序列得自己维护历史记录反而更麻烦。另一个原因是体积和依赖。cheet.js 的目标是极简整个库压缩后很小没有外部依赖直接引入就能用。如果依赖一个大的键盘库为了一个彩蛋功能引入几百 KB 的代码性价比太低。这其实是个很务实的工程决策功能单一、场景明确的工具自己实现往往比引入通用库更划算。还有一点是可读性。cheet.js 允许你用up up down down left right left right b a这样的字符串来定义序列而不是写一堆keyCode数字。这对后续维护太重要了半年后你回来看代码一眼就知道这是个科乐美秘技而不是对着一串 38 38 40 40 37 39 37 39 66 65 发呆。这种“面向人类可读”的设计取向贯穿了整个库。2.3 状态机的推进与重置策略状态机的核心是两个操作推进和重置。推进就是当前按键匹配成功指针后移一位重置就是把指针归零从头开始匹配。难点在于什么时候重置。cheet.js 默认的策略是当前按键不匹配目标序列中指针位置的按键时检查这个按键是不是目标序列的第一个按键如果是指针置为 1相当于重新开始否则指针归零。这个策略很巧妙。举个例子目标序列是a b c。用户输入a b x a b c。当输入到x时指针在位置 2期待cx不匹配但x也不是序列首字符a所以指针归零。接着输入a匹配首字符指针置 1然后b置 2c置 3触发。如果用户输入的是a b a b c在第二个a时它不匹配期待的c但它是首字符所以指针重置为 1 而不是 0这样就不会丢掉这个a后续b c接上就能触发。这个细节处理得很到位避免了“用户其实按对了但因为中间多按了一个首字符导致失败”的情况。提示这个“不匹配时检查是否为首字符”的逻辑是很多手写序列检测容易忽略的点。如果你自己实现一定要加上否则用户体验会差很多。3. KeyCode 映射把数字变成人话的中间层3.1 为什么需要映射表浏览器键盘事件里的keyCode是个历史遗留产物它是一组数字比如 37 是左箭头38 是上箭头65 是字母 A。直接拿数字写逻辑代码可读性极差而且容易记错。更麻烦的是不同浏览器、不同键盘布局下同一个物理按键的keyCode可能不一致尤其是符号键和功能键。cheet.js 的做法是建一张映射表把常用的按键名和keyCode对应起来用户配置时写名字内部查表转成数字比对。这张表的设计有个取舍是覆盖全部按键还是只覆盖常用按键。全覆盖的话表会很大而且很多按键比如多媒体键、浏览器功能键在彩蛋场景里根本用不到。cheet.js 选择了覆盖常用的一批方向键、字母、数字、功能键、以及一些特殊键如回车、空格、Shift 等。这个取舍很合理因为彩蛋序列通常就是这些键的组合。映射表的结构大致是这样的我用伪代码表示实际源码里是一个对象var keyMap { backspace: 8, tab: 9, enter: 13, shift: 16, ctrl: 17, alt: 18, pause: 19, capslock: 20, esc: 27, space: 32, pageup: 33, pagedown: 34, end: 35, home: 36, left: 37, up: 38, right: 39, down: 40, // ... 字母和数字 a: 65, b: 66, c: 67, // ... 以此类推 };3.2 映射表的查找与容错用户配置序列时可能写大写也可能写小写可能写left也可能写Left。cheet.js 在解析序列时会做归一化统一转成小写然后查表。如果查不到说明用户写了个不存在的按键名这时候有两种处理方式一是静默忽略二是报错提示。cheet.js 选择了前者因为彩蛋功能不应该因为配置错误就阻塞整个页面。但作为使用者你得自己保证按键名拼写正确否则序列永远触发不了还找不到原因。这里有个实操经验配置序列时先在控制台打印一下keyMap确认你要用的按键名在表里。我有次想用meta键就是 Mac 上的 Command 键结果发现表里没有序列死活不触发排查了半天才发现是按键名不支持。后来我干脆在初始化时加了一段校验把序列里每个词都查一遍表查不到就在控制台警告省得以后踩同样的坑。function validateSequence(seq) { var keys seq.toLowerCase().split( ); keys.forEach(function(k) { if (!keyMap[k]) { console.warn(cheet.js: 未知按键名 k 该序列可能无法触发); } }); }3.3 修饰键的处理细节修饰键Shift、Ctrl、Alt在序列检测里是个特殊存在。它们本身可以作为序列的一部分比如shift a表示先按 Shift 再按 A。但问题是当你按 Shift 的时候浏览器会先触发一个keydown事件keyCode是 16然后你按 A触发的事件keyCode是 65但此时shiftKey属性是 true。如果你只比对keyCode那shift a和单独按a在序列层面看起来是一样的都是先 16 后 65但实际上用户可能只是想输入大写 A。cheet.js 的处理方式是把修饰键当作普通按键对待也就是说shift a要求用户真的先按一下 Shift 松开再按 A。这符合彩蛋序列的使用习惯因为彩蛋通常是刻意输入的不会和正常打字混淆。但如果你把彩蛋序列设成a这种单键那用户在输入框里打字就会频繁触发所以单键序列一定要配合“仅在非输入状态下生效”的判断这个后面会讲。注意不要用修饰键组合如ctrl s来做彩蛋序列因为浏览器和操作系统会拦截很多组合键你的keydown可能根本收不到。彩蛋序列尽量用方向键、字母、数字这些“干净”的键。4. 序列检测的完整实现从事件监听到回调触发4.1 事件监听与初始化流程cheet.js 的初始化流程大致分三步。第一步是解析配置把用户传入的序列字符串如up up down down拆成按键名数组再通过映射表转成keyCode数组。第二步是注册全局键盘监听在document上挂一个keydown处理器。第三步是维护状态每个序列对应一个独立的指针记录当前匹配到第几位。这里有个设计选择是每个序列一个监听器还是所有序列共用一个监听器。显然共用一个更合理因为键盘事件是全局的挂多个监听器既浪费性能又容易出乱子。共用一个监听器意味着在处理器内部要遍历所有已注册的序列逐个更新它们的状态。序列数量通常很少几个到十几个遍历的开销可以忽略。var sequences []; // 存储所有注册的序列 function registerSequence(seqStr, callback) { var keys seqStr.toLowerCase().split( ); var codes keys.map(function(k) { return keyMap[k]; }); sequences.push({ codes: codes, callback: callback, pointer: 0 }); } document.addEventListener(keydown, function(e) { var code e.keyCode; sequences.forEach(function(seq) { updateSequence(seq, code); }); });4.2 状态推进的核心逻辑updateSequence是整个库的心脏。它的逻辑是拿当前按键的keyCode和序列中指针位置的keyCode比对。如果相等指针加一如果指针达到了序列长度说明匹配完成触发回调并把指针归零如果不相等检查当前按键是否等于序列的第一个按键是则指针置为 1否则指针归零。function updateSequence(seq, code) { if (code seq.codes[seq.pointer]) { seq.pointer; if (seq.pointer seq.codes.length) { seq.callback(); seq.pointer 0; } } else { // 不匹配时检查是否为首字符 if (code seq.codes[0]) { seq.pointer 1; } else { seq.pointer 0; } } }这段代码看着简单但有几个细节值得说。第一指针归零的时机。匹配完成后立即归零这样用户可以连续触发同一个彩蛋不用刷新页面。第二首字符检查的顺序。必须先判断是否匹配当前指针位置再判断是否为首字符否则当序列首字符和当前期待字符相同时会出错。比如序列是a a b指针在位置 1期待第二个a用户按a应该推进到位置 2而不是因为“是首字符”就重置为 1。第三大小写和修饰键的影响。keyCode对字母键是不区分大小写的按 A 和按 a 都是 65所以序列里写a和A效果一样这简化了处理。4.3 回调触发与多序列共存多个序列共存时每个序列有独立的指针互不干扰。但有个潜在问题如果两个序列有相同的前缀会不会互相影响。比如序列一是a b c序列二是a b d。用户输入a b时两个序列的指针都到了 2。接着输入c序列一触发序列二的指针因为不匹配c且c不是首字符a归零。这个行为是正确的用户输入a b c就应该只触发序列一。但如果用户想触发序列二输入a b d序列一在收到d时指针归零序列二触发。也没问题。唯一需要注意的是回调执行的顺序如果两个序列同时触发比如序列一是a序列二是a这种重复配置会按注册顺序依次执行。实际使用中应该避免重复序列否则行为不可预期。实操心得注册序列时把长序列放在短序列前面注册。因为如果短序列是长序列的前缀比如先注册a再注册a b c用户输入a b c时按到a的瞬间短序列就触发了长序列还没走完。虽然两个都会触发短序列触发后指针归零长序列继续但如果你只想触发长序列就会多触发一次短序列。把长的放前面至少保证长序列优先匹配。5. 实战配置把 cheet.js 用起来的完整步骤5.1 引入与基础配置cheet.js 的使用非常直接。引入脚本后调用cheet()函数注册序列。它的 API 设计是链式的可以一次注册多个序列cheet(up up down down left right left right b a, function() { console.log(科乐美秘技触发); // 这里写你的彩蛋逻辑 document.body.style.background gold; });如果你有多个彩蛋可以这样写cheet(a b c, function() { alert(序列一); }); cheet(x y z, function() { alert(序列二); });配置时序列字符串用空格分隔大小写不敏感。回调函数在序列完整匹配后执行执行完指针自动归零可以再次触发。5.2 与页面交互的冲突处理最大的坑来了如果页面上有输入框用户在输入框里打字会触发全局键盘监听。比如你的彩蛋序列是a b c用户在搜索框里输入 “abc”彩蛋就被触发了这显然不是想要的。解决办法是在键盘处理器里判断事件源如果焦点在输入框、文本域或可编辑元素上就跳过序列检测。document.addEventListener(keydown, function(e) { var target e.target; var tagName target.tagName.toLowerCase(); if (tagName input || tagName textarea || target.isContentEditable) { return; // 输入状态下不检测彩蛋 } // ... 正常的序列检测逻辑 });这个判断要放在最前面尽早返回避免不必要的计算。另外有些富文本编辑器用的是contenteditable属性而不是textarea所以isContentEditable的判断不能少。我踩过的坑是只判断了input和textarea结果在一个用contenteditable做的评论框里打字时彩蛋乱触发排查了好久。5.3 序列设计的最佳实践设计彩蛋序列时有几个原则。第一长度适中。太短容易误触发太长用户记不住。科乐美秘技是 10 个键这是个经过验证的合理长度。第二避免常用词。别用a b c这种用户随便打个字就触发了。用方向键组合或者不常见的字母组合更安全。第三给用户提示。彩蛋藏得太深没人发现就失去意义了可以在页面角落放个隐晦的提示或者在控制台打印一行线索。console.log(%c提示试试经典的科乐美秘技, color: #888; font-size: 12px;);第四回调里做好异常处理。彩蛋逻辑如果报错不应该影响页面其他功能。用try-catch包一下回调执行try { seq.callback(); } catch (err) { console.error(彩蛋回调执行出错, err); }6. 常见问题与排查技巧实录6.1 序列不触发的原因排查序列不触发是最常见的问题我整理了一个排查清单按可能性从高到低排列。排查项检查方法常见原因按键名拼写控制台打印 keyMap 对照写了表里没有的键名如 meta事件源判断在处理器里打印 e.target焦点在输入框被提前 return 了序列顺序检查注册顺序短序列是长序列前缀被提前触发大小写确认序列已转小写手动传入了大写但没归一化浏览器拦截换组合键测试某些组合键被系统或浏览器占用脚本加载检查控制台报错脚本没加载成功或初始化失败我遇到最多的是按键名拼写错误和事件源判断问题。前者靠校验函数解决后者靠console.log(e.target)定位。建议在开发阶段把序列检测的每一步都打上日志上线前再关掉。6.2 误触发与性能问题误触发通常是因为序列太短或太常见。解决办法是加长序列或者加一个“冷却时间”触发后一段时间内不再检测。性能问题一般不会出现在 cheet.js 这种量级的库上但如果序列数量很多比如上百个每次按键都遍历一遍会有轻微开销。优化方法是按首字符分组只检查首字符匹配的序列。var sequencesByFirstKey {}; // 按首字符 keyCode 分组 function registerSequence(seqStr, callback) { var keys seqStr.toLowerCase().split( ); var codes keys.map(function(k) { return keyMap[k]; }); var firstCode codes[0]; if (!sequencesByFirstKey[firstCode]) { sequencesByFirstKey[firstCode] []; } sequencesByFirstKey[firstCode].push({ codes: codes, callback: callback, pointer: 0 }); } document.addEventListener(keydown, function(e) { var code e.keyCode; var group sequencesByFirstKey[code]; if (group) { group.forEach(function(seq) { updateSequence(seq, code); }); } // 还需要处理非首字符的推进这里简化了 });这个优化在序列数量少时没必要但如果你要做个“彩蛋合集”页面注册几十个序列分组就有意义了。6.3 跨浏览器兼容性注意点keyCode虽然是个老标准但主流浏览器都支持兼容性没问题。需要注意的是不同键盘布局下同一个物理按键的keyCode可能不同。比如法语键盘的 A 键位置和 QWERTY 键盘不一样keyCode也会变。如果你的用户群体国际化彩蛋序列最好用方向键这种布局无关的键。另外移动端没有物理键盘keydown事件基本不会触发所以彩蛋功能在移动端是失效的这点要提前和产品说清楚别指望手机上也能玩。提示如果一定要在移动端做类似功能可以考虑用触摸手势序列替代键盘序列但那已经是另一个技术方案了不在 cheet.js 的覆盖范围内。7. 自己动手写一个最小可用的序列检测器理解了 cheet.js 的原理后自己写一个并不难。下面是一个约 50 行的最小实现去掉了映射表的完整版只保留核心逻辑方便你理解状态机的运作。(function() { var keyMap { left: 37, up: 38, right: 39, down: 40, a: 65, b: 66, c: 67, d: 68, e: 69 }; var sequences []; function register(seqStr, callback) { var codes seqStr.toLowerCase().split( ).map(function(k) { return keyMap[k]; }); if (codes.indexOf(undefined) ! -1) { console.warn(序列包含未知按键 seqStr); return; } sequences.push({ codes: codes, callback: callback, pointer: 0 }); } function handleKey(e) { var target e.target; var tag target.tagName.toLowerCase(); if (tag input || tag textarea || target.isContentEditable) { return; } var code e.keyCode; sequences.forEach(function(seq) { if (code seq.codes[seq.pointer]) { seq.pointer; if (seq.pointer seq.codes.length) { try { seq.callback(); } catch (err) { console.error(err); } seq.pointer 0; } } else { seq.pointer (code seq.codes[0]) ? 1 : 0; } }); } document.addEventListener(keydown, handleKey); window.myCheet { register: register }; })(); // 使用 myCheet.register(up up down down left right left right b a, function() { console.log(秘技触发); });这个实现和 cheet.js 的核心逻辑一致区别在于映射表不完整、没有链式 API、没有多实例支持。但作为学习样本足够了。你可以基于它扩展比如加上序列分组优化、加上触发冷却、加上回调的this绑定等。8. 从 cheet.js 延伸序列检测还能用在哪序列检测这个模式不只用于键盘彩蛋。任何“按时间顺序输入一串信号匹配特定模式后触发动作”的场景都能套用。比如手势识别把触摸事件的滑动方向当成按键序列上滑下滑左滑右滑组成手势密码。再比如物联网设备把传感器读数序列化检测特定的读数模式来触发告警。还有命令行工具检测用户输入的命令历史匹配特定模式后给出提示。核心思路是一样的维护一个指针逐个比对输入和目标序列匹配则推进不匹配则按策略重置。区别只在于输入源不同、归一化方式不同、重置策略不同。理解了 cheet.js 的实现你就能把这套逻辑迁移到各种场景里。我自己后来做的一个手势解锁组件就是直接复用了这个状态机把keyCode换成了方向字符串改动量很小。最后分享一个我在实际项目里的小技巧给序列检测加一个“超时重置”。如果用户输入到一半超过 3 秒没有下一个按键就把指针归零。这样可以避免用户上次输入了一半的序列过很久之后接着输入意外触发彩蛋。实现方式是用setTimeout每次推进指针时重置计时器超时后归零指针。这个细节 cheet.js 没有内置但加上之后体验会好很多尤其是序列较长的时候。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进