前言
在 wangEditor v4 中,对于快捷键的绑定没有进行统一的封装,散落在 src/text/index.ts 文件中的多个地方。不仅不利于维护,且用户自定义快捷键的能力也被掐断。
针对这一情况,我初步有了写一个快捷键管理工具的想法。大多数人第一想法可能是直接搜一个库拿来即用就是了。但是,不折腾几下总感觉缺点味儿。
基础科普
在 KeyboardEvent 中,针对 ctrl
、shift
、alt
、meta
四个功能键分别使用 ctrlKey
、shiftKey
、altKey
、metaKey
四个属性进行代指,
同时还存在 repeat
和 isComposing
两个特殊的状态。
我们后续的思路都是基于 ctrlKey
、shiftKey
、altKey
、metaKey
、repeat
和 isComposing
这六个属性,外加一个 keyCode
属性进行实现的。
思路一:将按键组合转换成可执行函数
在我们绑定快捷键的时候,按键组合肯定是一个字符串,比如:ctrl + shift + z
。此种思路的重点就是把按键组合的字符串转换成一个可以精确匹配的可执行函数。
此种思路不会涉及组合键校验成功后的执行逻辑,它的重心是对 KeyboardEvent
的校验。
快捷键规则定义
const option = {
revoke: 'ctrl + shift + z',
}
Object.keys(option).forEach(function (uuid) {
// 在保存数据和触发快捷键回调时,uuid 将被作为凭证
const hotkey = option[uuid]
// 编译并缓存规则
})
复制代码
第一步:分解按键组合
// rule: ['ctrl', 'shift', 'z']
let rule = hotkey
.trim()
.toLowerCase()
.replace(/(?<!(!|&))\+/g, ' ')
.split(/\s+/)
复制代码
第二步:补全功能键和特殊状态
const temp = []
// 补全功能键 + 特殊状态
const special = [
['shift', 'e.shiftKey'],
['ctrl', 'e.ctrlKey'],
['alt', 'e.altKey'],
['meta', 'e.metaKey'],
['repeat', 'e.repeat'],
['composing', 'e.isComposing'],
]
special.forEach(function ([key, map]) {
const index = rule.indexOf(key)
if (index > -1) {
rule.splice(index, 1)
temp.push(map)
} else {
temp.push(`!${map}`)
}
})
/* temp: [
"e.shiftKey",
"e.ctrlKey",
"!e.altKey",
"!e.metaKey",
"!e.repeat",
"!e.isComposing"
]
*/
复制代码
第三步:补全常规按键
// 按键补全
const routine = { a: 65, b: 66, c: 67,... }
let key
if (rule.length) {
for (let k of rule) {
if (k in routine) {
key = k
break
}
}
}
if (!key) {
throw new Error(`无效的按键组合:${hotkey}`)
}
temp.push(`e.keyCode === ${routine[key]}`)
/* rule: [
"e.shiftKey",
"e.ctrlKey",
"!e.altKey",
"!e.metaKey",
"!e.repeat",
"!e.isComposing"
"e.keyCode === 90"
]
*/
rule = temp
复制代码
第四步:生成校验函数(核心)
// function(e) { return e.shiftKey && e.ctrlKey && !e.altKey && !e.metaKey && !e.repeat && !e.isComposing && e.keyCode === 90 }
const valid = Function(`return function(e) { return ${rule.join(' && ')} }`)()
复制代码
第五步:缓存校验函数
// 缓存池
const cache = new Map()
let row = cache.get(uuid)
if (!row) {
cache.set(uuid, (row = []))
}
row.push(valid)
复制代码
第六步:触发,执行校验函数(验证 KeyboardEvent)
/**
* 验证当前按键是否符合规则
* @param {string} uuid 注册的唯一标识
* @param {KeyboardEvent} event
* @returns {boolean}
*/
function validator(uuid, event) {
if (cache.has(uuid)) {
return cache.get(uuid).reduce((status, valid) => status || valid(event), false)
}
return false
}
复制代码
document.addEventListener('keydown', function (e) {
// 验证快捷键
if (validator('revoke', e)) {
console.log('撤销')
}
if (validator('restore', e)) {
console.log('重做')
}
})
复制代码
思路二:树状缓存池
在完成思路一后,突然有一天我觉得此种方式在触发时,每次都会执行大量无效的校验函数,其中有效的校验函数最多只有一个。而如果我们绑定了很多个快捷键,那是不是意味着我们会执行很多无效函数。这必将是一种资源浪费了。
在这种思忖之下,我总是琢磨着有没有一种方式对 KeyboardEvent
的校验只会执行一次呢?
在这种环境下,某一天我突然想到了“蜂窝数据库”,但是吧,这玩意儿也就是在科幻小说里看过,虽然在我模糊的记忆中它很高效很牛逼,但是咱也不会啊!
然后在不断的思维挣扎下,我把目标放在了我学习过的几种数据结构上,但是哪一种合适呢依然未知。大概两天后的某一刻,我突然觉得 图 这种数据结构貌似能满足要求。
但是我对 图 的了解仅限于理论层面,如何进行节点间的连接难倒了我。而在这个思考的过程中,某一刻我想到 ctrlKey
、shiftKey
、altKey
、metaKey
、repeat
和 isComposing
六个属性是 Boolean
类型的值。也就是说每一个节点都有 true
和 false
两个分支连接到下一个节点,如果继续使用 图 数据结构作为缓存池的话,这完全不可控啊,与初衷背道而驰了。
那就只能对其进行优化了,初步想法是将六个属性按照一定的顺序进行连接,如此一来,貌似 树 更合适。并且如果只看这六个属性的话,形成的 树 还是个 完全二叉树 呢。
如果我们将图转换为 js 对象,那么结构如下:
{
value: 'ctrlKey',
true: {
value: 'shiftKey',
true: {
value: 'altKey',
true: {
value: 'metaKey',
true: {
value: 'repeat',
true: {
value: 'isComposing',
true: {},
false: {},
},
false: {
value: 'isComposing',
true: {},
false: {},
},
},
false: { ... },
},
false: { ... },
},
false: { ... },
},
false: { ... },
}
复制代码
快捷键规则定义
const option = [
{
hotkey: 'ctrl + shift + z',
callback(e) {
console.log('重做')
},
},
]
option.forEach(function ({ hotkey, callback }) {
// 编译规则,缓存回调函数
})
复制代码
第一步:解析按键组合,生成 rule 对象
const SPECIAL = [
['ctrl', 'ctrlKey'],
['alt', 'altKey'],
['shift', 'shiftKey'],
['meta', 'metaKey'],
['repeat', 'repeat'],
['composing', 'isComposing'],
]
/**
* 常规按键的 keyCode 值
*/
const ROUTIN_KEYCODE = { a: 65, b: 66, c: 67,... }
/**
* 检测并删除数组中指定的值
* @param {Array} array 数组
* @param {String} value 需要被检测并删除的值
* @returns {Boolean} 是否存在并被删除
*/
function dispose(array, value) {
const index = array.indexOf(value)
if (index > -1) {
array.splice(index, 1)
return true
}
return false
}
复制代码
/* rule: {
shiftKey: boolean,
ctrlKey: boolean,
altKey: boolean,
metaKey: boolean,
repeat: boolean,
isComposing: boolean,
keyCode: number
}*/
const rule = {
keyCode: -1,
}
// hotkey: 'ctrl + shift + z'
// hotkeys: ['ctrl', 'shift', 'z']
const hotkeys = hotkey
.trim()
.toLowerCase()
.replace(/(?<!(!|&))\+/g, ' ')
.split(/\s+/)
// 处理折叠输入
rule.isComposing = dispose(hotkeys, 'composing')
// 处理功能键 和 长按状态
specialMap.forEach(function ([key, val]) {
rule[val] = dispose(hotkeys, key)
})
// 刨除功能键还有其它键
if (hotkeys.length) {
for (let key of hotkeys) {
if (ROUTIN_KEYCODE[key]) {
rule.keyCode = ROUTIN_KEYCODE[key]
break
}
}
}
复制代码
第二步:生成数据缓存池(核心一)
/**
* 缓存池对象
*/
const cache = {}
/**
* 树层级顺序
*/
const EVENT_KEY = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey', 'repeat', 'isComposing']
// 初始化缓存池
EVENT_KEY.reduce(function (lastStack, value) {
// 收集下一层的节点对象,用于传递到下一层
const stack = []
// 遍历当前层的节点对象
let temp = lastStack.shift()
while (temp) {
temp.value = value
stack.push((temp.true = {}))
stack.push((temp.false = {}))
temp = lastStack.shift()
}
return stack
}, [cache])
复制代码
第三步:根据 rule 规则缓存回调函数
// EVENT_KEY: ['ctrlKey', 'shiftKey', 'altKey', 'metaKey', 'repeat', 'isComposing']
// parent: { 90: [], 71: [],... }
const parent = EVENT_KEY.reduce((parent, key) => parent[rule[key]], cache)
if (!parent[rule.keyCode]) {
parent[rule.keyCode] = []
}
parent[rule.keyCode].push(callback)
复制代码
第四步:触发,执行校验函数(核心二)
document.addEventListener('keydown', function (event) {
const parent = EVENT_KEY.reduce((parent, key) => parent[event[key]], cache)
if (parent[rule.keyCode]) {
parent[rule.keyCode].forEach((fn) => fn.call(this, event))
}
})
复制代码
思路三:仿二进制(优化思路二)
从思路二的树状缓存池是一个完整完全二叉树,但是根据我们平常的使用习惯来说,这个二叉树中的大部分分支都没有使用到,如此是否会造成内存泄漏呢?
如果我们能将这个树状结果拍平成一个一维对象,那不就可以能避开这个问题了吗!那如何拍平呢?其实很简单,我们只需要将由树根到树梢的这个路径压缩成一个字段不就可以了,且这个字段具有唯一性,可以直接作为对象的键使用。
从思路二中我们得知 ctrlKey
、shiftKey
、altKey
、metaKey
、repeat
和 isComposing
六个属性的值是 boolean
类型,即:true
和 false
。这让我直接想到了 0
和 1
,天然契合啊,还能高效的表达某一条路径。
快捷键规则定义
这一步同思路二完全一样,有印象的可以直接略过
const option = [
{
hotkey: 'ctrl + shift + z',
callback(e) {
console.log('重做')
},
},
]
option.forEach(function ({ hotkey, callback }) {
// 编译规则,缓存回调函数
})
复制代码
第一步:解析按键组合,生成 rule 对象
这一步同思路二完全一样,有印象的可以直接略过
const SPECIAL = [
['ctrl', 'ctrlKey'],
['alt', 'altKey'],
['shift', 'shiftKey'],
['meta', 'metaKey'],
['repeat', 'repeat'],
['composing', 'isComposing'],
]
/**
* 常规按键的 keyCode 值
*/
const ROUTIN_KEYCODE = { a: 65, b: 66, c: 67,... }
/**
* 检测并删除数组中指定的值
* @param {Array} array 数组
* @param {String} value 需要被检测并删除的值
* @returns {Boolean} 是否存在并被删除
*/
function dispose(array, value) {
const index = array.indexOf(value)
if (index > -1) {
array.splice(index, 1)
return true
}
return false
}
复制代码
/* rule: {
shiftKey: boolean,
ctrlKey: boolean,
altKey: boolean,
metaKey: boolean,
repeat: boolean,
isComposing: boolean,
keyCode: number
}*/
const rule = {
keyCode: -1,
}
// hotkey: 'ctrl + shift + z'
// hotkeys: ['ctrl', 'shift', 'z']
const hotkeys = hotkey
.trim()
.toLowerCase()
.replace(/(?<!(!|&))\+/g, ' ')
.split(/\s+/)
// 处理折叠输入
rule.isComposing = dispose(hotkeys, 'composing')
// 处理功能键 和 长按状态
specialMap.forEach(function ([key, val]) {
rule[val] = dispose(hotkeys, key)
})
// 刨除功能键还有其它键
if (hotkeys.length) {
for (let key of hotkeys) {
if (ROUTIN_KEYCODE[key]) {
rule.keyCode = ROUTIN_KEYCODE[key]
break
}
}
}
复制代码
第二步:将 rule 对象转换为仿二进制的字符串
ruleToBinary
函数是思路三的核心,在后续中的触发快捷键中还将用到
/**
* 将 rule 对象转换成二进制格式的排列顺序
* @type {string[]}
*/
const RULE_SORT = ['shiftKey', 'ctrlKey', 'altKey', 'metaKey', 'repeat', 'isComposing']
/**
* 将规则对象转换为仿二进制字符串
* @param {KeyboardEvent|{shiftKey: boolean, ctrlKey: boolean, altKey: boolean, metaKey: boolean, repeat: boolean, isComposing: boolean, keyCode: number}} rule 规则对象
* @returns {string}
*/
function ruleToBinary(rule) {
return `${RULE_SORT.map((key) => (rule[key] ? 1 : 0)).join('')}-${rule.keyCode}`
}
复制代码
const binary = ruleToBinary(rule)
复制代码
第三步:缓存回调函数
const cache = {}
if (!cache[binary]) {
cache[binary] = []
}
cache[binary].push(callback)
复制代码
第四步:触发,执行校验函数
document.addEventListener('keydown', function (event) {
const binary = ruleToBinary(rule)
if (!cache[binary]) {
cache[binary].forEach((fn) => fn.call(this, event))
}
})
复制代码
总结
思路二虽然是为了优化思路一,但是在实际的开发中,思路二的执行时间超过了思路一,且它的占据的内存也比思路一大(内存的参考意义不大,思路一并没有缓存快捷键回调函数,二思路二却对其进行了缓存)。
思路三虽然降低了执行时间和内存占比,但是它的执行时间仍超过思路一的执行时间。
我感觉这次优化有那么点逆向优化的感觉。哎,瞎折腾!