如何實現一個快捷鍵響應系統
- 2021 年 9 月 28 日
- 筆記
在 GitHub 的頁面上有很多快捷鍵可以使用,比如鍵入 g
+ c
鍵選中 Code 標籤頁,鍵入 g
+ i
選中 Issues 標籤頁。這裡是 GitHub 支援的快捷鍵列表。那麼,這麼豐富的快捷鍵,是如何來實現的呢?我們今天就通過 GitHub 官方的 @github/hotkey 來一窺究竟。
功能描述
在需要支援快捷鍵的元素上,通過 data-hotkey
屬性添加快捷鍵序列,然後通過 @github/hotkey
暴露的 install
方法使得快捷鍵生效。
<a href="/page/2" data-hotkey="j">Next</a>
<a href="/help" data-hotkey="Control+h">Help</a>
<a href="/rails/rails" data-hotkey="g c">Code</a>
<a href="/search" data-hotkey="s,/">Search</a>
import {install} from '@github/hotkey'
// Install all the hotkeys on the page
for (const el of document.querySelectorAll('[data-hotkey]')) {
install(el)
}
添加快捷鍵的規則是:
- 如果一個元素上支援多個快捷鍵,則不同的快捷鍵之間通過
,
分割。 - 組合鍵通過
+
連接,比如Control + j
。 - 如果一個快捷鍵序列中有多個按鍵,則通過空格
g c
。
我們在這裡可以查到鍵盤上每個功能按鍵對應事件鍵值名稱,方便設置快捷鍵。
如何實現
我們先看 install
函數的實現。
export function install(element: HTMLElement, hotkey?: string): void {
// 響應鍵盤輸入事件
if (Object.keys(hotkeyRadixTrie.children).length === 0) {
document.addEventListener('keydown', keyDownHandler)
}
// 註冊快捷鍵
const hotkeys = expandHotkeyToEdges(hotkey || element.getAttribute('data-hotkey') || '')
const leaves = hotkeys.map(h => (hotkeyRadixTrie.insert(h) as Leaf<HTMLElement>).add(element))
elementsLeaves.set(element, leaves)
}
在 install
函數中有兩部分功能,第一部分是註冊快捷鍵,第二部分是響應鍵盤輸入事件並觸發快捷鍵動作。
註冊快捷鍵
因為程式碼較短,我們逐行說明。
首先,通過 expandHotkeyToEdges
函數解析元素的 data-hotkey
屬性,獲得設置的快捷鍵列表。快捷鍵的設置規則在前面功能描述中已經說明。
export function expandHotkeyToEdges(hotkey: string): string[][] {
return hotkey.split(',').map(edge => edge.split(' '))
}
之後通過這行程式碼實現了快捷鍵註冊。
const leaves = hotkeys.map(h => (hotkeyRadixTrie.insert(h) as Leaf<HTMLElement>).add(element))
最後一行實則是一個快取,方便在 uninstall
函數中刪除已經添加的快捷鍵,不贅述了。
因此,整個註冊過程核心就是 hotkeyRadixTrie
,hotkeyRadixTrie
是一棵前綴樹,在系統啟動時就已經初始化。
const hotkeyRadixTrie = new RadixTrie<HTMLElement>()
所謂前綴樹,就是 N 叉樹的一種特殊形式。通常來說,一個前綴樹是用來存儲字元串的。前綴樹的每一個節點代表一個字元串(前綴)。每一個節點會有多個子節點,通往不同子節點的路徑上有著不同的字元。子節點代表的字元串是由節點本身的原始字元串,以及通往該子節點路徑上所有的字元組成的。
在 @github/hotkey
中,有兩個類一起實現了前綴樹的功能,RadixTrie
和 Leaf
。
Leaf
類,顧名思義就是樹的葉子節點,其中保存著註冊了快捷鍵的元素。
export class Leaf<T> {
parent: RadixTrie<T>
children: T[] = []
constructor(trie: RadixTrie<T>) {
this.parent = trie
}
delete(value: T): boolean {
const index = this.children.indexOf(value)
if (index === -1) return false
this.children = this.children.slice(0, index).concat(this.children.slice(index + 1))
// 如果葉子節點保存的所有元素都已經刪除,則從前綴樹中刪除這個葉子節點
if (this.children.length === 0) {
this.parent.delete(this)
}
return true
}
add(value: T): Leaf<T> {
// 在葉子節點中添加一個元素
this.children.push(value)
return this
}
}
RadixTrie
類實現了前綴樹的主體功能,RadixTrie
的功能實現其實是樹中的一個非葉子節點,它的子節點可以是一個 Leaf
節點,也可以是另一個 RadixTrie
節點。
export class RadixTrie<T> {
parent: RadixTrie<T> | null = null
children: {[key: string]: RadixTrie<T> | Leaf<T>} = {}
constructor(trie?: RadixTrie<T>) {
this.parent = trie || null
}
get(edge: string): RadixTrie<T> | Leaf<T> {
return this.children[edge]
}
insert(edges: string[]): RadixTrie<T> | Leaf<T> {
let currentNode: RadixTrie<T> | Leaf<T> = this
for (let i = 0; i < edges.length; i += 1) {
const edge = edges[i]
let nextNode: RadixTrie<T> | Leaf<T> | null = currentNode.get(edge)
// If we're at the end of this set of edges:
if (i === edges.length - 1) {
// 如果末端節點是 RadixTrie 節點,則刪除這個節點,並用 Leaf 節點替代
if (nextNode instanceof RadixTrie) {
currentNode.delete(nextNode)
nextNode = null
}
if (!nextNode) {
nextNode = new Leaf(currentNode)
currentNode.children[edge] = nextNode
}
return nextNode
// We're not at the end of this set of edges:
} else {
// 當前快捷鍵序列還沒有結束,如果節點是一個 Leaf 節點,則刪除這個節點,並用 RadixTrie 節點替代
if (nextNode instanceof Leaf) nextNode = null
if (!nextNode) {
nextNode = new RadixTrie(currentNode)
currentNode.children[edge] = nextNode
}
}
currentNode = nextNode
}
return currentNode
}
}
我們可以看到,RadixTrie
的 insert
方法會根據前面 expandHotkeyToEdges
方法獲取到的快捷鍵列表,在當前 RadixTrie
節點上動態的添加新的 RadixTrie
或者 Leaf
節點。在添加過程中,如果之前已經有相同序列的快捷鍵添加,則會覆蓋之前的快捷鍵設置。
insert
方法返回一個 Leaf
節點,在前面的獲取快捷鍵列表然後批量調用 insert
方法之後,都會調用返回的 Leaf
節點的 add
方法將這個元素添加到葉子節點中去。
響應鍵盤輸入事件
有了前綴樹以後,響應鍵盤輸入事件就是根據輸入的鍵值遍歷前綴樹了。功能在 keyDownHandler
函數中。
function keyDownHandler(event: KeyboardEvent) {
if (event.defaultPrevented) return
if (!(event.target instanceof Node)) return
if (isFormField(event.target)) {
const target = event.target as HTMLElement
if (!target.id) return
if (!target.ownerDocument.querySelector(`[data-hotkey-scope=${target.id}]`)) return
}
if (resetTriePositionTimer != null) {
window.clearTimeout(resetTriePositionTimer)
}
resetTriePositionTimer = window.setTimeout(resetTriePosition, 1500)
// If the user presses a hotkey that doesn't exist in the Trie,
// they've pressed a wrong key-combo and we should reset the flow
const newTriePosition = (currentTriePosition as RadixTrie<HTMLElement>).get(eventToHotkeyString(event))
if (!newTriePosition) {
resetTriePosition()
return
}
currentTriePosition = newTriePosition
if (newTriePosition instanceof Leaf) {
let shouldFire = true
const elementToFire = newTriePosition.children[newTriePosition.children.length - 1]
const hotkeyScope = elementToFire.getAttribute('data-hotkey-scope')
if (isFormField(event.target)) {
const target = event.target as HTMLElement
if (target.id !== elementToFire.getAttribute('data-hotkey-scope')) {
shouldFire = false
}
} else if (hotkeyScope) {
shouldFire = false
}
if (shouldFire) {
fireDeterminedAction(elementToFire)
event.preventDefault()
}
resetTriePosition()
}
}
這段程式碼可以分成三個部分來看。
第一部分是一些校驗邏輯,比如接收到的事件已經被 preventDefault
了,或者觸發事件的元素類型錯誤。對於表單元素,還有一些特殊的校驗邏輯。
第二部分是恢復邏輯。因為用戶輸入是逐個按鍵輸入的,因此 keydown
事件也是逐次觸發的。因此,我們需要一個全局指針來遍歷前綴樹。這個指針一開始是指向根節點 hotkeyRadixTrie
的。
let currentTriePosition: RadixTrie<HTMLElement> | Leaf<HTMLElement> = hotkeyRadixTrie
當用戶停止輸入之後,不管有沒有命中快捷鍵,我們需要將這個指針回撥到根節點的位置。這個就是恢復邏輯的功能。
function resetTriePosition() {
resetTriePositionTimer = null
currentTriePosition = hotkeyRadixTrie
}
第三部分就是響應快捷鍵的核心邏輯。
首先會通過 eventToHotkeyString
函數將事件鍵值翻譯為快捷鍵,是的鍵值與前綴樹中保存的一致。
export default function hotkey(event: KeyboardEvent): string {
const elideShift = event.code.startsWith('Key') && event.shiftKey && event.key.toUpperCase() === event.key
return `${event.ctrlKey ? 'Control+' : ''}${event.altKey ? 'Alt+' : ''}${event.metaKey ? 'Meta+' : ''}${
event.shiftKey && !elideShift ? 'Shift+' : ''
}${event.key}`
}
之後,在當前節點指針 currentTriePosition
根據新獲取的鍵值獲取下一個樹節點。如果下一個節點為空,說明未命中快捷鍵,執行恢復邏輯並返回。
如果找到了下一個節點,則將當前節點指針 currentTriePosition
往下移一個節點。如果找到的這個新節點是一個 Leaf
節點,則獲取這個葉子節點中保存的元素,並在這個元素上執行 fireDeterminedAction
動作。
export function fireDeterminedAction(el: HTMLElement): void {
if (isFormField(el)) {
el.focus()
} else {
el.click()
}
}
fireDeterminedAction
執行的動作就是,如果這個元素是一個表單元素,則讓這個元素獲取焦點,否則觸發點擊事件。
常見面試知識點、技術解決方案、教程,都可以掃碼關注公眾號「眾里千尋」獲取,或者來這裡 //everfind.github.io 。