Vue3+TS实现区域快捷键指令(力荐tabindex)

947 阅读2分钟

场景

当我们使用addEventListener进行键盘事件监听时,对冒泡一定又爱又恨!爱是它使KeyboardEvent在DOM Tree向上传递,恨是它使KeyboardEvent在DOM Tree向上传递。试想如果要监听俩个不同Div的不同Delete按键,要怎么做:首先给Div设置不同id,然后监听键盘事件并根据id做状态转换。假如有十个区域呢,或者可以动态添加的区域呢?

这里就要介绍tabindex,它是是HTML全局属性,当给div加上tabindex=0时,鼠标(click)或者键盘(tab)可以触发其:focus伪类。最为关键一点是,当div:focus时,它有且仅有接受它子代事件的冒泡,这样就能把不同标签(不互为子代)的事件区分开来,成功了一半。

再试想,你想监听Div的Delete,但是它下面存在标签Input,当按下Delete时,Input原生事件和你监听的事件回调都会被执行,是不是很头疼,如果该Div下存在多个Input,直接爆炸。基于上面的理论,我们只需要阻止Input的事件冒泡或者Div主动防御Input传递过来的事件即可(可根据Div下可focus标签数量决定使用哪种方案,本篇为第二种)。

实现

import { Directive, DirectiveBinding } from "vue";

export type BindingType = {
  keyMap?: {
    // 比如:'Enter': () => log('按下了Enter')
    [key: string]: <T>() => T
  },
  // 禁止的标签
  banTitle?: string[],
}

// Vue3指令类型没有存储事件方法的属性(这样移除的时候不好操作),所以这里定义了一个Map
// 当然,你还可以使用mixin混入完整的生命周期,或者继承Vue3指令类型
const keyboardNodeList = new Map();

function getKey(event: KeyboardEvent) {
    const key = event.key;
    // 捕捉三个特殊键(Meta键位没有考虑)
    if (["Control", "Shift", "Alt"].includes(key)) return "";
    let preKey = "";
    if (event.ctrlKey) {
      preKey += "Ctrl+";
    }
    if (event.shiftKey) {
      preKey += "Shift+";
    }
    if (event.altKey) {
      preKey += "Alt+";
    }
    // shift键会导致字母大写
    return preKey + key[0].toUpperCase() + key.slice(1);
  }

function addKeyboardEventListener(el: HTMLElement, binding: DirectiveBinding<BindingType>) {
  if (!el || !binding) return null;
  return function (event: KeyboardEvent) {
    const BindingValue = binding.value as BindingType;

    if (!BindingValue.keyMap) return;
    // 默认禁止input、a的键盘事件冒泡
    const BanTitle = BindingValue.banTitle?.length ? BindingValue.banTitle : ['INPUT', 'A'];
    // 全部转为大写
    for (let i = 0; i < BanTitle.length; ++i) BanTitle[i].toLocaleUpperCase();
    const modifiers = binding.modifiers;
    // 如果带有阻止符,阻止此键盘事件的冒泡
    if (modifiers.stop) event.stopPropagation?.();
    // 如果是禁止的标签,不监听
    const TagName = (event.target as HTMLElement).tagName || '';
    if (TagName && BanTitle.includes(TagName)) return;
    // 获取键盘key
    const keyboard = getKey(event);
    if (keyboard) {
      // 执行映射的事件
      BindingValue.keyMap[keyboard]?.();
    }
  };
}

export const DirectiveKeyboard: Directive<HTMLElement, BindingType> = {
  created(el, binding) {
    el.tabIndex = 0;
    const keyboardFn = addKeyboardEventListener(el, binding);
    if (keyboardFn) {
      keyboardNodeList.set(el, keyboardFn);
      el.addEventListener('keydown', keyboardFn);
    }
  },
  mounted(el, binding) {
    // el没有挂载时不能执行focus,必须在挂载之后
    el.focus();
  },
  beforeUnmount(el, binding) {
    const existKeyboardFn = keyboardNodeList.get(el);
    // 卸载键盘监听事件
    if (existKeyboardFn) {
      el.removeEventListener('keydown', existKeyboardFn);
      keyboardNodeList.delete(el);
    }
  },
};

然后就是在app启动的时候use这个指令啦!大家还可以创建directive.d.ts文件,使开发的时候能够得到指令的提示,不至于出现下面这种情况,至于怎么定义,可以参考下你所使用的ui组件库,这里我就不介绍了(我不会)。

image.png