在 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 pagefor (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 叉树的一种非凡模式。通常来说,一个前缀树是用来存储字符串的。前缀树的每一个节点代表一个字符串(前缀)。每一个节点会有多个子节点,通往不同子节点的门路上有着不同的字符。子节点代表的字符串是由节点自身的原始字符串,以及通往该子节点门路上所有的字符组成的。

@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 执行的动作就是,如果这个元素是一个表单元素,则让这个元素获取焦点,否则触发点击事件。

常见面试知识点、技术解决方案、教程,都能够扫码关注公众号“众里千寻”获取,或者来这里 https://everfind.github.io 。