如何實現一個快捷鍵響應系統

  • 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 函數中刪除已經添加的快捷鍵,不贅述了。

因此,整個註冊過程核心就是 hotkeyRadixTriehotkeyRadixTrie 是一棵前綴樹,在系統啟動時就已經初始化。

const hotkeyRadixTrie = new RadixTrie<HTMLElement>()

所謂前綴樹,就是 N 叉樹的一種特殊形式。通常來說,一個前綴樹是用來存儲字元串的。前綴樹的每一個節點代表一個字元串(前綴)。每一個節點會有多個子節點,通往不同子節點的路徑上有著不同的字元。子節點代表的字元串是由節點本身的原始字元串,以及通往該子節點路徑上所有的字元組成的。
image

@github/hotkey 中,有兩個類一起實現了前綴樹的功能,RadixTrieLeaf

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
  }
}

我們可以看到,RadixTrieinsert 方法會根據前面 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
眾里千尋