本文深入分析 mousetrap 库的早期源码,解析其绑定与监听机制,并基于此使用 ES6 Class 语法从零实现一个轻量级快捷键库。文章涵盖键盘事件监听、修饰键处理、键组合与序列匹配等核心逻辑,帮助开发者理解快捷键底层原理并掌握实际编码技巧。
背景
在前端开发中,项目常涉及快捷键相关需求,如绑定、展示及编辑快捷键等,这在工具类项目中尤为常见。对于简单的单键绑定,通常直接监听键盘事件(如 keydown)即可满足;而面对更复杂的需求,开发者往往倾向于引入第三方库,例如主流的 mousetrap 或 hotkey-js 等。
本文将通过对快捷键库 mousetrap 首次提交的源码进行简要剖析,并在此基础上实现一个简易的快捷键库。
前置知识
在动手实现前,需掌握快捷键相关的基础概念:如何监听键盘事件、如何识别用户按下的具体按键、键盘按键的分类及其特性。只有厘清这些基础,才能深入理解 mousetrap 等库的设计思路,从而顺利构建自己的快捷键库。
如何监听键盘事件
实现快捷键的核心在于监听用户的按键行为,这依赖于键盘事件 API。
常见的键盘事件包括 keydown、keyup 和 keypress。通常情况下,我们通过监听用户按下按键的动作来判断是否触发对应的快捷键逻辑,即通过监听 keydown 事件来实现快捷键功能。
如何监听键盘上按下的键
既然可以通过键盘事件监听用户行为,那么如何准确识别用户按下的是哪个或哪些按键呢?
例如,当用户绑定的快捷键为 s 时,系统如何确认当前按下的是 s?我们可以借助键盘事件对象 keyboardEvent 上的 code、keyCode 和 key 等属性来判断用户当前按下的具体按键。
键盘按键分类
部分按键会影响其他按键按下后生成的字符。例如,当用户同时按下 shift 和 / 时,生成的字符为 ?;而若仅按 shift 不会产生字符,仅按 / 生成的字符应为 /,最终显示 ? 是因为同时按下了 shift 所致。这里的 shift 被称为修饰键,类似的还有 ctrl、alt(即 option)、command(即 meta)。
除上述修饰键外,其余按键统称为非修饰键。
快捷键分类
常用的快捷键类型包括单个键、键组合以及键序列。
单个键
顾名思义,单个键指只需按下单一按键即可触发的快捷键。常见的如音视频播放/暂停快捷键 Space,以及游戏中控制移动方向的快捷键 w、a、s、d 等。
键组合
键组合通常由一个或多个修饰键与非修饰键组合而成。例如常用的复制粘贴快捷键 ctrl+c、ctrl+v,保存文件快捷键 ctrl+s,以及新建窗口快捷键 ctrl+shift+n(在 macOS 上为 command+shift+n)。
键序列
依次按下的按键称为键序列。比如键序列h e l l o,需要依次按下h,e,l,l,o按键才会触发。
mousetrap 源码分析
以下分析基于 mousetrap 首次提交的源码,源码链接如下:bit.ly/3TdcK8u
简而言之,该代码主要完成两件事:绑定快捷键和监听键盘事件。
代码设计和初始化
首先,通过 IIFE(立即执行函数表达式)封装代码,并向 window 对象添加全局属性 Mousetrap。
该函数对外暴露了若干公共方法:
bind(keys, callback, action): 用于绑定快捷键trigger(): 用于手动触发绑定的快捷键对应的回调函数。
最后,当 window 加载完成后立即执行 init() 函数,即执行初始化逻辑,包括添加键盘事件监听等。
// 以下为简化后的代码
window['Mousetrap'] = (function () {
return {
/**
* 绑定快捷键
* @param keys 快捷键,支持一次绑定多个快捷键。
* @param callback 快捷键触发后的回调函数
* @param action 行为
*/
bind: function (keys, callback, action) {
action = action || '';
_bindMultiple(keys.split(','), callback, action);
_direct_map[keys + ':' + action] = callback;
},
/**
* 手动触发快捷键对应的回调函数
* @param keys 绑定时的快捷键
* @param action 行为
*/
trigger: function (keys, action) {
_direct_map[keys + ':' + (action || '')]();
},
/**
* 给DOM对象添加事件,针对浏览器兼容性的写法
* @param object
* @param type
* @param callback
*/
addEvent: function (object, type, callback) {
_addEvent(object, type, callback);
},
init: function () {
_addEvent(document, 'keydown', _handleKeyDown);
_addEvent(document, 'keyup', _handleKeyUp);
_addEvent(window, 'focus', _resetModifiers);
},
};
})();
Mousetrap.addEvent(window, 'load', Mousetrap.init);
绑定快捷键
通常,快捷键库会提供一个绑定函数,如 bind(key, callback)。在 mousetrap 中,我们可以通过调用 Mousetrap.bind() 函数来实现快捷键的绑定。
我们可以结合调用时的写法对 Mousetrap.bind() 函数进行分析。例如,绑定快捷键 ctrl+s 和 command+s 的代码如下:Mousetrap.bind('ctrl+s, command+s', () => {console.log('保存成功')} )
bind(keys, callback, action)
由于 bind() 函数支持一次绑定多个快捷键(多个快捷键间用逗号分隔),因此内部封装了 _bindMultiple() 函数来处理一次绑定多个快捷键的场景。
window['Mousetrap'] = (function () {
return {
bind: function (keys, callback, action) {
action = action || '';
_bindMultiple(keys.split(','), callback, action);
_direct_map[keys + ':' + action] = callback;
},
};
})();
_bindMultiple(combinations, callback, action)
该函数仅遍历绑定时传入的多个快捷键,并依次调用 _bindSingle() 函数进行绑定。
/**
* binds multiple combinations to the same callback
*/
function _bindMultiple(combinations, callback, action) {
for (var i = 0; i < combinations.length; ++i) {
_bindSingle(combinations[i], callback, action);
}
}
_bindSingle(combination, callback, action)
该函数是绑定快捷键的核心代码。
主要包含以下几部分:
- 将绑定的快捷键
combination拆分为单个键数组,并收集修饰键至修饰键数组modifiers中。 - 以
key(即key code)为属性名,将当前绑定的快捷键及其对应的回调函数等数据保存至回调函数集合_callbacks中。 - 若之前已绑定过相同的快捷键,则调用
_getMatch()函数移除之前绑定的快捷键。
/**
* binds a single event
*/
function _bindSingle(combination, callback, action) {
var i,
key,
keys = combination.split('+'),
// 修饰键列表
modifiers = [];
// 收集修饰键到修饰键数组中
for (i = 0; i < keys.length; ++i) {
if (keys[i] in _MODIFIERS) {
modifiers.push(_MODIFIERS[keys[i]]);
}
// 获取当前按键(修饰键 || 特殊键 || 普通按键(a-z, 0-9))的 key code,注意这里charCodeAt()的用法
key = _MODIFIERS[keys[i]] || _MAP[keys[i]] || keys[i].toUpperCase().charCodeAt(0);
}
// 以 key code 为属性名,保存回调函数
if (!_callbacks[key]) {
_callbacks[key] = [];
}
// 如果之前有绑定过相同的快捷键,则移除之前绑定的快捷键
_getMatch(key, modifiers, action, true);
// 保存当前绑定的快捷键的回调函数/修饰键等数据到回调函数数组中
_callbacks[key].push({callback: callback, modifiers: modifiers, action: action});
}
注意这里的 _callbacks 数据结构。假设绑定了以下快捷键:
Mousetrap.bind('s', e => {
console.log('sss')
})
Mousetrap.bind('ctrl+s', e => {
console.log('ctrl+s')
})
则 _callbacks 的值如下:
{
// key code 作为属性名,属性值为数组,用于保存当前绑定的修饰键和回调函数等数据
"83": [ // 83对应的是字符s的key code
{
modifiers: [],
callback: e => { console.log('sss') }
action: ""
},
{
modifiers: [17], // 17对应的是修饰键ctrl的key code
callback: e => { console.log('ctrl+s') }
action: ""
}
]
}
_getMatch(code, modifiers, action, remove)
从快捷键回调函数集合 _callbacks 中获取或删除已绑定快捷键对应的回调函数 callback。
function _getMatch(code, modifiers, action, remove) {
if (!_callbacks[code]) {
return;
}
var i,
callback;
// loop through all callbacks for the key that was pressed
// and see if any of them match
for (i = 0; i < _callbacks[code].length; ++i) {
callback = _callbacks[code][i];
if (action == callback.action && _modifiersMatch(modifiers, callback.modifiers)) {
if (remove) {
_callbacks[code].splice(i, 1);
}
return callback;
}
}
}
监听键盘事件
在初始化逻辑 init() 函数中,向 document 对象注册了 keydown 事件监听。
⚠: 此处仅分析 keydown 事件,keyup 事件的处理逻辑类似。
_addEvent(document, 'keydown', _handleKeyDown);
_handleKeyDown(e)
首先,调用 _stop(e) 函数判断是否需要停止执行后续操作,如需则直接 return。
其次,根据键盘事件对象 event 获取当前按下按键对应的 key code,并收集当前按下的所有修饰键的 key code 至修饰键列表 _active_modifiers 中。
最后,调用 _fireCallback(code, modifers, action, e) 函数,获取当前匹配的快捷键对应的回调函数 callback 并执行。
function _handleKeyDown(e) {
if (_stop(e)) {
return;
}
var code = _keyCodeFromEvent(e);
if (_MODS[code]) {
_active_modifiers.push(code);
}
return _fireCallback(code, _active_modifiers, '', e);
}
_stop(e)
若当前 keydown 事件触发的目标元素为 input/select/textarea 元素,则停止处理 keydown 事件。
function _stop(e) {
var tag_name = (e.target || e.srcElement).tagName;
// stop for input, select, and textarea
return tag_name == 'INPUT' || tag_name == 'SELECT' || tag_name == 'TEXTAREA';
}
_keyCodeFromEvent(e)
根据键盘事件对象 event 获取对应按键的 key code。
注意,此处并未直接使用 event.keyCode。原因是部分按键在不同浏览器中的 event.keyCode 值不一致,需进行特殊处理。
function _keyCodeFromEvent(e) {
var code = e.keyCode;
// right command on webkit, command on gecko
if (code == 93 || code == 224) {
code = 91;
}
return code;
}
_fireCallback(code, modifiers, action, e)
获取当前匹配的快捷键对应的回调函数 callback 并执行。
function _fireCallback(code, modifiers, action, e) {
var callback = _getMatch(code, modifiers, action);
if (callback) {
return callback.callback(e);
}
}
_getMatch(code, modifiers, action)
获取当前匹配的快捷键对应的回调函数 callback。
function _getMatch(code, modifiers, action, remove) {
if (!_callbacks[code]) {
return;
}
var i,
callback;
// loop through all callbacks for the key that was pressed
// and see if any of them match
for (i = 0; i < _callbacks[code].length; ++i) {
callback = _callbacks[code][i];
if (action == callback.action && _modifiersMatch(modifiers, callback.modifiers)) {
if (remove) {
_callbacks[code].splice(i, 1);
}
return callback;
}
}
}
_modifiersMatch(modifiers1, modifiers2)
判断两个修饰键数组中的元素是否完全一致。例如:_modifiersMatch(['ctrl', 'shift'], ['shift', 'ctrl'])
function _modifiersMatch(group1, group2) {
return group1.sort().join(',') === group2.sort().join(',');
}
实现一个简单的快捷键库
结合前置知识和对 mousetrap 源码的分析,我们可以轻松实现一个简单的快捷键库。
思路
总体思路与 mousetrap 几乎完全一致,仅做两件事:1. 对外提供 bind() 函数用于绑定快捷键;2. 内部通过添加 keydown 事件监听键盘输入,查找与对应快捷键匹配的回调函数 callback 并执行。
与 mousetrap 不同的是,本次实现将使用 event.key 属性来判断用户按下的具体按键,该属性也是规范/标准推荐使用的属性(Authors SHOULD use the key attribute instead of the charCode and keyCode attributes.)。
代码将采用 ES6 class 语法,并对外提供 bind() 函数用于绑定快捷键。
功能
支持绑定快捷键(包括单个键和键组合)。
实现
由于实现思路前文已详细分析,此处不再赘述,直接给出完整源代码。
不过,代码有几点需要注意:
event.key受shift按键影响。例如,绑定的快捷键为shift+/,实际上在keydown事件对象event中event.key的值为?,因此代码中维护了这种特殊字符的映射_SHIFT_MAP,用于判断用户是否按下了这类特殊字符。- 部分特殊字符按键产生的字符(
event.key)需特殊处理,例如空格按键Space,按下后实际产生的字符(event.key)为' ',详情见代码中的checkKeyMatch()函数。
/**
* this is a mapping of keys that converts characters generated by pressing shift key
* at the same time to characters produced when the shift key is not pressed
*
* @type {Object}
*/
var _SHIFT_MAP = {
'~': '`',
'!': '1',
'@': '2',
'#': '3',
$: '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
_: '-',
'+': '=',
':': ';',
'"': "'",
'<': ',',
'>': '.',
'?': '/',
'|': '\\',
};
/**
* get modifer key list by keyboard event
* @param {KeyboardEvent} event - keyboard event
* @returns {Array}
*/
const getModifierKeysByKeyboardEvent = (event) => {
const modifiers = [];
if (event.shiftKey) {
modifiers.push('shift');
}
if (event.altKey) {
modifiers.push('alt');
}
if (event.ctrlKey) {
modifiers.push('ctrl');
}
if (event.metaKey) {
modifiers.push('command');
}
return modifiers;
};
/**
* get non modifier key
* @param {string} shortcut
* @returns {string}
*/
function getNonModifierKeyByShortcut(shortcut) {
if (typeof shortcut !== 'string') return '';
if (!shortcut.trim()) return '';
const validModifierKeys = ['shift', 'ctrl', 'alt', 'command'];
return (
shortcut.split('+').filter((key) => !validModifierKeys.includes(key))[0] ||
''
);
}
/**
* check if two modifiers match
* @param {Array} modifers1
* @param {Array} modifers2
* @returns {boolean}
*/
function checkModifiersMatch(modifers1, modifers2) {
return modifers1.sort().join(',') === modifers2.sort().join(',');
}
/**
* check if key match
* @param {string} shortcutKey - shortcut key
* @param {string} eventKey - event.key
* @returns {boolean}
*/
function checkKeyMatch(shortcutKey, eventKey) {
if (shortcutKey === 'space') {
return eventKey === ' ';
}
return shortcutKey === (_SHIFT_MAP[eventKey] || eventKey);
}
/**
* shortcut binder class
*/
class ShortcutBinder {
constructor() {
/**
* shortcut list
*/
this.shortcuts = [];
this.init();
}
/**
* init, add keyboard event listener
*/
init() {
this._addKeydownEvent();
}
/**
* add keydown event
*/
_addKeydownEvent() {
document.addEventListener('keydown', (event) => {
const modifers = getModifierKeysByKeyboardEvent(event);
const matchedShortcut = this.shortcuts.find(
(shortcut) =>
checkKeyMatch(shortcut.key, event.key.toLowerCase()) &&
checkModifiersMatch(shortcut.modifiers, modifers)
);
if (matchedShortcut) {
matchedShortcut.callback(event);
}
});
}
/**
* bind shortcut & callback
* @param {string} shortcut
* @param {Function} callback
*/
bind(shortcut, callback) {
this._addShortcut(shortcut, callback);
}
/**
* add shortcut & callback to shortcut list
* @param {string} shortcut
* @param {Function} callback
*/
_addShortcut(shortcut, callback) {
this.shortcuts.push({
shortcut,
callback,
key: this._getKeyByShortcut(shortcut),
modifiers: this._getModifiersByShortcut(shortcut),
});
}
/**
* get key (character/name) by shortcut
* @param {string} shortcut
* @returns {string}
*/
_getKeyByShortcut(shortcut) {
const key = getNonModifierKeyByShortcut(shortcut);
return key.toLowerCase();
}
/**
* get modifier keys by shortcut
* @param {string} shortcut
* @returns {Array}
*/
_getModifiersByShortcut(shortcut) {
const keys = shortcut.split('+').map((key) => key.trim());
const VALID_MODIFIERS = ['shift', 'ctrl', 'alt', 'command'];
let modifiers = [];
keys.forEach((key) => {
if (VALID_MODIFIERS.includes(key)) {
modifiers.push(key);
}
});
return modifiers;
}
}
调用
调用方法与 mousetrap 类似。以下仅列出部分测试代码,可查看在线示例测试实际效果。
shortcutBinder.bind('ctrl+s', () => {
console.log('ctrl+s');
});
shortcutBinder.bind('ctrl+shift+s', () => {
console.log('ctrl+shift+s');
});
shortcutBinder.bind('space', (e) => {
e.preventDefault();
console.log('space');
});
shortcutBinder.bind('shift+5', (e) => {
e.preventDefault();
console.log('shift+5');
});
shortcutBinder.bind(`shift+\\`, (e) => {
e.preventDefault();
console.log('shift+\\');
});
shortcutBinder.bind(`f2`, (e) => {
e.preventDefault();
console.log('f2');
});
在线示例
CodePen: 手写一个简单的快捷键库
TODO
至此,我们已实现了一个简单的快捷键库,可满足常见的快捷键绑定业务需求。当然,相较于当前流行的几个快捷键库,我们实现的版本较为简单,还有许多功能和细节有待实现和完善。以下列出待完成的几个事项,感兴趣的开发者可尝试实现:
- 支持设置键序列快捷键
- 支持设置快捷键作用域
- 支持解绑单个快捷键
- 支持重置所有绑定的快捷键
- 支持获取所有绑定的快捷键信息
总结
通过学习 mousetrap 源码以及手写一个简单的快捷键库,我们可以学习到关于快捷键和键盘事件的相关知识。目的并非重复造轮子,而是通过日常业务需求,驱动我们去了解当前流行的常见快捷键库的实现思路,从而更好地理解并实现相关业务需求。假如日后有展示、修改快捷键或其他快捷键相关的需求,我们便能做到胸有成竹,举一反三。
以上就是 JavaScript 手写实现一个简单的快捷键库的详细内容,更多关于 JavaScript 快捷键库的资料请关注本站其它相关文章!