最近electron项目中某些特定页面要求有一些键盘组合键方便用户快速编辑,直接用 globalShortcut 显然是不适合的,下面封装了一个 useCombinationKey 的hook,方便调用。
思路分析
键码映射表 KEY_MAP
我们建立一个完整的键名 → keyCode 映射表,支持:
- 修饰键:
ctrl、shift、alt、meta - 功能键:
esc、enter、space、tab、delete、insert等 - 导航键:
arrowup、arrowdown、home、end等 - 数字键盘:
numpad0~numpad9、numpadadd等 - 符号键:
`、-、=、[、]、;、'、,、.、/ - 字母键:
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+k和shift+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;
}
生命周期自动管理
利用 onMounted 和 onUnmounted 自动注册/注销事件,避免内存泄漏:
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()
})