2、事件类型系统:定义事件类型与冒泡机制
事件类型系统,说白了就是给 UI 上发生的各种交互行为「上户口」。鼠标点了、键盘按了、手指划了,甚至业务层自己发出的信号,都得有个明确的身份和规矩。我做了这么多年 UI 框架,发现很多人栽就栽在「事件类型一团乱麻」上——回调满天飞,优先级全靠猜,冒泡路径走到哪算哪。
这一章,咱们就把这件事彻底理清楚。
2.1 四大事件类型
我把事件分成四类:鼠标、键盘、触摸、自定义。前三个是硬件输入,最后一个是业务逻辑的「软事件」。
2.1.1 鼠标事件
鼠标事件是最经典的。click、dblclick、mousedown、mouseup、mousemove、mouseenter、mouseleave…… 你肯定都用过。但有个坑,我当年在做一个拖拽组件时踩过——mousemove 的触发频率极高,每秒能跑几十次。如果你在回调里做 DOM 重排或发请求,页面直接卡成 PPT。
另外,mouseenter 和 mouseover 的区别,很多人搞混。前者不冒泡,后者冒泡。如果你用 mouseover 做 hover 效果,子元素进出时会反复触发父级的回调——嗯,我见过一个菜单因此疯狂闪烁。
2.1.2 键盘事件
键盘事件有三个:keydown、keypress、keyup。注意,keypress 已经 deprecated 了,别再用。现在统一用 keydown 和 keyup,配合 event.key 和 event.code 来判断按键。
我个人习惯用 event.code 做物理按键判断(比如 ArrowUp),用 event.key 做字符判断(比如 a 还是 A)。为什么?因为 event.key 会受输入法影响,而 event.code 不会。曾经有个用户反馈说按「W」键角色不走,查了半天——他开了中文输入法,event.key 返回的是「w」的拼音组合。
event.preventDefault() 可以阻止默认行为,但要注意——某些浏览器对 keydown 的 preventDefault 有限制,比如 F12 开发者工具键。别问我怎么知道的。
2.1.3 触摸事件
移动端的主力。touchstart、touchmove、touchend、touchcancel。触摸事件和鼠标事件最大的区别是:多点触控。一个 touch 对象代表一个手指,event.touches 是当前所有手指的列表。
我建议你在触摸事件里永远不要依赖 event.target 做精确命中——手指的接触面积比鼠标大得多,用户可能「点」在按钮边缘,但实际命中的是旁边的元素。用 document.elementFromPoint() 配合触摸坐标会更靠谱。
还有个细节:触摸事件触发顺序是 touchstart → touchend → click。如果你在 touchstart 里做了 preventDefault,后面的 click 就不会触发。这在做「滑动翻页」时很有用——避免滑动结束后还触发点击。
2.1.4 自定义事件
这才是架构设计的重头戏。内置事件只能覆盖「用户操作硬件」,但业务逻辑里经常需要「A 模块通知 B 模块」。比如:购物车数量变了、用户登录状态变了、WebSocket 推送了一条消息。
自定义事件用 new CustomEvent(type, options) 创建,通过 dispatchEvent 派发。options 里可以带 detail 字段传数据。
// 定义一个自定义事件
const event = new CustomEvent('cart:updated', {
bubbles: true,
cancelable: true,
detail: { itemCount: 3 }
});
// 派发
document.getElementById('cart-icon').dispatchEvent(event);
// 监听
document.addEventListener('cart:updated', (e) => {
console.log('购物车数量:', e.detail.itemCount);
});
cart:updated、auth:logout。这样在调试时一眼就能看出事件来源,避免命名冲突。
2.2 事件优先级
事件多了,就得排先后。比如一个按钮上同时绑了 click 和 dblclick,用户双击时两个事件都会触发。你希望先执行哪个?
浏览器原生的事件优先级其实很模糊。我一般自己建一个优先级系统,用数字表示:数字越小优先级越高。
| 优先级 | 值 | 适用场景 |
|---|---|---|
| 最高 | 0 | 紧急中断(如 ESC 取消操作) |
| 高 | 1 | 用户主动交互(点击、按键) |
| 中 | 2 | 系统反馈(动画结束、状态变更) |
| 低 | 3 | 日志、埋点、非关键通知 |
实现时,我会在事件对象上挂一个 priority 字段,然后在事件调度器里按优先级排序执行。注意:高优先级事件可以阻止低优先级事件的执行——比如 ESC 按下时,直接取消所有待处理的低优先级回调。
2.3 冒泡机制
冒泡是事件从目标元素向上传播到 document 的过程。捕获阶段则相反,从 document 往下走。标准的事件流是:捕获 → 目标 → 冒泡。
但实际开发中,90% 的场景只用冒泡。为什么?因为冒泡可以实现「事件委托」——在父元素上监听,通过 event.target 判断具体是哪个子元素触发的。这样你不需要给每个子元素单独绑事件,性能好得多。
// 事件委托:在父容器上监听所有按钮点击
document.getElementById('toolbar').addEventListener('click', (e) => {
if (e.target.matches('button')) {
console.log('点击了按钮:', e.target.dataset.action);
}
});
冒泡可以中途停止。用 event.stopPropagation() 阻止继续向上冒泡,用 event.stopImmediatePropagation() 连当前元素上的其他回调也一并阻止。后者比较暴力,我一般只在「一个事件只能被一个处理器消费」的场景下用。
2.4 整体架构图
下面这张图把事件类型、优先级、冒泡路径串起来了。你仔细看,箭头方向就是事件流动的方向。
2.5 实战建议
最后给几条硬核建议,都是我在项目里摔出来的经验:
- 自定义事件一定要带命名空间。 别用
update、change这种通用名,否则项目大了根本分不清是谁发的。 - 优先级别滥用。 只有真正需要打断用户操作的事件才用 P0,比如「ESC 取消全屏」。日志、统计全部丢到 P3。
- 冒泡不要随便 stop。 你 stop 了,父组件可能就收不到事件了。我见过一个同事在子组件里 stopPropagation,结果父组件的点击统计全废了。
- 触摸事件和鼠标事件共存时,用标志位去重。 比如在 touchstart 里设一个
isTouching = true,在 click 回调里判断这个标志,避免同一操作触发两次。
事件类型系统搭好了,后面的事件捕获、分发、回调管理才能站得住脚。嗯,这一章的内容就到这儿,你先把这些基础打牢,后面咱们再聊更深的调度策略。