背景

前端开发中,有时我的项目会遇到一些快捷键需要,比方绑定快捷键,展现快捷键,编辑快捷键等需要,特地是工具类的我的项目。如果只是简略的绑定几个快捷键之类的需要,咱们个别会通过监听键盘事件(如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,新建(浏览器或其余app)窗口快捷键ctrl+shift+n(command+shift+n)。

键序列

顺次按下的按键称为键序列。比方键序列h e l l o,须要顺次按下h,e,l,l,o按键才会触发。

mousetrap源码剖析

以下将以mousetrap第一次提交的源码为根底进行简略剖析,源码链接如下:https://bit.ly/3TdcK8u

简略来说,代码只做了两件事,即绑定快捷键监听键盘事件

代码设计和初始化

首先,给window对象增加了一个全局属性Mousetrap,应用的是IIFE(立刻执行函数表达式)对代码进行封装。

该函数对外裸露了几个公共办法:

  • 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+scommand+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)

该函数是实现绑定快捷键的外围代码。

次要分为以下几局部:

  1. 将绑定的快捷键combination拆分为单个键数组,而后收集润饰键到润饰键数组modifiers中。
  2. key(key code)为属性名,将以后绑定的快捷键及其对应的回调函数等数据保留到回调函数汇合_callbacks中。
  3. 如果之前有绑定过雷同的快捷键,则调用_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)

判断两个润饰键数组中的元素是否完全一致。eg: _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()函数用于绑定快捷键。

性能

反对绑定快捷键(单个键,键组合)。

实现

因为实现思路前文曾经剖析过,因而这里就不具体解释了,以下间接给出残缺的源代码。

不过,代码有几点须要留神下:

  1. event.keyshift按键影响。比方,绑定的快捷键是shift+/,实际上在keydown事件对象eventevent.key的值是?,因而代码里保护了这种特殊字符的映射_SHIFT_MAP,用于判断用户是否按下了这类特殊字符。
  2. 有些特殊字符按键产生的字符(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源码以及手写一个简略的快捷键库,咱们能够学习到一些对于快捷键和键盘事件相干的常识。目标不是反复造轮子,而是通过日常业务需要,驱动咱们去理解以后风行的常见快捷键库的实现思路,以便于咱们更好地了解并实现相干业务需要。如果日后有展现、批改快捷键或者其余快捷键相干的需要,咱们就能够做到胸有成竹,触类旁通。