极致舒适的Vue快捷键开发方案

一个Hook让你实现快捷键自由,你还不快来看看?

HotKey那些事

快捷键功能在编辑器类应用中是一个有助于提升用户体验的重要功能。最近,我正在开发一个名为Pictode的画板编辑器框架项目。由于Pictode是一个编辑器,因此我想分享一下在开发Pictode的过程中关于快捷键的实践方案

这时候有的同学可能会说:不就是快捷键嘛?这有何难,**开干!我玩的就是真实~**😏😎

要实现快捷键功能并不需要高深的技术知识,任何学过前端的人都可以实现这个技能~

然而,我有一个习惯,当面对一个新的需求时,我不会立即投入开发。而是首先利用已有知识构建出一个初步的解决方案,然后再去搜索并研究别人的方案。通过综合对比多个方案,我会改进自己的方案,或者在找到更加优雅高效的方案时直接采用。在实施方案时,我还会根据项目的具体需求对方案进行调整

这个方式帮助我确保所选的方案是最适合项目的,同时也可以借鉴其他开发者的经验和最佳实践

放心食用: useHotKey体验地址

放心食用: Pictode体验地址

现有方案考察

监听keydown事件

要实现快捷键功能的核心,首先需要监听浏览器焦点元素的keydown事件 。这个事件会在用户按下键盘按键时触发。通过监听keydown事件,我们可以捕获用户的按键操作,并根据事件对象中的key属性来确定用户按下了哪个按键

详情阅读MDN Element: 键盘按下事件

实现代码如下:

js 复制代码
document.addEventListener(
  "keydown",
  function (event) {
    if (event.defaultPrevented) {
      return; // 如果事件已经在进行中,则不做任何事。
    }
    switch (event.key) {
      case "ArrowUp":
        // 按"↑"方向键时要做的事。
        break;
      case "ArrowDown":
        // 按"↓"方向键时要做的事。
        break;
      case "ArrowLeft":
        // 按"←"方向键时要做的事。
        break;
      case "ArrowRight":
        // 按"→"方向键时要做的事。
        break;
      default:
        return; // 什么都没按就退出吧。
    }
    // 取消默认动作,从而避免处理两次。
    event.preventDefault();
  },
  true,
);

这个方案的优点很明显,在于简单直接,容易上手,缺点同样很明显

优点

  • 简单直接,易于理解和实现

缺点

  • 可维护性差:随着快捷键数量的增加,代码会变得越来越混乱,难以维护
  • 扩展性不足:添加新的快捷键时,需要修改现有代码,缺乏灵活性
  • 上下文处理困难:不同的快捷键要处理的上下文可能是不一样的,因此这种结构很不理想

按键修饰符

Vue中对于事件的处理可以添加按键修饰符,通过按键修饰符来实现当按键按下时触发事件

你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发

  • .ctrl
  • .alt
  • .shift
  • .meta
  • .exact修饰符允许控制触发一个事件所需的确定组合的系统按键修饰符

详情阅读Vue 系统按键修饰符

实现代码如下:

html 复制代码
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl + 点击 -->
<div @click.ctrl="doSomething">Do something</div>

<!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
<button @click.ctrl="onClick">A</button>

<!-- 仅当按下 Ctrl 且未按任何其他键时才会触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- 仅当没有按下任何系统按键时触发 -->
<button @click.exact="onClick">A</button>

Vue按键修饰符能够优雅地实现部分快捷键的场景,但需要注意的是,只是优雅的解决部分场景

如果你的逻辑只受页面上某个按钮或DOM事件的触发影响,那么按键修饰符完全足够。然而,如果你的逻辑会受到多个组件上的多个按钮或DOM事件的触发影响,那么使用Vue的按键修饰符可能会遇到一些障碍

我遇到的场景是,需要在页面上的按钮点击触发撤销/重做功能,同时也需要在右键菜单中触发相同的功能

然而,最关键的问题不仅仅是逻辑分散。如果只是因为逻辑分散的问题,我们可以通过一些工程手段来组织代码。最重要的问题是,Vue的按键修饰符无法处理按键抬起的逻辑

我遇到的场景是,当按下空格键时,需要激活画布的拖拽功能,而当空格键抬起时,需要取消画布的拖拽功能

所以在我看来,Vue的按键修饰符适用于解决一些快捷键场景。但是对于一些更复杂的快捷键场景,仅仅使用按键修饰符可能会变得非常复杂,代码组织困难,而且无法处理按键抬起事件,快捷键无法集中管理

自定义指令

对于Vue按键修饰符存在的一些问题,有的大神已经开发了自定义指令,例如 v-hotkey。这是一个适用于Vue 2的自定义指令,旨在解决Vue按键修饰符无法处理按键抬起事件的问题。尽管基于自定义指令的快捷键开发已经迈出了一大步,但仍然无法解决按键修饰符带来的快捷键逻辑分散的问题

详情阅读v-hotkey Github仓库

示例代码如下:

html 复制代码
<template>
  <span v-hotkey="keymap" v-show="show"> 
    Press `ctrl + esc` to toggle me! Hold `enter` to hide me!
  </span>
</template>

<script>
export default {
  data () {
    return {
      show: true
    }
  },
  methods: {
    toggle () {
      this.show = !this.show
    },
    show () {
      this.show = true
    },
    hide () {
      this.show = false
    }
  },
  computed: {
    keymap () {
      return {
        // 'esc+ctrl' is OK.
        'ctrl+esc': this.toggle,
        'enter': {
          keydown: this.hide,
          keyup: this.show
        }
      }
    }
  }
}
</script>

Mousetrap.js

另一个可选方案是使用 Mousetrap.js,这是一个经典的JavaScript快捷键开发库。这种专门解决快捷键功能的库,对于快捷键开发来说当然是考虑的非常全面了

详情阅读Mousetrap.js

示例代码如下:

ts 复制代码
// single keys
Mousetrap.bind('4', function() { highlight(2); });
Mousetrap.bind('x', function() { highlight(3); }, 'keyup');

// combinations
Mousetrap.bind('command+shift+k', function(e) {
    highlight([6, 7, 8, 9]);
    return false;
});

Mousetrap.bind(['command+k', 'ctrl+k'], function(e) {
    highlight([11, 12, 13, 14]);
    return false;
});

// gmail style sequences
Mousetrap.bind('g i', function() { highlight(17); });
Mousetrap.bind('* a', function() { highlight(18); });

// konami code!
Mousetrap.bind('up up down down left right left right b a enter', function() {
    highlight([21, 22, 23]);
});

尽管Mousetrap.js提供了广泛的功能,但我决定在 Pictode 中不使用它的原因之一是避免引入第三方库 。此外,该库具有较陡峭的学习曲线,并且某些自定义功能可能无法满足项目需求😑

解决方案探索

思考

上面提到的现有方案多多少少让我感到不满意,总觉得有些地方很奇怪、别扭。但到底哪里奇怪呢?我认为关键在于我们的关注点

实现快捷键功能,我们应该关注什么?

像上面的那些方案一样,关注按键应该如何绑定?还是应该关注keydown事件应该挂到哪个DOM?🤡

我觉得都不是,在实现快捷键功能时,应该关注的是功能本身,也就是函数。快捷键功能的核心是一种函数触发的机制。我们之所以使用快捷键,是为了执行特定的功能,而不是将关注点放在事件应该挂载到哪个 DOM 元素上

🧐我站在这个角度重新思考了快捷键的开发方式,将注意力集中在功能的实现上,而不是事件的处理方式。这种思维方式让我感受可以到更清晰地组织和管理快捷键功能,使快捷键开发变得更加直观和自然

极致舒适快捷键开发

上面的Pictode场景比较复杂,为了更好地理解快捷键开发,我创建了一个简化版的场景如下:

放心食用: useHotKey体验地址

设置target元素

不设置target元素

从页面上可以看到,除了快捷键外,我们还需要在表格中展示快捷键信息。这个表格上有执行对应功能的按钮,点击按钮会立即执行相应的快捷键功能

实现上面的页面代码如下:

html 复制代码
<script setup lang="ts">
import { ref } from 'vue';
import { ElTable, ElTableColumn } from 'element-plus';

const hotkeyRef = ref<HTMLElement | null>(null);
const isVisible = ref<boolean>(true);
const message = ref<string>();

const hotkeyList: Array<{
  hotkey?: string;
  label?: string;
  target?: string;
  action?: () => void;
}> = [];
</script>

<template>
  <div class="container">
    <button @click="isVisible = !isVisible">切换</button>
    <div v-if="isVisible" class="hotkey">
      <h1>快捷键绑定区域</h1>
      <input ref="hotkeyRef" />
    </div>
    <ElTable stripe :border="true" :data="hotkeyList">
      <ElTableColumn prop="target" label="绑定区域"></ElTableColumn>
      <ElTableColumn prop="label" label="说明"></ElTableColumn>
      <ElTableColumn prop="hotkey" label="快捷键"></ElTableColumn>
      <ElTableColumn prop="option" label="操作" v-slot="{ row }">
        <button @click="row.action">立即执行</button>
      </ElTableColumn>
    </ElTable>
    <h2>快捷键消息</h2>
    <h1>{{ message }}</h1>
  </div>
</template>

页面的布局和代码都十分简单,接下来我们将实现动图上的功能。首先,我们使用useHotKey函数来定义一个函数,并返回一个包含相关信息的对象

ts 复制代码
const copy = useHotKey(
  () => {
    message.value = '复制功能';
  },
  {
    key: 'c',
    ctrlKey: true,
    directions: '复制',
    target: hotkeyRef,
  }
);

//...

const undo = useHotKey(
  () => {
    message.value = '撤销功能';
  },
  {
    key: 'z',
    ctrlKey: true,
    directions: '撤销',
  }
);

const hotkeyList: Array<{
  hotkey?: string;
  label?: string;
  action?: () => void;
}> = [
  {
    hotkey: copy.hotKey?.join('+'),
    label: copy.directions,
    target: 'Input',
    action: copy,
  },
  {
    hotkey: undo.hotKey?.join('+'),
    label: undo.directions,
    target: 'Window',
    action: undo,
  },
];

这里,copy函数设置了target属性为input。因此,只有当input元素处于激活状态时,copy函数才会响应设置的Ctrl+c快捷键触发,否则copy函数将不会响应快捷键事件。与此不同,undo函数没有设置target属性,因此默认将target设置为window对象。但是当input处于激活状态时,undo函数不会响应window上的快捷键,这是为了避免快捷键冲突而设计的

在这段代码中,我使用了useHotKey函数来定义一个函数,并返回一个包含了快捷键组合、功能说明和对应操作函数的对象。这种方式使我们能够更便捷地管理和执行快捷键功能

需要强调的是,在使用useHotKey的过程中,我们的主要关注点是函数的定义 。简而言之,我们通过useHotKey定义了一个函数,该函数可以通过特定的按键触发,当然你也可以不通过按钮来调用这个函数。这种设计让我们的代码更加灵活,不仅可以通过快捷键触发函数,还可以像普通函数一样直接调用,从而提高了代码的可维护性和可复用性

这种方式的好处在于,我们能够将快捷键功能与普通函数完美融合,为开发提供了更多的自由度和便捷性

useHotKey说明

useHotKey函数有两个参数

第一个参数:用于定义函数,该函数如果返回一个函数,则会在按键抬起时执行返回的这个函数,就像下面这个函数

ts 复制代码
const subscribe = useHotKey(
  () => {
    message.value = 'Y消息';
    return () => {
      message.value = '';
    };
  },
  {
    key: 'y',
    directions: '按下设置消息,抬起清楚消息',
  }
);

第二个参数:用于设置快捷键的触发条件,包括键值、事件监听的目标元素、快捷键的描述以及是否需要同时按下 Shift 键和 Ctrl 键等等。以下是第二个参数的详细定义:

ts 复制代码
export interface HotKeyOptions {
  key: string | string[]; // 要监听的键值,可以是字符串或字符串数组,如果为数组则为或的关系
  target: Ref<EventTarget> | EventTarget; // 事件监听的目标元素,可以是 Vue Ref 对象或普通的 EventTarget 
  directions: string; // 快捷键的描述或用途 
  shiftKey: boolean; // 是否需要同时按下 Shift 键才能触发快捷键 
  ctrlKey: boolean; // 是否需要同时按下 Ctrl 键才能触发快捷键 
  exact: boolean; // 精确匹配同时考虑是否同时满足 Ctrl 和 Shift 的设置
}

返回值 :调用useHotKey后会返回一个函数,这个返回的函数跟快捷键函数是同一函数,因此完全可以当作快捷键函数在其他地方调用。不同的是,useHotKey会在该函数上挂载hotKeypauseunpasuedirections等属性,可以通过useHotKey的返回值对快捷键功能进行操作

感受

通过使用 useHotKey 这个 Hook,可以让你将快捷键功能的关注点从繁琐的事件绑定和DOM操作上解放出来,转而将注意力集中在功能的定义和实现上

使用 useHotKey 的好处包括:

  • 简化了快捷键功能的开发,使其更加直观和自然
  • 提高了代码的可读性和可维护性,将相关的代码集中在一起,降低了代码的复杂性
  • 允许开发者将快捷键功能与具体的DOM元素解耦,使代码更加灵活和可重用
  • 通过配置参数,可以轻松定义不同的快捷键触发条件,满足不同场景的需求

useHotKey 可以让快捷键开发变得更加舒适和高效,这种思维方式使快捷键开发更加直观和自然,提高了开发效率和代码质量

温馨提示

虽然useHotKey可以轻松实现快捷键开发,但应当谨慎使用。在某些情况下,看似适合快捷键的功能,例如登录页按回车键登录或搜索框按回车查询,实际上只是表单提交的需求。在这些情景中,使用useHotKey可能会增加不必要的复杂性。相反,你只需简单地在表单上添加@submit事件监听,将按钮设置为submit类型即可轻松实现回车提交功能,无需额外的快捷键处理

关于源码

你可以在 GitHub获取到useHotKey的源码

在介绍useHotKey之前,需要引入另一个辅助Hook,即useEventListener。这个辅助Hook有助于useHotKey实现原生事件的绑定。由于useHotKey本身是对keydown事件的监听,借助useEventListener可以简化开发难度,并将一部分事件绑定的职责剥离到useEventListener中。这种分工协作让代码更加模块化和清晰

useEventListener

useEventListener 是一个用于管理事件监听的Hook。允许指定要监听的目标元素、事件类型以及相应的事件处理函数

  • 在组件挂载时自动添加事件监听器,
  • 在组件卸载时自动移除监听器
  • 返回一个"取消监听函数",这个函数允许手动移除之前添加的事件监听器

通过useEventListener,我们可以方便地将热键功能绑定到特定的DOM元素或Vue Ref对象上,从而实现了useHotKey的核心功能

ts 复制代码
import { isRef, onMounted, onUnmounted, Ref, unref, watch } from 'vue';

type EventMap = HTMLElementEventMap & DocumentEventMap & WindowEventMap & MediaQueryListEventMap;

export const useEventListener = <K extends keyof EventMap>(
  target: Ref<EventTarget | null> | EventTarget,
  event: K,
  handler: (event: EventMap[K]) => void
): (() => void) => {
  const eventHandler = (event: Event) => {
    handler(event as EventMap[K]);
  };
  if (isRef(target)) {
    watch(target, (value, oldValue) => {
      oldValue?.removeEventListener(event, eventHandler);
      value?.addEventListener(event, eventHandler);
    });
  } else {
    onMounted(() => {
      target.addEventListener(event, eventHandler);
    });
  }

  const removeEventListener = () => {
    unref(target)?.removeEventListener(event, eventHandler);
  };

  onUnmounted(() => {
    removeEventListener();
  });

  return removeEventListener;
};

export default useEventListener;

useHotKey

至于useHotKey的实现代码并不长,这里我也直接给出,你可以直接复制到自己项目实践。

ts 复制代码
import { Ref, unref } from 'vue';

import useEventListener from './useEventListener';

export interface HotKeyOptions {
  key: string | string[];
  target: Ref<EventTarget | null> | EventTarget | null;
  directions: string;
  shiftKey: boolean;
  ctrlKey: boolean;
  exact: boolean; // 当 exact 设置为 true 时,表示在判断快捷键是否匹配时,不仅要考虑按下的按键是否匹配,还需要考虑是否同时满足 Ctrl 键和 Shift 键的状态
}

export interface HotKeyFunction {
  (...args: any[]): any;
  hotKey?: (string | string[] | undefined)[];
  directions?: string;
  pause?: () => void;
  unpause?: () => void;
  removeListener?: () => void;
}

export const useHotKey = (hotKeyFunction: HotKeyFunction, opts?: Partial<HotKeyOptions>): HotKeyFunction => {
  const target = opts?.target ?? window;
  const isMacOS = navigator.userAgent.toLowerCase().includes('mac');
  let paused: boolean = false;
  let key = opts?.key;

  const getHotKey = (): (string | string[] | undefined)[] => {
    const options = opts || {};
    const keyCombination = [];
    if (options.ctrlKey) keyCombination.push(isMacOS ? 'Cmd' : 'Ctrl');
    if (options.shiftKey) keyCombination.push(isMacOS ? 'Option' : 'Shift');
    keyCombination.push(key);

    return keyCombination;
  };

  const handleKeydownEvent = (event: KeyboardEvent) => {
    event.stopPropagation();
    const options = opts || {};
    if (paused || !key) {
      return;
    }
    key = typeof key === 'string' ? [key] : key;
    if (key.includes(event.key.toLowerCase()) && matchKeyScheme(options, event)) {
      event.preventDefault();
      const result = hotKeyFunction();
      if (typeof result !== 'function') {
        return;
      }
      const targetElement: EventTarget = unref(target) ?? window;
      const handleKeyup = (event: Event) => {
        event.preventDefault();
        result();
        targetElement.removeEventListener('keyup', handleKeyup);
      };
      targetElement.addEventListener('keyup', handleKeyup);
    }
  };

  const removeListener = useEventListener(target, 'keydown', handleKeydownEvent);

  hotKeyFunction.hotKey = getHotKey();
  hotKeyFunction.directions = opts?.directions ?? '';
  hotKeyFunction.removeListener = removeListener;
  hotKeyFunction.pause = () => (paused = true);
  hotKeyFunction.unpause = () => (paused = false);
  return hotKeyFunction;
};

const matchKeyScheme = (
  opts: Pick<Partial<HotKeyOptions>, 'shiftKey' | 'ctrlKey' | 'exact'>,
  event: KeyboardEvent
): boolean => {
  const ctrlKey = opts.ctrlKey ?? false;
  const shiftKey = opts.shiftKey ?? false;
  if (opts.exact) {
    return (ctrlKey === event.metaKey || ctrlKey === event.ctrlKey) && shiftKey === event.shiftKey;
  }
  const satisfiedKeys: boolean[] = [];
  satisfiedKeys.push(ctrlKey === event.metaKey || ctrlKey === event.ctrlKey);
  satisfiedKeys.push(shiftKey === event.shiftKey);
  return satisfiedKeys.every((item) => item);
};

export default useHotKey;

最后

感谢各位看官的阅读!如果这篇文章对你有所帮助,请点赞/收藏/关注👻

如果你觉得useHotKey对你在开发中有所帮助,请多点赞评论收藏😊

如果useHotKey对你实现某些业务有所启发,请多点赞评论收藏😊

如果...,请多点赞评论收藏😊

如果大家有其他快捷键开发方案,欢迎留言交流哦!

我将这篇文章收录到了Vue极致舒适系列 - youth君的专栏 - 掘金 (juejin.cn),这个专栏收录了我在工作中的一些Vue实践方案,欢迎大家订阅

相关推荐
excel6 分钟前
webpack 核心编译器 十四 节
前端
excel13 分钟前
webpack 核心编译器 十三 节
前端
腾讯TNTWeb前端团队7 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰11 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪11 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪11 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy11 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom12 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom12 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom12 小时前
React与Next.js:基础知识及应用场景
前端·面试·github