Electron/Vue 3快捷键方案:useCombinationKey Hook设计与实现详解

122 阅读4分钟

最近electron项目中某些特定页面要求有一些键盘组合键方便用户快速编辑,直接用 globalShortcut 显然是不适合的,下面封装了一个 useCombinationKey 的hook,方便调用。

思路分析

键码映射表 KEY_MAP

我们建立一个完整的键名 → keyCode 映射表,支持:

  • 修饰键ctrlshiftaltmeta
  • 功能键escenterspacetabdeleteinsert 等
  • 导航键arrowuparrowdownhomeend 等
  • 数字键盘numpad0 ~ numpad9numpadadd 等
  • 符号键`-=[];',./
  • 字母键a ~ z
  • 功能键扩展f1 ~ f24

修饰键白名单 MODIFIER_KEYS

并非所有组合都合法。我们定义一个修饰键白名单,确保组合键中至少包含一个“控制性按键”,避免误触发:

const MODIFIER_KEYS = new Set([
    16, // Shift
    17, // Ctrl
    18, // Alt
    91, // Meta
    9,  // Tab
    46, // Delete
    38, 39, 40, 37 // 方向键
]);

组合键解析器 parseCombo

将字符串如 "ctrl+enter" 解析为 [17, 13],另外也需要支持大小写、空格、别名(如 control 代替 ctrl)。

function parseCombo(combo) {
    let comboArr = combo.toLowerCase().split('+').map(key => {
        const parsedKey = key.trim();
        const code = KEY_MAP[parsedKey];
        if (!code) throw new Error(`Invalid key: ${parsedKey}`);
        return code;
    });
    if(comboArr.some(k => MODIFIER_KEYS.has(k))) return comboArr
    throw new Error(`Invalid combination: ${combo}`);
}

事件处理器状态管理

每个 useCombinationKey 实例拥有独立状态

const handler = {
    pressed: new Set(),     // 当前按下的键集合
    lastTriggered: false,   // 上次是否已触发(防重复)
    continuous             // 是否持续触发
};

键盘事件监听

📥 handleKeydown

  • 跳过可编辑元素(input/textarea/contentEditable)
  • 防止重复触发(非 continuous 模式)
  • 记录按键,检查组合
const handleKeydown = (e) => {
    if (isEditableElement(e.target)) return
    if (handler.pressed.has(e.keyCode) && !handler.continuous) return;

    handler.pressed.add(e.keyCode);
    checkCombination(e);
};

📤 handleKeyup

  • 移除按键记录
  • 非 continuous 模式下重置触发状态
const handleKeyup = (e) => {
    handler.pressed.delete(e.keyCode);
    if (!handler.continuous) {
        handler.lastTriggered = false;
    }
};

🔄 checkCombination

精确匹配当前按下的键与目标组合键:

const currentKeys = Array.from(handler.pressed).sort();
const isExactMatch =
    currentKeys.length === sortedKeys.length &&
    currentKeys.every((key, index) => key === sortedKeys[index]);

if (isExactMatch) {
    if (handler.continuous || !handler.lastTriggered) {
        callback(e);
        if (!handler.continuous) handler.lastTriggered = true;
    }
}

✅ 支持任意顺序:ctrl+shift+kshift+ctrl+k 等效
✅ 支持持续触发:按住不放可重复执行回调


编辑元素防冲突

自动跳过输入场景,避免干扰用户输入:

function isEditableElement(element) {
    const tagName = element.tagName.toUpperCase();
    if (tagName === 'INPUT') {
        const inputTypes = ['text', 'search', 'password', 'email', 'number', 'tel', 'url'];
        return inputTypes.includes(element.type.toLowerCase());
    }
    if (tagName === 'TEXTAREA') return true;
    return element.isContentEditable === true;
}

生命周期自动管理

利用 onMountedonUnmounted 自动注册/注销事件,避免内存泄漏:

onMounted(() => {
    window.addEventListener('keydown', handleKeydown, true);
    window.addEventListener('keyup', handleKeyup, true);
    window.addEventListener('focus', handleFocus)
})

onUnmounted(() => {
    window.removeEventListener('keydown', handleKeydown, true);
    window.removeEventListener('keyup', handleKeyup, true);
    window.removeEventListener('focus', handleFocus)
});

完整代码

import { isObject, isBoolean } from "@/utils/common.js"
// 键码映射表
const KEY_MAP = {
    // 修饰键
    ...['ctrl', 'control'].reduce((o, k) => (o[k] = 17, o), {}),
    ...['shift'].reduce((o, k) => (o[k] = 16, o), {}),
    ...['alt', 'option'].reduce((o, k) => (o[k] = 18, o), {}),
    ...['meta', 'cmd', 'command', 'windows'].reduce((o, k) => (o[k] = 91, o), {}),

    // 功能键
    ...{
        esc: 27,
        enter: 13,
        space: 32,
        tab: 9,
        capslock: 20,
        backspace: 8,
        delete: 46,
        del: 46,
        insert: 45,
        ins: 45,
        numlock: 144,
        scrolllock: 145,
        printscreen: 44,
        pause: 19,
        contextmenu: 93
    },

    // 导航键
    ...{
        arrowup: 38,
        arrowdown: 40,
        arrowleft: 37,
        arrowright: 39,
        pageup: 33,
        pagedown: 34,
        home: 36,
        end: 35
    },

    // 数字键盘
    ...Object.fromEntries(
        Array(10).fill().map((_, i) => [`numpad${i}`, 96 + i])
    ),
    ...{
        numpadmultiply: 106,
        numpadadd: 107,
        numpadsubtract: 109,
        numpaddecimal: 110,
        numpaddivide: 111
    },

    // 符号键
    ...{
        '`': 192,
        '-': 189,
        '=': 187,
        '[': 219,
        ']': 221,
        '\\': 220,
        ';': 186,
        "'": 222,
        ',': 188,
        '.': 190,
        '/': 191
    }
};

// 字母键(a-z)
for (let i = 0; i < 26; i++) {
    const char = String.fromCharCode(97 + i);
    KEY_MAP[char] = 65 + i;
}

// 功能键扩展(F1-F24)
for (let i = 1; i <= 24; i++) {
    KEY_MAP[`f${i}`] = 111 + i;
}

// 有效修饰键列表
const MODIFIER_KEYS = new Set([
    // 修饰键
    16, 17, 18, 91,
    // 功能键
    9, 46, 38, 40, 37, 39
]);
// 浏览器内快捷键
export function useCombinationKey(combo, callback, options = {}) {
    // 解析组合键
    const keys = parseCombo(combo);
    const sortedKeys = [...keys].sort();

    let continuous = false;
    if (isObject(options)) {
        continuous = !!options.continuous;
    } else if (isBoolean(options)) {
        continuous = options;
    }

    // 创建独立的事件处理器
    const handler = {
        pressed: new Set(),
        lastTriggered: false,
        continuous
    };

    // 键盘按下事件处理
    const handleKeydown = (e) => {
        // console.log('键盘事件处理', e, handler)
        if (isEditableElement(e.target)) return
        if (handler.pressed.has(e.keyCode) && !handler.continuous) return;

        handler.pressed.add(e.keyCode);
        checkCombination(e);
    };

    // 是否是编辑元素
    function isEditableElement(element) {
        const tagName = element.tagName.toUpperCase();
        
        if (tagName === 'INPUT') {
            const inputTypes = ['text', 'search', 'password', 'email', 'number', 'tel', 'url'];
            return inputTypes.includes(element.type.toLowerCase());
        }
        
        if (tagName === 'TEXTAREA') return true;
        
        return element.isContentEditable === true;
    }

    // 键盘松开事件处理
    const handleKeyup = (e) => {
        handler.pressed.delete(e.keyCode);
        // 仅在非持续模式时重置触发状态
        if (!handler.continuous) {
            handler.lastTriggered = false;
        }
    };

    // 组合键检查
    const checkCombination = (e) => {
        const currentKeys = Array.from(handler.pressed).sort();

        // 精确匹配需要同时满足:
        // 1. 按键数量一致
        // 2. 所有按键都包含在目标组合中
        const isExactMatch =
            currentKeys.length === sortedKeys.length &&
            currentKeys.every((key, index) => key === sortedKeys[index]);
        if (isExactMatch) {
            // 持续模式或首次触发
            if (handler.continuous || !handler.lastTriggered) {
                callback(e);
                if (!handler.continuous) {
                    handler.lastTriggered = true;
                }
            }
        }
    };

    const handleFocus = () => handler.pressed.clear()
    
    // 注册事件
    onMounted(() => {
        // console.log('注册事件')
        window.addEventListener('keydown', handleKeydown, true);
        window.addEventListener('keyup', handleKeyup, true);
        window.addEventListener('focus', handleFocus)
    })

    // 自动清理
    onUnmounted(() => {
        // console.log('自动清理')
        window.removeEventListener('keydown', handleKeydown, true);
        window.removeEventListener('keyup', handleKeyup, true);
        window.removeEventListener('focus', handleFocus)
    });

    // 解析组合键字符串
    function parseCombo(combo) {
        let comboArr = combo.toLowerCase().split('+').map(key => {
            const parsedKey = key.trim();
            const code = KEY_MAP[parsedKey];

            if (!code) {
                throw new Error(`Invalid key: ${parsedKey}`);
            }

            return code;
        });
        if(comboArr.some(k => MODIFIER_KEYS.has(k))) return comboArr
        throw new Error(`Invalid combination:${combo}`);
    }
}

使用示例

基础用法

useCombinationKey('ctrl+s', () => {
  saveDocument()
})

持续触发(如游戏移动)

useCombinationKey('w', (e) => {
  movePlayer('up')
}, { continuous: true })

多修饰键组合

useCombinationKey('ctrl+shift+i', () => {
  toggleInspector()
})

功能键

useCombinationKey('f5', () => {
  location.reload()
})