在 GitHub 的页面上有很多快捷键可以使用,比如键入 g + c 键选中 Code 标签页,键入 g + i 选中 Issues 标签页。这里是 GitHub 支持的快捷键列表open in new window。那么,这么丰富的快捷键,是如何来实现的呢?我们今天就通过 GitHub 官方的 @github/hotkeyopen in new window 来一窥究竟。

功能描述

在需要支持快捷键的元素上,通过 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>
1
2
3
4
import {install} from '@github/hotkey'

// Install all the hotkeys on the page
for (const el of document.querySelectorAll('[data-hotkey]')) {
  install(el)
}
1
2
3
4
5
6

添加快捷键的规则是:

  • 如果一个元素上支持多个快捷键,则不同的快捷键之间通过 , 分割。
  • 组合键通过 + 连接,比如 Control + j
  • 如果一个快捷键序列中有多个按键,则通过空格连接,比如 g c

我们在这里可以查到键盘上每个功能按键对应事件键值名称open in new window,方便设置快捷键。

如何实现

我们先看 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)
}
1
2
3
4
5
6
7
8
9
10
11

install 函数中有两部分功能,第一部分是注册快捷键,第二部分是响应键盘输入事件并触发快捷键动作。

注册快捷键

因为代码较短,我们逐行说明。

首先,通过 expandHotkeyToEdges 函数解析元素的 data-hotkey 属性,获得设置的快捷键列表。快捷键的设置规则在前面功能描述中已经说明。

export function expandHotkeyToEdges(hotkey: string): string[][] {
  return hotkey.split(',').map(edge => edge.split(' '))
}
1
2
3

之后通过这行代码实现了快捷键注册。

const leaves = hotkeys.map(h => (hotkeyRadixTrie.insert(h) as Leaf<HTMLElement>).add(element))
1

最后一行实则是一个缓存,方便在 uninstall 函数中删除已经添加的快捷键,不赘述了。

因此,整个注册过程核心就是 hotkeyRadixTriehotkeyRadixTrie 是一棵前缀树,在系统启动时就已经初始化。

const hotkeyRadixTrie = new RadixTrie<HTMLElement>()
1

所谓前缀树,就是 N 叉树的一种特殊形式。通常来说,一个前缀树是用来存储字符串的。前缀树的每一个节点代表一个字符串(前缀)。每一个节点会有多个子节点,通往不同子节点的路径上有着不同的字符。子节点代表的字符串是由节点本身的原始字符串,以及通往该子节点路径上所有的字符组成的。 前缀树

@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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

我们可以看到,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()
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

这段代码可以分成三个部分来看。

第一部分是一些校验逻辑,比如接收到的事件已经被 preventDefault 了,或者触发事件的元素类型错误。对于表单元素,还有一些特殊的校验逻辑。

第二部分是恢复逻辑。因为用户输入是逐个按键输入的,因此 keydown 事件也是逐次触发的。因此,我们需要一个全局指针来遍历前缀树。这个指针一开始是指向根节点 hotkeyRadixTrie 的。

let currentTriePosition: RadixTrie<HTMLElement> | Leaf<HTMLElement> = hotkeyRadixTrie
1

当用户停止输入之后,不管有没有命中快捷键,我们需要将这个指针回拨到根节点的位置。这个就是恢复逻辑的功能。

function resetTriePosition() {
  resetTriePositionTimer = null
  currentTriePosition = hotkeyRadixTrie
}
1
2
3
4

第三部分就是响应快捷键的核心逻辑。

首先会通过 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}`
}
1
2
3
4
5
6

之后,在当前节点指针 currentTriePosition 根据新获取的键值获取下一个树节点。如果下一个节点为空,说明未命中快捷键,执行恢复逻辑并返回。

如果找到了下一个节点,则将当前节点指针 currentTriePosition 往下移一个节点。如果找到的这个新节点是一个 Leaf 节点,则获取这个叶子节点中保存的元素,并在这个元素上执行 fireDeterminedAction 动作。

export function fireDeterminedAction(el: HTMLElement): void {
  if (isFormField(el)) {
    el.focus()
  } else {
    el.click()
  }
}
1
2
3
4
5
6
7

fireDeterminedAction 执行的动作就是,如果这个元素是一个表单元素,则让这个元素获取焦点,否则触发点击事件。

关注微信公众号,获取最新推送~