使用JavaScript手寫一個簡單的快捷鍵庫

發表於2024-02-28

背景

前端開發中,有時專案會遇到一些快捷鍵需求,比如繫結快捷鍵,展示快捷鍵,編輯快捷鍵等需求,特別是工具類的專案。如果只是簡單的繫結幾個快捷鍵之類的需求,我們一般會透過監聽鍵盤事件(如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原始碼以及手寫一個簡單的快捷鍵庫,我們可以學習到一些關於快捷鍵和鍵盤事件相關的知識。目的不是重複造輪子,而是透過日常業務需求,驅動我們去了解當前流行的常見快捷鍵庫的實現思路,以便於我們更好地理解並實現相關業務需求。假如日後有展示、修改快捷鍵或者其他快捷鍵相關的需求,我們就可以做到胸有成竹,舉一反三。

相關文章