事件处理机制

张玥 2026年9月20日 阅读时间 40分钟
JavaScript 事件机制 DOM 事件委托 性能优化
前端开发技巧之事件处理机制

如果把 DOM 比作一栋大楼,那么事件就是这栋楼里的神经系统。没有事件,网页只是一张会滚动的图片;有了事件,它才能对用户做出反应。但“会用 addEventListener”和“真正理解事件”之间,隔着一条很宽的河:为什么点击一个按钮,它的父元素也会收到通知?为什么 this 在回调里不是你期待的那个值?为什么给 1000 个列表项各绑一个监听器会让页面卡顿?为什么滚动事件加了 passive: true 就能变流畅?stopPropagation 和 preventDefault 到底谁管谁?target 和 currentTarget 什么时候不一样?once、capture、signal 这些选项各自解决什么问题?本篇 40 分钟长文,从事件流的三次“旅行”讲起,逐一拆解事件对象、绑定方式、事件委托、阻止行为、常见事件类型、自定义事件、性能优化与无障碍实践,并配有大量可以直接动手操作的交互演示。读完它,你对事件的理解会从“记住 API”变成“预判每一次点击”。

一、事件到底是什么:一次“通知”的完整旅程

在浏览器里,事件(Event)是发生在某个对象上的一次“值得关注的事情”——用户点了按钮、键盘被按下、图片加载完成、网络请求失败、一段动画播放结束。而事件处理机制要解决的,是这样一个问题:

当这件事发生时,谁应该知道?以什么顺序知道?知道之后能做些什么?

浏览器给出的答案由三部分组成:

事件目标 事件最初发生在哪个元素上,也就是 event.target
事件监听器 通过 addEventListener 注册的函数,声明“当某类事件发生时请调用我”
事件对象 浏览器构造的一个包含本次事件全部信息的对象,随回调的第一个参数传入
事件流 事件在 DOM 树中传播的路径,决定了监听器的触发顺序

这四者中,事件流是最容易被忽视、也最容易造成困惑的一环。很多人写了几年 JavaScript,依然说不清楚“为什么点击子元素,父元素的监听器也响了”。这正是我们下一节要拆开的东西。

一个最小的例子

<button id="btn">点我</button>

<script>
  const btn = document.getElementById('btn');

  btn.addEventListener('click', function (event) {
    console.log('被点击的元素:', event.target);
    console.log('绑定监听器的元素:', event.currentTarget);
    console.log('事件类型:', event.type);
  });
</script>

这个例子里的 event 就是浏览器“送”给我们的情报包。它不只是告诉我们“点击发生了”,还携带了坐标、目标元素、时间戳、修饰键状态等几十项信息。会用事件的一半功力,在于知道这个对象里有什么。

事件不是“命令”,而是“广播”

初学者常把事件理解成“点击 → 调用函数”的一对一直连。真实情况更像是广播:事件发生时,浏览器沿着 DOM 树走一圈,把所有愿意“收听”的监听器依次叫醒。一个事件可以触发多个监听器,一个监听器也可以监听多个元素(通过事件委托),而同一个元素上同一类型事件可以注册多个不同的处理函数,它们会按注册顺序依次执行。

// 同一个按钮,三个监听器都会被执行,顺序与注册顺序一致
btn.addEventListener('click', () => console.log('第 1 个'));
btn.addEventListener('click', () => console.log('第 2 个'));
btn.addEventListener('click', () => console.log('第 3 个'));

// 输出:第 1 个 → 第 2 个 → 第 3 个

这一点和 onclick 完全不同:btn.onclick = fn 是覆盖式的,后写的会顶掉先写的。所以在需要多个模块独立监听同一元素时,addEventListener 是唯一正确的选择。

二、事件流:捕获、目标、冒泡的三段旅程

这是整个事件机制中最核心、也最反直觉的部分。请一定亲手玩一下下面的演示——它比任何文字描述都直观。

动手试试:事件在嵌套元素中如何传播

下面有三个层层嵌套的方块。点击最内层的绿色方块,观察右侧日志中事件被触发的完整顺序。

外层 div(捕获监听)
中层 div(捕获监听)
内层 div · 点我
点击最内层的绿色方块,观察事件传播顺序…

三个阶段

① 捕获阶段 事件从 window 出发,沿着 DOM 树一路向下,直到目标元素的父元素。只有 capture: true 的监听器会在此阶段触发。
② 目标阶段 事件抵达 event.target 本身。此时捕获与冒泡监听器都会被触发,按注册顺序执行。
③ 冒泡阶段 事件从目标元素一路向上“冒泡”,经过每一层祖先,直到 window。默认注册的监听器都在这个阶段触发。

绝大多数事件都会冒泡。少数例外不冒泡,例如 focus、blur、mouseenter、mouseleave、load、unload、error。其中 focus / blur 有可冒泡的替身 focusin / focusout,而 mouseenter / mouseleave 有可冒泡的 mouseover / mouseout。

验证一下“捕获先于冒泡”

const outer = document.querySelector('.outer');
const inner = document.querySelector('.inner');

// 第三个参数为 true,表示在捕获阶段监听
outer.addEventListener('click', () => console.log('① 外层 · 捕获'), true);
outer.addEventListener('click', () => console.log('⑥ 外层 · 冒泡'));

inner.addEventListener('click', () => console.log('② 内层 · 捕获'), true);
inner.addEventListener('click', () => console.log('⑤ 内层 · 冒泡'));

// 点击内层元素,输出顺序:
// ① 外层 · 捕获
// ② 内层 · 捕获
// ⑤ 内层 · 冒泡
// ⑥ 外层 · 冒泡

注意:捕获阶段是从外向内,冒泡阶段是从内向外。同一个元素上,如果同时注册了捕获和冒泡监听器,在目标元素自身上,它们按注册顺序执行,而不是“捕获一定先”。这是很多人的知识盲区。

事件流的实际意义

理解事件流带来的第一个直接收益,就是事件委托:既然点击子元素会让父元素也收到通知,那我们完全可以把监听器挂在父元素上,统一处理所有子元素的点击。这一招能把 1000 个监听器压缩成 1 个,是前端性能优化中最经典的技巧之一。

第二个收益是避免“误触发”。比如一个弹窗里有个关闭按钮,点击它时如果不阻止冒泡,事件会一路冒到弹窗容器上,触发“点击弹窗外部关闭”的逻辑,导致弹窗刚被关闭又被重新打开(或者相反的逻辑冲突)。这类 bug 的根源,正是对事件流缺乏预判。

三、事件对象精讲:那个装满情报的包裹

每次事件触发,浏览器都会构造一个事件对象。它的具体类型取决于事件种类(MouseEvent、KeyboardEvent、InputEvent、PointerEvent……),但它们都继承自同一个基类 Event,因此共享一批通用属性。

通用属性速查

属性 / 方法 含义 易错点
type 事件类型字符串,如 "click" 大小写敏感,全小写
target 实际触发事件的元素 可能是后代元素,不一定是绑定者
currentTarget 当前正在执行监听器的元素 事件处理结束后会被置为 null
timeStamp 事件发生的时间(相对页面加载) 不是 Date 对象
isTrusted 是否由真实用户行为触发 脚本 dispatch 的事件为 false
bubbles 该事件是否会冒泡 focus 等为 false
cancelable 能否被 preventDefault 取消 不少事件不可取消
defaultPrevented 是否已被阻止默认行为 只读,用于判断
preventDefault() 阻止浏览器默认行为 不阻止冒泡
stopPropagation() 阻止事件继续传播 不阻止默认行为
stopImmediatePropagation() 阻止传播 + 阻止同元素其余监听器 杀伤力大,慎用

target 与 currentTarget:最容易搞混的一对

把这两个概念记牢,能省下无数调试时间:

  • event.target:“谁被点了”——事件的真正发源地,在整个传播过程中保持不变。
  • event.currentTarget:“谁在听”——当前正在执行这个监听器的元素,会随着事件流的推进而变化。

在一个没有子元素的按钮上,它们恰好相同,所以初学者常常感觉不到区别。但只要按钮内部有 <span> 或图标,点击到图标时 target 就会变成那个 <span>,而 currentTarget 依然是按钮。事件委托的整个逻辑,就建立在这个差异之上。

错误写法
if (event.target === button) { ... }
点击按钮内部的图标时判断失败,因为 target 是图标元素。
正确写法
if (event.target.closest('button') === button) { ... }
或直接使用 event.currentTarget,天然就是绑定者。

动手试试:鼠标事件的坐标系统

鼠标事件对象携带了多套坐标,它们在滚动、缩放、嵌套布局下的表现各不相同。把鼠标移到下面的区域里观察数值变化。

在这里移动鼠标 / 手指
clientX / clientY—
pageX / pageY—
offsetX / offsetY—
screenX / screenY—
clientX/Y 相对视口的坐标,不含页面滚动距离。做拖拽时最常用。
pageX/Y 相对整个文档的坐标,包含滚动距离。pageX = clientX + scrollX。
offsetX/Y 相对事件目标元素内边距盒的坐标,做画板类应用时很方便。
screenX/Y 相对物理屏幕的坐标,多屏环境下可能与视口差异很大。

动手试试:键盘事件对象

在下面的输入框里敲击键盘,观察 key、code 与修饰键状态。注意区分:key 受输入法与键盘布局影响,code 表示物理按键位置。

key code mod
在下方输入框内按键,这里会实时显示事件信息…
属性 说明 典型用途
key 按键产生的字符,如 "a"、"Enter"、"ArrowUp" 判断用户想输入什么
code 物理按键代码,如 "KeyA"、"Space"、"ArrowUp" 游戏键位、快捷键,不受布局影响
keyCode 已废弃的数字编码 不要再在新代码中使用
altKey 是否按住了 Alt 组合快捷键
ctrlKey 是否按住了 Ctrl(Mac 上对应 Control) 组合快捷键
metaKey 是否按住了 Cmd(Mac)/ Win 键 Mac 快捷键判断
shiftKey 是否按住了 Shift 区间选择、大写输入
repeat 是否为长按产生的重复事件 避免长按误触发

四、绑定方式与 addEventListener 的第三个参数

四种绑定方式的历史演进

// ① 内联属性(HTML 属性)—— 不推荐
<button onclick="handleClick()">点我</button>
// 问题:逻辑与结构耦合、CSP 不友好、难以移除、无法传作用域

// ② DOM0 级:onclick 属性 —— 会被覆盖
btn.onclick = function () { ... };
btn.onclick = function () { ... }; // 上面那个被顶掉了
btn.onclick = null; // 移除方式

// ③ DOM2 级:addEventListener —— 推荐
btn.addEventListener('click', handleClick);
btn.removeEventListener('click', handleClick); // 必须传同一个函数引用

// ④ 一次性监听:once 选项
btn.addEventListener('click', handleClick, { once: true });

removeEventListener 必须传同一个函数

这是新手最常见的坑之一:removeEventListener 比较的是函数引用,而不是函数体。下面这段代码是无效的:

// ❌ 移除失败:这是两个不同的函数对象
btn.addEventListener('click', () => console.log('hi'));
btn.removeEventListener('click', () => console.log('hi'));

// ✅ 正确做法:保存引用
const onClick = () => console.log('hi');
btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);

addEventListener 的第三个参数

它可以是布尔值(useCapture),也可以是一个配置对象。现代代码推荐用对象形式:

element.addEventListener('click', handler, {
  capture: false,      // 是否在捕获阶段触发
  once: false,         // 触发一次后自动移除
  passive: false,       // 承诺不调用 preventDefault
  signal: controller.signal // AbortSignal,用于批量移除
});
capture 为 true 时在捕获阶段触发,父元素可以“抢先”处理事件
once 适合“只关心第一次”的场景:首次交互统计、引导提示
passive 告诉浏览器“我不会阻止默认行为”,滚动性能显著提升
signal 传入 AbortSignal,调用 abort() 可一次性移除该信号下所有监听器

动手试试:once 只触发一次

下面这个按钮注册时带了 { once: true }。无论点多少次,计数只会增加到 1。

已触发 0 次

底层实现等价于在回调里手动 removeEventListener,但 once 由浏览器保证,不会因为异常而漏掉清理。

AbortSignal:现代的事件清理方式

在单页应用里,组件卸载时需要移除大量监听器。一个个 removeEventListener 既啰嗦又容易遗漏。AbortController 提供了一个优雅的批量方案:

const controller = new AbortController();
const { signal } = controller;

window.addEventListener('resize', onResize, { signal });
window.addEventListener('scroll', onScroll, { signal });
document.addEventListener('keydown', onKey, { signal });

// 一行代码,三个监听器全部移除,同时中止关联的 fetch
controller.abort();

这个模式还有个额外好处:传入了已经 abort 的 signal,监听器会直接不注册,天然避免了“组件已销毁但异步回调仍在注册事件”的竞态问题。

五、事件委托:一个监听器管住一百个元素

事件委托(Event Delegation)建立在冒泡机制之上。核心思想是:不给每个子元素绑监听器,而是把监听器绑在共同的祖先上,利用冒泡统一处理,再用 event.target 判断到底是谁被点了。

没有委托时的问题

// 1000 个列表项,1000 个监听器,1000 份内存
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// 而且:动态新增的项根本没有监听器,必须重新绑定

动手试试:事件委托 + 动态添加

下面这个列表只有一个监听器(挂在 ul 上)。点击“添加一项”生成的新元素,不需要重新绑定就能响应点击。

商品列表(委托监听)
  • 机械键盘 ¥399
  • 人体工学椅 ¥1299
  • 显示器支架 ¥259
点击任意一项,或者先添加几项再点击…

事件委托的标准写法

const list = document.querySelector('#delegateList');

list.addEventListener('click', function (event) {
  // closest 从 target 向上查找最近的匹配祖先(含自身)
  const item = event.target.closest('li');

  // 关键防护:确保匹配到的元素确实在当前容器内
  if (!item || !list.contains(item)) return;

  console.log('点击了:', item.dataset.name);
});

那个 list.contains(item) 的检查看起来多余,其实非常必要:如果 li 内部还嵌套了别的 li(比如多级菜单),closest 可能匹配到容器外的元素,导致逻辑错乱。

事件委托的三大收益

内存 1000 个监听器压缩成 1 个,内存占用与初始化时间大幅下降
动态元素 新插入的 DOM 天然享有监听,无需手动绑定,杜绝遗漏
维护性 逻辑集中在一处,不用在创建 / 销毁时成对管理监听器

什么时候不该用委托

  • 不冒泡的事件:focus、blur 等无法通过冒泡委托,需改用 focusin / focusout,或使用捕获阶段监听。
  • 需要阻止冒泡的场景:如果子元素内部的监听器会调用 stopPropagation,委托就会失效。第三方组件的内部实现你无法控制,这时应谨慎。
  • 对性能极度敏感的路径:mousemove、scroll 这类高频事件,在委托里反复执行 closest 反而有额外开销,不如直接在目标上处理。
  • 层级过深:如果祖先链很长,closest 每次都要向上遍历,成本不容忽视。

六、阻止传播与阻止默认行为:两件完全不同的事

这一组方法被混淆的概率,大概和 target / currentTarget 不相上下。

方法 作用对象 效果 典型场景
preventDefault() 浏览器默认行为 阻止跳转、提交、勾选、右键菜单等 自定义表单校验、右键菜单
stopPropagation() 事件传播 阻止继续冒泡或捕获,同元素其他监听器仍执行 弹窗内部点击不穿透
stopImmediatePropagation() 事件传播 + 同元素监听器 连当前元素上后续的监听器也不执行 需要完全接管某个事件时

动手试试:preventDefault 的效果

下面是一个指向页面内锚点的链接。勾选复选框后,点击将不再产生任何跳转。

这是一个链接,点击试试

点击链接,观察默认行为是否被阻止…
link.addEventListener('click', function (event) {
  if (shouldBlock) {
    event.preventDefault(); // 阻止跳转,但事件仍会冒泡
    console.log('跳转被拦截');
  }
});

三个高频真实场景

场景一:阻止表单提交。表单里的 <button> 不写 type 时默认是 submit,点击会触发表单提交并刷新页面。做前端校验时:

form.addEventListener('submit', function (event) {
  if (!form.checkValidity()) {
    event.preventDefault(); // 阻止提交与页面刷新
    return;
  }
  // 校验通过,走自己的异步提交逻辑
});

场景二:自定义右键菜单。监听 contextmenu 并调用 preventDefault(),就能屏蔽浏览器原生菜单,弹出自己的面板。记得同时处理键盘上的菜单键(Shift + F10),否则键盘用户无法访问。

场景三:自定义拖拽。拖拽场景中常用 preventDefault() 阻止浏览器默认的文本选择与图片拖影,同时用 stopPropagation() 避免与父级的拖拽逻辑冲突。

return false 的坑

在 jQuery 时代,处理函数里写 return false 等同于同时调用 preventDefault() 和 stopPropagation()。但在原生 addEventListener 中,return false 什么也不做——它只是让函数返回了 false,浏览器的默认行为和事件传播完全不受影响。这是一个从 jQuery 迁移到原生时极容易踩的坑。

无效写法
link.addEventListener('click', e => { return false; });
链接照样跳转,事件照样冒泡。
正确写法
link.addEventListener('click', e => { e.preventDefault(); });
明确表达意图,不受框架影响。

七、常见事件类型全景

浏览器的事件类型有上百种。下面这张分卡片清单覆盖了日常开发中 95% 的场景,值得收藏备查。

鼠标事件
click / dblclick
mousedown / mouseup
mousemove
mouseover / mouseout
mouseenter / mouseleave
contextmenu / auxclick
指针与触摸事件
pointerdown / pointermove / pointerup
pointercancel
pointerover / pointerout
touchstart / touchmove / touchend
touchcancel
键盘事件
keydown / keyup
keypress(已废弃)
input / beforeinput
compositionstart / compositionend
表单与焦点事件
focus / blur
focusin / focusout
change / input
submit / reset
invalid / select
文档与窗口事件
DOMContentLoaded
load / beforeunload / unload
resize / scroll
visibilitychange
pagehide / pageshow
资源与网络事件
error / abort
online / offline
message
readystatechange
progress / timeout
剪贴板与拖放
copy / cut / paste
dragstart / drag / dragend
dragenter / dragover
dragleave / drop
媒体与动画
play / pause / ended
timeupdate / volumechange
loadedmetadata / canplay
animationstart / animationend
transitionstart / transitionend
CSS 与观察者
scrollend
IntersectionObserver
ResizeObserver
MutationObserver
PerformanceObserver

mouseenter 与 mouseover 的区别

这是最常被问到的一组对比。假设有一个父元素,内部嵌套了一个子元素:

  • mouseover:指针进入元素或其子元素时都会触发,会冒泡。从父元素移入子元素时,父元素会再收到一次 mouseover。
  • mouseenter:只在指针进入元素边界时触发一次,不冒泡。在内部子元素之间移动不会重复触发。

做“悬停显示下拉菜单”时,如果用 mouseover,鼠标在子元素间移动会导致菜单反复闪烁;用 mouseenter 就没有这个问题。同理,mouseleave 优于 mouseout。

change 与 input 的区别

事件 触发时机 适用场景
input 每次值变化立即触发(含粘贴、拖入、语音输入) 实时搜索、字数统计、即时校验
change 值变化且元素失去焦点(或 select 选项变化) 提交前的最终确认、表单统计

注意:input 事件在现代浏览器中已经能覆盖 keyup 的所有用途,而且能捕捉到鼠标粘贴、拖拽文本、输入法上屏等 keyup 捕捉不到的情况。做实时响应时,一律优先用 input。

Pointer Events:统一鼠标与触摸

过去要同时支持鼠标和触摸,需要写两套逻辑。Pointer Events 把两者统一成了同一套 API:

element.addEventListener('pointerdown', onDown);
element.addEventListener('pointermove', onMove);
element.addEventListener('pointerup', onUp);

function onDown(event) {
  console.log('指针类型:', event.pointerType); // mouse / pen / touch
  console.log('指针 ID:', event.pointerId);   // 多点触控时区分手指
  console.log('压力值:', event.pressure);   // 支持压感的设备

  // 捕获指针,确保拖拽过程中事件不丢失
  element.setPointerCapture(event.pointerId);
}

setPointerCapture 是拖拽实现的利器:即使指针移出了元素边界,事件依然会派发给这个元素,彻底解决了“鼠标移太快导致拖拽中断”的经典问题。

八、this、箭头函数与处理器绑定

事件处理函数里的 this 是另一个高频困惑点。规则其实很清晰,只是需要记住三种情况。

情况一:普通函数

btn.addEventListener('click', function () {
  console.log(this); // 绑定监听器的元素,即 currentTarget
});

// 等价写法:
btn.addEventListener('click', function (event) {
  console.log(this === event.currentTarget); // true
});

情况二:箭头函数

const widget = {
  name: 'widget',
  init() {
    btn.addEventListener('click', () => {
      console.log(this); // widget 对象,而不是 btn
    });
  }
};

箭头函数没有自己的 this,它会从定义它的外层作用域继承。这条规则既带来了便利(轻松访问组件实例),也带来了陷阱(拿不到被点击的元素)。需要访问元素时,用 event.currentTarget 显式获取即可。

情况三:bind

class Panel {
  constructor() {
    this.count = 0;
    // 绑定后保存引用,既能正确访问 this,又能被移除
    this.handleClick = this.handleClick.bind(this);
    btn.addEventListener('click', this.handleClick);
  }

  handleClick(event) {
    this.count++; // 正确指向实例
    console.log('点击元素:', event.currentTarget);
  }
}

三种方式对比

写法 this 指向 可否移除 适用场景
function(){} 绑定元素 可(需保存引用) 需要操作 DOM 元素本身
() => {} 外层作用域 难 需要访问组件实例 / 闭包变量
fn.bind(this) 绑定的对象 可 类组件、需要显式控制上下文

一个实用的经验法则:如果处理函数里既要用组件状态、又要操作元素,就用普通函数 + 闭包,或者干脆统一用 event.currentTarget 而不依赖 this。后者是最不容易出错的做法。

九、自定义事件:让模块之间开口说话

事件机制不只能监听浏览器内置的行为,你还可以自己定义事件类型,让不同的模块通过事件解耦通信。

动手试试:派发与监听自定义事件

等待事件…
点击按钮,观察自定义事件被派发与接收的过程…

创建与派发

// 1. 创建自定义事件,detail 用于携带数据
const event = new CustomEvent('user:login', {
  detail: { id: 42, name: '张玥' },
  bubbles: true,    // 允许冒泡
  cancelable: true  // 允许被 preventDefault
});

// 2. 在某个元素上派发
document.dispatchEvent(event);

// 3. 在别处监听
document.addEventListener('user:login', function (e) {
  console.log('收到登录事件:', e.detail);
});

为什么事件名要带冒号

自定义事件名推荐使用 模块名:动作名 的形式,例如 cart:add、modal:close、user:logout。原因有两点:

  • 避免冲突:原生事件都是单个单词,带命名空间的名字天然不会与内置事件重名。
  • 自解释:看到 cart:add,立刻知道是购物车模块发出的添加动作,比 add 或 update 清晰得多。

dispatchEvent 的同步特性

这一点常被忽略:dispatchEvent() 是同步执行的,它会立即走完整个捕获与冒泡流程,然后才返回。它的返回值表示事件是否没有被取消(即没有监听器调用 preventDefault)。

const evt = new CustomEvent('before:save', { cancelable: true });
const notCancelled = element.dispatchEvent(evt);

if (!notCancelled) {
  console.log('有监听器阻止了保存流程');
}

// 监听端可以这样“否决”:
element.addEventListener('before:save', e => {
  if (hasUnsavedChanges) e.preventDefault();
});

这个模式让自定义事件具备了“可拦截”的能力,非常适合做插件系统、表单前置校验、路由守卫这类需要多方参与决策的场景。

EventTarget:不依赖 DOM 的事件总线

EventTarget 是一个独立于 DOM 的类,你可以直接实例化它,得到一个轻量级的事件总线:

const bus = new EventTarget();

bus.addEventListener('toast', e => {
  showToast(e.detail.message);
});

// 任意模块都可以发布消息,无需知道谁在监听
bus.dispatchEvent(new CustomEvent('toast', {
  detail: { message: '保存成功' }
}));

比起自己手写发布订阅模式,EventTarget 免费提供了 once、signal、capture 等能力,而且没有 DOM 依赖,在 Node.js 环境中也能运行。

十、事件性能:passive、防抖与节流

事件处理是前端性能问题的高发区。原因很简单:用户的手指和鼠标每秒可以产生几十上百个事件,而每个事件都会触发 JavaScript 执行,进而可能触发样式计算、布局和重绘。一旦处理函数里有重活,帧率立刻崩塌。

passive:让滚动不再等待

浏览器过去无法预知 touchstart、touchmove、wheel 的监听器里会不会调用 preventDefault()。为了安全起见,它必须先执行完 JavaScript,再决定要不要滚动页面。这个“等待”就是移动端滚动卡顿的头号元凶。

// ❌ 浏览器无法确定是否阻止默认行为,必须等 JS 执行完
document.addEventListener('touchmove', onTouchMove);

// ✅ 明确承诺不阻止,浏览器可以立即滚动,JS 并行执行
document.addEventListener('touchmove', onTouchMove, { passive: true });
document.addEventListener('wheel', onWheel, { passive: true });

在 Chrome 51+ 中,对 window、document、body 上的 touchstart / touchmove 监听,passive 默认为 true。这意味着如果你的代码依赖在这些事件里调用 preventDefault() 来阻止滚动,可能会突然失效——控制台会给出明确警告。遇到这种情况,正确的做法是显式写出 { passive: false },而不是抱怨浏览器。

动手试试:防抖与节流的效果差异

下面模拟 1.5 秒内连续触发 30 次事件(相当于用户快速滚动或高频输入)。点击按钮,观察三种策略下处理函数实际执行的次数。

等待开始…
0
原始触发次数
0
防抖(300ms)执行次数
0
节流(300ms)执行次数
点击上方按钮开始模拟…

两种策略的本质区别

防抖 Debounce
等用户停下来再执行。每次事件触发都重置计时器,只有连续 300ms 没有新事件时才真正执行一次。

适合:搜索输入联想、窗口 resize 后重排、表单自动保存。
特征:只在“最后”执行一次。
节流 Throttle
固定频率执行。不管事件触发多密集,每 300ms 最多执行一次。

适合:滚动加载、鼠标跟随、拖拽时的位置更新。
特征:在过程中“均匀”执行多次。
// 防抖:等待安静下来
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 节流:按固定节奏执行
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener('scroll', throttle(onScroll, 200), { passive: true });

其他的性能要点

  • 用 CSS 代替 JS:悬停效果用 :hover、动画用 transition / animation,比 JS 逐帧修改样式高效得多。
  • 用 IntersectionObserver 代替 scroll 监听:判断元素是否进入视口时,监听 scroll 再逐个 getBoundingClientRect() 会造成大量强制同步布局。IntersectionObserver 由浏览器在合适的时机异步回调,性能好一个数量级。
  • 读写分离:不要在同一个循环里交替读取布局属性(offsetWidth)和写入样式,这会触发“布局抖动”(Layout Thrashing)。先批量读,再批量写。
  • 及时移除监听器:组件销毁、元素从 DOM 中移除后,监听器若仍被引用会造成内存泄漏。用 AbortSignal 或成对的 removeEventListener 处理。
  • 不要在事件里做重活:把复杂的计算交给 requestIdleCallback、Web Worker,或者用 requestAnimationFrame 合并到下一帧。

一个容易忽视的泄漏点

// ❌ 每次打开弹窗都注册一次,弹窗关闭后监听器仍然存在
function openModal() {
  window.addEventListener('resize', repositionModal);
}

// ✅ 关闭时同步移除
function closeModal() {
  window.removeEventListener('resize', repositionModal);
}

// ✅ 更好的方式:用 AbortSignal 一次性管理
const ac = new AbortController();
window.addEventListener('resize', repositionModal, { signal: ac.signal });
ac.abort(); // 关闭时一行搞定

十一、事件与无障碍:键盘用户也在这个页面上

事件处理与无障碍的交集,比大多数人想象的要大得多。核心问题只有一个:你用鼠标能完成的操作,键盘用户能不能完成?

能不用 div 就不用 div

反例
<div class="btn" onclick="submit()">提交</div>
键盘无法聚焦、无法用 Enter 触发、屏幕阅读器读不出这是按钮。
正解
<button type="button">提交</button>
焦点、键盘、语义、禁用状态全部免费获得。

如果确实必须用 div 模拟按钮,就必须补齐一整套:tabindex="0"、role="button"、keydown 里处理 Enter 和 Space、Space 时要阻止页面滚动、鼠标点击时聚焦、禁用状态用 aria-disabled 而不是 disabled 属性……写下来就知道,为什么原生元素是更优解。

键盘可访问的四个要点

  • 所有交互元素可 Tab 聚焦:a、button、input、select、textarea 天然可聚焦。自定义组件需要手动加 tabindex="0"。
  • 焦点顺序符合阅读顺序:不要用正数 tabindex(如 tabindex="3")打乱顺序,浏览器按 DOM 顺序聚焦最符合直觉。
  • 焦点必须可见:永远不要写裸的 outline: none。如果觉得默认焦点环不好看,就替换成更符合设计的样式,而不是删掉它。
  • 键盘事件不要只监听 keypress:keypress 已经被废弃,而且不触发功能键。统一用 keydown。
/* 保留焦点可见性的正确做法 */
.custom-button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

/* :focus-visible 只在键盘聚焦时生效,鼠标点击不显示焦点环 */

pointer 事件与触摸目标尺寸

移动端的事件处理还有一个物理约束:手指的触摸精度远低于鼠标。WCAG 2.2 建议可点击目标至少为 24×24 CSS 像素,实际体验中 44×44 更稳妥。如果设计稿上的按钮很小,可以用伪元素扩大它的可点击区域,而不必改变视觉尺寸:

.icon-button {
  position: relative;
  width: 24px;
  height: 24px;
}

/* 视觉上仍是 24px,但可点击区域扩大到 44px */
.icon-button::after {
  content: '';
  position: absolute;
  inset: -10px;
}

拖动操作的键盘替身

拖拽(drag)是最容易忽略无障碍的交互。屏幕阅读器用户和键盘用户无法“按住并移动”,必须提供替代方案:通常是给每个可拖拽项目加一个“上移 / 下移”按钮,或者在选中项目后允许用方向键移动。这是拖拽排序组件能否通过无障碍审计的分水岭。

十二、十个最常见的事件处理误区

误区 1 以为 stopPropagation() 能阻止浏览器默认行为(它不能,那是 preventDefault() 的事)
误区 2 在原生 addEventListener 里写 return false 并期待它起作用
误区 3 用 removeEventListener 移除匿名函数或箭头函数
误区 4 认为 event.target 永远等于绑定监听器的元素
误区 5 给 focus / blur 做事件委托(它们不冒泡)
误区 6 在 scroll 事件里同步读取 offsetTop 等布局属性,造成布局抖动
误区 7 忘记给触摸 / 滚轮监听加 passive: true,导致移动端滚动卡顿
误区 8 用 onclick 给同一个元素绑定多个监听器,结果被静默覆盖
误区 9 在组件卸载时忘记移除 window 上的监听器,造成内存泄漏
误区 10 用 div + onclick 模拟按钮,导致键盘用户完全无法使用

两个需要展开说的点

关于 stopPropagation 的滥用:很多开发者在遇到“点击穿透”问题时,第一反应是无脑 stopPropagation()。这会带来两个副作用:一是让父级的事件委托失效,二是让埋点统计、全局的“点击空白关闭”等横切逻辑收不到事件。更克制的做法是在父级监听器里判断 event.target 是否在某个范围内,把“要不要处理”的判断权交给接收方,而不是发送方强行切断传播。

关于监听器泄漏:现代浏览器对“元素与监听器同生共死”的情况处理得很好——当元素从 DOM 中移除且没有任何引用时,它上面的监听器会一起被回收。真正会泄漏的是两种情况:一是监听在 window、document、body 等长生命周期对象上;二是处理函数闭包引用了大对象。前者用 AbortSignal 解决,后者需要审视闭包里到底捕获了什么。

十三、事件处理检查清单与代码基线

把全文的结论浓缩成一份可以在提交代码前扫一眼的清单。

  • 用 addEventListener,不用 onclick 属性,也不在 HTML 里写内联事件。
  • 移除监听器时传同一个函数引用,或使用 AbortSignal 批量管理。
  • 组件卸载 / 元素移除时清理监听器,尤其是挂在 window、document 上的那些。
  • 列表、表格、菜单类元素优先用事件委托,并用 closest + contains 双重校验。
  • 区分 target 与 currentTarget,需要“谁在听”时用后者。
  • 区分 preventDefault 与 stopPropagation,不要用后者解决前者的问题。
  • 不滥用 stopPropagation,给全局统计和父级委托留出空间。
  • 触摸、滚轮监听加 passive: true,除非确实需要阻止默认行为。
  • 高频事件做防抖或节流,滚动、输入、resize、mousemove 都不例外。
  • 视口判断用 IntersectionObserver,不要用 scroll + getBoundingClientRect。
  • 键盘快捷键同时检查 ctrlKey / metaKey,兼容 Mac 与 Windows。
  • 用 key 判断输入、用 code 判断物理键位,不用废弃的 keyCode。
  • 交互元素用原生标签,用 div 时补齐 tabindex、role 与键盘事件。
  • 保留可见的焦点样式,优先用 :focus-visible。
  • 自定义事件命名带命名空间,如 cart:add。
  • 需要多方决策时使用 cancelable 自定义事件,用返回值判断是否被否决。
  • 拖动类交互提供键盘替代方案。
  • 只用键盘走一遍页面,确认所有交互都能完成。

一份可直接复用的事件处理基线代码

/* ---------- 1. 委托:一个监听器管住所有列表项 ---------- */
const list = document.querySelector('#list');
list.addEventListener('click', function (event) {
  const item = event.target.closest('[data-id]');
  if (!item || !list.contains(item)) return;
  selectItem(item.dataset.id);
});

/* ---------- 2. 高频事件:节流 + passive ---------- */
window.addEventListener('scroll', throttle(onScroll, 200), { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: true });

/* ---------- 3. 生命周期:AbortSignal 统一清理 ---------- */
const ac = new AbortController();
const { signal } = ac;

window.addEventListener('resize', onResize, { signal });
window.addEventListener('keydown', onKeyDown, { signal });
document.addEventListener('visibilitychange', onVisibility, { signal });

// 组件销毁时:
// ac.abort();

/* ---------- 4. 自定义事件:可拦截的前置钩子 ---------- */
function beforeSave() {
  const evt = new CustomEvent('form:before-save', {
    detail: { form },
    cancelable: true,
    bubbles: true
  });

  const ok = form.dispatchEvent(evt);
  if (!ok) return; // 被某个监听器否决了

  doSave(form);
}

事件机制最迷人的地方在于,它是一条贯穿前端所有层次的线索:往上看,它是框架响应式系统的底层依赖;往下看,它是浏览器渲染性能的关键路径;往旁边看,它是无障碍与用户体验的基石。把事件真正吃透,你写的代码会同时变得更短、更快、更稳——因为你知道每一次点击之后,浏览器到底做了什么。

所以下次当你在回调里写下 console.log(event) 的时候,不妨展开那个对象多看两眼。那里面藏着的,是整个 Web 交互模型的缩影。