JavaScript手寫實(shí)現(xiàn)一個(gè)簡單的快捷鍵庫
背景
前端開發(fā)中,有時(shí)項(xiàng)目會(huì)遇到一些快捷鍵需求,比如綁定快捷鍵,展示快捷鍵,編輯快捷鍵等需求,特別是工具類的項(xiàng)目。如果只是簡單的綁定幾個(gè)快捷鍵之類的需求,我們一般會(huì)通過監(jiān)聽鍵盤事件(如keydown
事件)來實(shí)現(xiàn),如果是稍微復(fù)雜點(diǎn)的需求,我們一般都會(huì)通過引入第三方快捷鍵庫來實(shí)現(xiàn),比如常用的幾個(gè)快捷鍵庫mousetrap, hotkey-js等。
接下來,我將會(huì)通過對快捷鍵庫mousetrap
第一次提交的源碼進(jìn)行簡單分析,然后實(shí)現(xiàn)一個(gè)簡單的快捷鍵庫。
前置知識
首先,我們需要了解一些快捷鍵相關(guān)的基礎(chǔ)知識。比如,如何監(jiān)聽鍵盤事件?如何監(jiān)聽用戶按下的按鍵?鍵盤上的按鍵有哪些?是如何分類的?只有知道這些,才能更好的理解mousetrap
這種快捷鍵庫實(shí)現(xiàn)的思路,才能更好地實(shí)現(xiàn)我們自己的快捷鍵庫。
如何監(jiān)聽鍵盤事件
實(shí)現(xiàn)快捷鍵需要監(jiān)聽用戶按下鍵盤按鍵的行為,那就需要使用到鍵盤事件API。
常用的鍵盤事件有keydown, keyup,keypress事件。一般來說,我們會(huì)通過監(jiān)聽用戶按下按鍵的行為,來判斷是否要觸發(fā)對應(yīng)的快捷鍵行為。通常來說,在用戶按下按鍵時(shí),就會(huì)判斷是否有匹配的綁定過的快捷鍵,即通過監(jiān)聽keydown
事件來實(shí)現(xiàn)快捷鍵。
如何監(jiān)聽鍵盤上按下的鍵
我們可以通過鍵盤事件來監(jiān)聽用戶按鍵行為。那如何知道用戶具體按下了哪個(gè)/哪些按鍵呢?
比如,用戶綁定的快捷鍵是s
,那如何知道當(dāng)前按下的按鍵是s
?我們可以通過鍵盤事件對象keyboardEvent上的code, keyCode, key這些屬性來判斷用戶當(dāng)前按下的按鍵。
鍵盤按鍵分類
有些按鍵會(huì)影響其他按鍵按下后產(chǎn)生的字符。比如,用戶同時(shí)按下了shift
和/
按鍵,此時(shí)產(chǎn)生的字符是?
,然而實(shí)際上如果只按shift
按鍵不會(huì)產(chǎn)生任何字符,只按/
按鍵產(chǎn)生的字符本應(yīng)該是/
,最終產(chǎn)生的字符?
就是因?yàn)橥瑫r(shí)按下了shift
按鍵導(dǎo)致的。這里的shift
按鍵就是影響其他按鍵按下后產(chǎn)生字符的按鍵,這種按鍵被稱為修飾鍵。類似的修飾鍵還有ctrl
, alt
(option
), command
(meta
)。
除了這幾個(gè)修飾鍵以外,其他的按鍵稱為非修飾鍵。
快捷鍵分類
常用的快捷鍵有單個(gè)鍵,鍵組合。有的還會(huì)用到鍵序列。
單個(gè)鍵
故名思義,單個(gè)鍵是只需要按下一個(gè)鍵就會(huì)觸發(fā)的快捷鍵。比如常用的音視頻切換播放/暫??旖萱ISpace
,游戲中控制移動(dòng)方向快捷鍵w
,a
,s
,d
等等。
鍵組合
鍵組合通常是一個(gè)或多個(gè)修飾鍵和一個(gè)非修飾鍵組合而成的快捷鍵。比如常用的復(fù)制粘貼快捷鍵ctrl+c
,ctrl+v
,保存文件快捷鍵ctrl+s
,新建(瀏覽器或其他app)窗口快捷鍵ctrl+shift+n
(command+shift+n
)。
鍵序列
依次按下的按鍵稱為鍵序列。比如鍵序列h e l l o
,需要依次按下h
,e
,l
,l
,o
按鍵才會(huì)觸發(fā)。
mousetrap源碼分析
以下將以mousetrap
第一次提交的源碼為基礎(chǔ)進(jìn)行簡單分析,源碼鏈接如下:bit.ly/3TdcK8u
簡單來說,代碼只做了兩件事,即綁定快捷鍵和監(jiān)聽鍵盤事件。
代碼設(shè)計(jì)和初始化
首先,給window
對象添加了一個(gè)全局屬性Mousetrap
,使用的是IIFE(立即執(zhí)行函數(shù)表達(dá)式)對代碼進(jìn)行封裝。
該函數(shù)對外暴露了幾個(gè)公共方法:
bind(keys, callback, action)
: 綁定快捷鍵trigger()
: 手動(dòng)觸發(fā)綁定的快捷鍵對應(yīng)的回調(diào)函數(shù)。
最后當(dāng)window
加載后立即執(zhí)行init()
函數(shù),即執(zhí)行初始化邏輯:添加鍵盤事件監(jiān)聽等。
// 以下為簡化后的代碼 window['Mousetrap'] = (function () { return { /** * 綁定快捷鍵 * @param keys 快捷鍵,支持一次綁定多個(gè)快捷鍵。 * @param callback 快捷鍵觸發(fā)后的回調(diào)函數(shù) * @param action 行為 */ bind: function (keys, callback, action) { action = action || ''; _bindMultiple(keys.split(','), callback, action); _direct_map[keys + ':' + action] = callback; }, /** * 手動(dòng)觸發(fā)快捷鍵對應(yīng)的回調(diào)函數(shù) * @param keys 綁定時(shí)的快捷鍵 * @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);
綁定快捷鍵
一般來說,快捷鍵庫都會(huì)提供一個(gè)綁定快捷鍵的函數(shù),比如bind(key, callback)
。在mousetrap
中,我們可以通過調(diào)用Mousetrap.bind()
函數(shù)來實(shí)現(xiàn)快捷鍵綁定。
我們可以結(jié)合調(diào)用時(shí)的寫法對Mousetrap.bind()
函數(shù)進(jìn)行分析。比如,我們綁定了快捷鍵ctrl+s
和command+s
,如下:Mousetrap.bind('ctrl+s, command+s', () => {console.log('保存成功')} )
bind(keys, callback, action)
由于bind()
函數(shù)支持一次綁定多個(gè)快捷鍵(綁定時(shí)多個(gè)快捷鍵用逗號分隔),因此內(nèi)部封裝了_bindMultiple()
函數(shù)用于處理一次綁定多個(gè)快捷鍵的用法。
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)
該函數(shù)只是對綁定時(shí)傳入的多個(gè)快捷鍵進(jìn)行遍歷,然后調(diào)用_bindSingle()
函數(shù)依次綁定。
/** * 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)
該函數(shù)是實(shí)現(xiàn)綁定快捷鍵的核心代碼。
主要分為以下幾部分:
- 將綁定的快捷鍵
combination
拆分為單個(gè)鍵數(shù)組,然后收集修飾鍵到修飾鍵數(shù)組modifiers
中。 - 以
key
(key code
)為屬性名,將當(dāng)前綁定的快捷鍵及其對應(yīng)的回調(diào)函數(shù)等數(shù)據(jù)保存到回調(diào)函數(shù)集合_callbacks
中。 - 如果之前有綁定過相同的快捷鍵,則調(diào)用
_getMatch()
函數(shù)移除之前綁定的快捷鍵。
/** * binds a single event */ function _bindSingle(combination, callback, action) { var i, key, keys = combination.split('+'), // 修飾鍵列表 modifiers = []; // 收集修飾鍵到修飾鍵數(shù)組中 for (i = 0; i < keys.length; ++i) { if (keys[i] in _MODIFIERS) { modifiers.push(_MODIFIERS[keys[i]]); } // 獲取當(dāng)前按鍵(修飾鍵 || 特殊鍵 || 普通按鍵(a-z, 0-9))的 key code,注意這里charCodeAt()的用法 key = _MODIFIERS[keys[i]] || _MAP[keys[i]] || keys[i].toUpperCase().charCodeAt(0); } // 以 key code 為屬性名,保存回調(diào)函數(shù) if (!_callbacks[key]) { _callbacks[key] = []; } // 如果之前有綁定過相同的快捷鍵,則移除之前綁定的快捷鍵 _getMatch(key, modifiers, action, true); // 保存當(dāng)前綁定的快捷鍵的回調(diào)函數(shù)/修飾鍵等數(shù)據(jù)到回調(diào)函數(shù)數(shù)組中 _callbacks[key].push({callback: callback, modifiers: modifiers, action: action}); }
注意這里的_callbacks
數(shù)據(jù)結(jié)構(gòu)。假設(shè)綁定了以下快捷鍵:
Mousetrap.bind('s', e => { console.log('sss') }) Mousetrap.bind('ctrl+s', e => { console.log('ctrl+s') })
則_callbacks
值如下:
{ // key code 作為屬性名,屬性值為數(shù)組,用于保存當(dāng)前綁定的修飾鍵和回調(diào)函數(shù)等數(shù)據(jù) "83": [ // 83對應(yīng)的是字符s的key code { modifiers: [], callback: e => { console.log('sss') } action: "" }, { modifiers: [17], // 17對應(yīng)的是修飾鍵ctrl的key code callback: e => { console.log('ctrl+s') } action: "" } ] }
_getMatch(code, modifiers, action, remove)
從快捷鍵回調(diào)函數(shù)集合_callbacks
中獲取/刪除已經(jīng)綁定的快捷鍵對應(yīng)的回調(diào)函數(shù)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; } } }
監(jiān)聽鍵盤事件
在初始化邏輯init()
函數(shù)中給document
對象注冊了keydown
事件監(jiān)聽。
?: 這里只分析keydown
事件,keyup
事件類似。
_addEvent(document, 'keydown', _handleKeyDown);
_handleKeyDown(e)
首先,會(huì)調(diào)用_stop(e)
函數(shù)判斷是否需要停止執(zhí)行后續(xù)操作。如果需要?jiǎng)t直接return。
其次,根據(jù)鍵盤事件對象event
獲取當(dāng)前按下的按鍵對應(yīng)的key code
,并收集當(dāng)前按下的所有修飾鍵的key code
到修飾鍵列表_active_modifiers
中。
最后,調(diào)用_fireCallback(code, modifers, action, e)
函數(shù),獲取當(dāng)前匹配的快捷鍵對應(yīng)的回調(diào)函數(shù)callback
,并執(zhí)行。
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)
如果當(dāng)前keydown
事件觸發(fā)時(shí)所在的目標(biāo)元素是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)
根據(jù)鍵盤事件對象event
獲取對應(yīng)按鍵的key code
。
注意,這里并沒有直接使用event.keyCode
。原因是有些按鍵在不同瀏覽器中的event.keyCode
值不一致,需要進(jìn)行特殊處理。
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)
獲取當(dāng)前匹配的快捷鍵對應(yīng)的回調(diào)函數(shù)callback
,并執(zhí)行。
function _fireCallback(code, modifiers, action, e) { var callback = _getMatch(code, modifiers, action); if (callback) { return callback.callback(e); } }
_getMatch(code, modifiers, action)
獲取當(dāng)前匹配的快捷鍵對應(yīng)的回調(diào)函數(shù)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)
判斷兩個(gè)修飾鍵數(shù)組中的元素是否完全一致。eg: _modifiersMatch(['ctrl', 'shift'], ['shift', 'ctrl'])
function _modifiersMatch(group1, group2) { return group1.sort().join(',') === group2.sort().join(','); }
實(shí)現(xiàn)一個(gè)簡單的快捷鍵庫
結(jié)合前置知識和對mousetrap
的源碼的分析,我們可以很容易實(shí)現(xiàn)一個(gè)簡單的快捷鍵庫。
思路
總體思路和mousetrap
幾乎完全一樣,只做兩件事。即1. 對外提供bind()
函數(shù)用于綁定快捷鍵,2. 內(nèi)部通過添加keydown
事件,監(jiān)聽鍵盤輸入,查找與對應(yīng)快捷鍵匹配的回調(diào)函數(shù)callback
并執(zhí)行。
與mousetrap
不同的是,這次將使用event.key
屬性來判斷用戶按下的具體按鍵,該屬性也是規(guī)范/標(biāo)準(zhǔn)推薦使用的屬性(Authors SHOULD use the key
attribute instead of the charCode
and keyCode
attributes.)。
代碼將使用ES6 class 語法,對外提供bind()
函數(shù)用于綁定快捷鍵。
功能
支持綁定快捷鍵(單個(gè)鍵,鍵組合)。
實(shí)現(xiàn)
由于實(shí)現(xiàn)思路前文已經(jīng)分析過,因此這里就不詳細(xì)解釋了,以下直接給出完整的源代碼。
不過,代碼有幾點(diǎn)需要注意下:
event.key
受shift
按鍵影響。比如,綁定的快捷鍵是shift+/
,實(shí)際上在keydown
事件對象event
中event.key
的值是?
,因此代碼里維護(hù)了這種特殊字符的映射_SHIFT_MAP
,用于判斷用戶是否按下了這類特殊字符。- 有些特殊字符按鍵產(chǎn)生的字符(
event.key
)需要特殊處理,比如空格按鍵Space
,按下后實(shí)際產(chǎn)生的字符(event.key
)是' '
,詳情見代碼中的checkKeyMatch()
函數(shù)。
/** * 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; } }
調(diào)用
調(diào)用方法和mousetrap
類似。以下僅列出部分測試代碼,可以查看在線示例測試實(shí)際效果。
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'); });
在線示例
TODO
至此,我們已經(jīng)實(shí)現(xiàn)了一個(gè)簡單的快捷鍵庫,可以滿足常見的快捷鍵綁定相關(guān)的業(yè)務(wù)需求。當(dāng)然,相對當(dāng)前流行的幾個(gè)快捷鍵庫而言,我們實(shí)現(xiàn)的快捷鍵庫比較簡單,還有很多功能和細(xì)節(jié)有待實(shí)現(xiàn)和完善。以下列出待完成的幾個(gè)事項(xiàng),感興趣的可以嘗試實(shí)現(xiàn)下。
- 支持設(shè)置鍵序列快捷鍵
- 支持設(shè)置快捷鍵作用域
- 支持解綁單個(gè)快捷鍵
- 支持重置所有綁定的快捷鍵
- 支持獲取所有綁定的快捷鍵信息
總結(jié)
通過學(xué)習(xí)mousetrap
源碼以及手寫一個(gè)簡單的快捷鍵庫,我們可以學(xué)習(xí)到一些關(guān)于快捷鍵和鍵盤事件相關(guān)的知識。目的不是重復(fù)造輪子,而是通過日常業(yè)務(wù)需求,驅(qū)動(dòng)我們?nèi)チ私猱?dāng)前流行的常見快捷鍵庫的實(shí)現(xiàn)思路,以便于我們更好地理解并實(shí)現(xiàn)相關(guān)業(yè)務(wù)需求。假如日后有展示、修改快捷鍵或者其他快捷鍵相關(guān)的需求,我們就可以做到胸有成竹,舉一反三。
以上就是JavaScript手寫實(shí)現(xiàn)一個(gè)簡單的快捷鍵庫的詳細(xì)內(nèi)容,更多關(guān)于JavaScript快捷鍵庫的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
利用javascript實(shí)現(xiàn)一些常用軟件的下載導(dǎo)航
利用javascript實(shí)現(xiàn)一些常用軟件的下載導(dǎo)航,非常不錯(cuò)的應(yīng)用,思路值得借鑒,沒看過的朋友可以看下。2009-08-08js中scrollTop()方法和scroll()方法用法示例
這篇文章主要介紹了js中scrollTop()方法和scroll()方法用法,結(jié)合實(shí)例形式分析了scrollTop()方法和scroll()方法滾動(dòng)操作的用法與相關(guān)操作技巧,需要的朋友可以參考下2016-10-10element?UI中在?el-select?與?el-tree?結(jié)合組件實(shí)現(xiàn)過程
項(xiàng)目上實(shí)現(xiàn)某個(gè)功能,使用到了?el-select?和?el-tree?組合實(shí)現(xiàn),記錄下兩者結(jié)合的實(shí)現(xiàn)過程,對?el-select?與?el-tree?結(jié)合組件實(shí)現(xiàn)過程感興趣的朋友跟隨小編一起看看吧2023-02-02JavaScript判斷數(shù)字是否為質(zhì)數(shù)的方法匯總
這篇文章主要介紹了JavaScript判斷數(shù)字是否為質(zhì)數(shù)的方法匯總的相關(guān)資料,非常不錯(cuò)具有參考借鑒價(jià)值,需要的朋友可以參考下2016-06-06Bootstrap基本組件學(xué)習(xí)筆記之進(jìn)度條(15)
這篇文章主要為大家詳細(xì)介紹了Bootstrap基本組件學(xué)習(xí)筆記之進(jìn)度條,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-12-12