2. 事件类型与分类:鼠标事件、键盘事件、触控事件、自定义事件

事件响应机制的核心,说白了就是「谁触发了什么,然后怎么办」。但触发的方式千差万别——有人用鼠标点,有人按键盘,有人在平板上划拉,甚至还有程序自己发出的信号。我刚开始做动画框架时,总觉得把事件笼统处理就行,结果在触控设备上踩了大坑。嗯,今天咱们就把这四类事件掰开揉碎讲清楚。

2.1 鼠标事件:最经典的交互方式

鼠标事件是 GUI 交互的元老。从桌面应用走到 Web,再到游戏引擎,它的模型基本没变过。我个人习惯把鼠标事件分成三组:

  • 点击类:mousedown、mouseup、click、dblclick
  • 移动类:mousemove、mouseenter、mouseleave、mouseover、mouseout
  • 滚轮类:wheel(旧版是 mousewheel)

这里有个容易混淆的点:click 其实是 mousedown + mouseup 的组合事件。如果你在 mousedown 里做了拖拽逻辑,又在 click 里做了选中逻辑,两者可能会打架。我曾经在画板项目里就因为这个,拖拽完线条后总会多选一个对象——后来把状态机加进去才解决。

关键属性:clientX/clientY(视口坐标)、pageX/pageY(页面坐标)、button(0=左键,1=中键,2=右键)、buttons(位掩码,支持多键同时按下)。

在动画循环里监听 mousemove 时,记得用 requestAnimationFrame 节流。否则每秒触发上百次回调,你的帧率会直接崩掉。我一般这样写:

let lastMove = 0;
element.addEventListener('mousemove', (e) => {
  if (Date.now() - lastMove < 16) return; // 约60fps
  lastMove = Date.now();
  // 处理逻辑
});

2.2 键盘事件:精准的离散输入

键盘事件适合做「按下即触发」的操作,比如游戏里的跳跃、射击,或者快捷键。它只有三个核心事件:

事件 触发时机 典型用途
keydown 按键按下瞬间 开始移动、技能施放
keypress 字符按键按下(已废弃,慎用) 文本输入检测
keyup 按键抬起瞬间 停止移动、取消技能

你想想看,为什么游戏里长按方向键角色会持续移动?其实就是在 keydown 里设置一个标志位,keyup 里清除它,然后在动画循环里根据标志位更新位置。千万别在 keydown 里直接做位置累加——那样按一次只动一下,根本没法流畅移动。

避坑指南:我曾经在做一个打字游戏时,发现某些浏览器对 keydown 的重复触发行为不一致。按住一个键不放,有的浏览器会持续触发 keydown,有的只触发一次。解决方案是:自己维护一个按键状态表,在 keydown 时置 true,keyup 时置 false,完全不管浏览器的重复策略。

键盘事件的关键属性是 key(返回字符,如 "a"、"Enter")和 code(返回物理按键位置,如 "KeyA"、"ArrowLeft")。我个人建议:做游戏用 code,做文本输入用 key。为什么?因为 code 不受键盘布局影响——你按 Q 键,不管键盘上是 Q 还是 A,code 永远是 "KeyQ"。

2.3 触控事件:移动端的核心挑战

触控和鼠标最大的区别是什么?触控支持多点,而且没有「悬停」状态。这就导致很多桌面端的交互逻辑直接搬过来会出问题。触控事件主要有:

  • touchstart:手指按下
  • touchmove:手指滑动
  • touchend:手指抬起
  • touchcancel:系统中断(如来电、弹窗)

每个触控事件都带有一个 touches 数组,里面包含每个触摸点的信息。我记得第一次做双指缩放时,以为只要监听 touchmove 然后算两个点的距离变化就行。结果发现 touches 里保存的是当前所有触摸点,而 changedTouches 才是本次事件中变化的触摸点。这两个搞混,你的多点交互就会各种抽风。

核心区别:

  • touches:当前屏幕上所有触摸点
  • targetTouches:当前元素上的触摸点
  • changedTouches:本次事件中状态变化的触摸点(抬起、移动、按下)

另外,触控事件默认会触发鼠标事件的模拟。也就是说,你在移动端点一下,touchstart → touchend → mousedown → mouseup → click 会依次触发。如果你同时监听了触控和鼠标事件,记得在触控事件里调用 preventDefault() 阻止冒泡,否则同一个操作会触发两次逻辑。

2.4 自定义事件:让系统听懂你的语言

内置事件再丰富,也总有覆盖不到的场景。比如「动画播放完毕」、「数据加载完成」、「角色血量低于 20%」——这些业务语义的事件,就需要我们自己造。

自定义事件的实现方式,不同平台略有差异,但核心思路一致:

平台 创建方式 触发方式
浏览器 DOM new CustomEvent('myEvent', { detail: {} }) element.dispatchEvent(event)
Node.js EventEmitter const myEmitter = new EventEmitter() myEmitter.emit('myEvent', data)
Python asyncio asyncio.Event() event.set()

我建议在动画框架里,把自定义事件作为「解耦」的核心手段。比如一个粒子系统,粒子爆炸后需要通知场景管理器移除粒子、播放音效、更新分数。如果把这些逻辑写死在粒子类里,代码会变成一团乱麻。用自定义事件:粒子爆炸时触发 'particle-explode' 事件,其他模块各自监听处理,互不干扰。

自定义事件命名最好带命名空间,比如 'player:jump'、'ui:panel-open'。这样在调试时一眼就能看出事件来源。我见过有人用 'event1'、'event2' 命名,三个月后自己都看不懂。

2.5 事件分类全景图

下面这张图把四类事件的关系和典型应用场景串起来了。你可以把它当作一个快速参考:

事件类型与分类全景图 事件类型与分类全景图 事件系统 🖱 鼠标事件 ⌨ 键盘事件 📱 触控事件 ⚙ 自定义事件 子事件 • click / dblclick • mousedown / mouseup • mousemove / wheel • mouseenter / mouseleave 子事件 • keydown / keyup • keypress(已废弃) 子事件 • touchstart / touchend • touchmove • touchcancel 子事件 • 业务语义事件 • 框架内部事件 💡 核心要点 • 鼠标事件适合精确点击和悬停交互 • 键盘事件适合离散触发和快捷键 • 触控事件必须处理多点触摸和取消场景 • 自定义事件是解耦和扩展的利器

2.6 如何选择合适的事件类型

很多新手会问:「我该用鼠标事件还是触控事件?」我的回答是:看你的目标设备。如果只做桌面端,鼠标事件就够了。如果要做移动端,必须同时处理触控事件。如果要做跨平台,建议抽象一层事件适配器,把底层事件统一成业务事件。

举个例子,一个「点击」操作:

  • 桌面端:监听 click
  • 移动端:监听 touchend,并判断触摸时间 < 300ms 且移动距离 < 10px
  • 统一接口:onTap 事件,内部自动适配

这样做的好处是,业务代码永远只关心 onTap,不用管底层是鼠标还是手指。我在做跨端动画引擎时就是这么干的,后期维护成本降低了一大半。

注意:不要试图用鼠标事件模拟触控手势(如双指缩放)。触控事件的 touches 数组天然支持多点,而鼠标事件一次只能有一个坐标点。强行模拟只会让你的代码变得又臭又难维护。

好了,事件分类就讲到这里。记住一个原则:用对事件类型,比写好事件处理逻辑更重要。选错了类型,后面再怎么优化都是白搭。