头图

背景

前端开发中,有时项目会遇到一些快捷键需求,比如绑定快捷键,展示快捷键,编辑快捷键等需求,特别是工具类的项目。如果只是简单的绑定几个快捷键之类的需求,我们一般会通过监听键盘事件(如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源码以及手写一个简单的快捷键库,我们可以学习到一些关于快捷键和键盘事件相关的知识。目的不是重复造轮子,而是通过日常业务需求,驱动我们去了解当前流行的常见快捷键库的实现思路,以便于我们更好地理解并实现相关业务需求。假如日后有展示、修改快捷键或者其他快捷键相关的需求,我们就可以做到胸有成竹,举一反三。


玛尔斯通
486 声望693 粉丝

a cyclist, runner and coder.