如果把 DOM 比作一栋大楼,那么事件就是这栋楼里的神经系统。没有事件,网页只是一张会滚动的图片;有了事件,它才能对用户做出反应。但“会用 addEventListener”和“真正理解事件”之间,隔着一条很宽的河:为什么点击一个按钮,它的父元素也会收到通知?为什么 this 在回调里不是你期待的那个值?为什么给 1000 个列表项各绑一个监听器会让页面卡顿?为什么滚动事件加了 passive: true 就能变流畅?stopPropagation 和 preventDefault 到底谁管谁?target 和 currentTarget 什么时候不一样?once、capture、signal 这些选项各自解决什么问题?本篇 40 分钟长文,从事件流的三次“旅行”讲起,逐一拆解事件对象、绑定方式、事件委托、阻止行为、常见事件类型、自定义事件、性能优化与无障碍实践,并配有大量可以直接动手操作的交互演示。读完它,你对事件的理解会从“记住 API”变成“预判每一次点击”。
一、事件到底是什么:一次“通知”的完整旅程
在浏览器里,事件(Event)是发生在某个对象上的一次“值得关注的事情”——用户点了按钮、键盘被按下、图片加载完成、网络请求失败、一段动画播放结束。而事件处理机制要解决的,是这样一个问题:
当这件事发生时,谁应该知道?以什么顺序知道?知道之后能做些什么?
浏览器给出的答案由三部分组成:
event.target
addEventListener 注册的函数,声明“当某类事件发生时请调用我”
这四者中,事件流是最容易被忽视、也最容易造成困惑的一环。很多人写了几年 JavaScript,依然说不清楚“为什么点击子元素,父元素的监听器也响了”。这正是我们下一节要拆开的东西。
一个最小的例子
<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 是唯一正确的选择。
二、事件流:捕获、目标、冒泡的三段旅程
这是整个事件机制中最核心、也最反直觉的部分。请一定亲手玩一下下面的演示——它比任何文字描述都直观。
动手试试:事件在嵌套元素中如何传播
下面有三个层层嵌套的方块。点击最内层的绿色方块,观察右侧日志中事件被触发的完整顺序。
三个阶段
window 出发,沿着 DOM 树一路向下,直到目标元素的父元素。只有 capture: true 的监听器会在此阶段触发。
event.target 本身。此时捕获与冒泡监听器都会被触发,按注册顺序执行。
window。默认注册的监听器都在这个阶段触发。
绝大多数事件都会冒泡。少数例外不冒泡,例如 focus、blur、mouseenter、mouseleave、load、unload、error。其中 focus / blur 有可冒泡的替身 focusin / focusout,而 mouseenter / mouseleave 有可冒泡的 mouseover / mouseout。
验证一下“捕获先于冒泡”
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,天然就是绑定者。
动手试试:鼠标事件的坐标系统
鼠标事件对象携带了多套坐标,它们在滚动、缩放、嵌套布局下的表现各不相同。把鼠标移到下面的区域里观察数值变化。
pageX = clientX + scrollX。
动手试试:键盘事件对象
在下面的输入框里敲击键盘,观察 key、code 与修饰键状态。注意区分:key 受输入法与键盘布局影响,code 表示物理按键位置。
| 属性 | 说明 | 典型用途 |
|---|---|---|
key |
按键产生的字符,如 "a"、"Enter"、"ArrowUp" |
判断用户想输入什么 |
code |
物理按键代码,如 "KeyA"、"Space"、"ArrowUp" |
游戏键位、快捷键,不受布局影响 |
keyCode |
已废弃的数字编码 | 不要再在新代码中使用 |
altKey |
是否按住了 Alt | 组合快捷键 |
ctrlKey |
是否按住了 Ctrl(Mac 上对应 Control) | 组合快捷键 |
metaKey |
是否按住了 Cmd(Mac)/ Win 键 | Mac 快捷键判断 |
shiftKey |
是否按住了 Shift | 区间选择、大写输入 |
repeat |
是否为长按产生的重复事件 | 避免长按误触发 |
四、绑定方式与 addEventListener 的第三个参数
四种绑定方式的历史演进
<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),也可以是一个配置对象。现代代码推荐用对象形式:
capture: false, // 是否在捕获阶段触发
once: false, // 触发一次后自动移除
passive: false, // 承诺不调用 preventDefault
signal: controller.signal // AbortSignal,用于批量移除
});
动手试试:once 只触发一次
下面这个按钮注册时带了 { once: true }。无论点多少次,计数只会增加到 1。
底层实现等价于在回调里手动 removeEventListener,但 once 由浏览器保证,不会因为异常而漏掉清理。
AbortSignal:现代的事件清理方式
在单页应用里,组件卸载时需要移除大量监听器。一个个 removeEventListener 既啰嗦又容易遗漏。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 判断到底是谁被点了。
没有委托时的问题
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
});
// 而且:动态新增的项根本没有监听器,必须重新绑定
动手试试:事件委托 + 动态添加
下面这个列表只有一个监听器(挂在 ul 上)。点击“添加一项”生成的新元素,不需要重新绑定就能响应点击。
- 机械键盘 ¥399
- 人体工学椅 ¥1299
- 显示器支架 ¥259
事件委托的标准写法
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 可能匹配到容器外的元素,导致逻辑错乱。
事件委托的三大收益
什么时候不该用委托
- 不冒泡的事件:
focus、blur等无法通过冒泡委托,需改用focusin/focusout,或使用捕获阶段监听。 - 需要阻止冒泡的场景:如果子元素内部的监听器会调用
stopPropagation,委托就会失效。第三方组件的内部实现你无法控制,这时应谨慎。 - 对性能极度敏感的路径:
mousemove、scroll这类高频事件,在委托里反复执行closest反而有额外开销,不如直接在目标上处理。 - 层级过深:如果祖先链很长,
closest每次都要向上遍历,成本不容忽视。
六、阻止传播与阻止默认行为:两件完全不同的事
这一组方法被混淆的概率,大概和 target / currentTarget 不相上下。
| 方法 | 作用对象 | 效果 | 典型场景 |
|---|---|---|---|
preventDefault() |
浏览器默认行为 | 阻止跳转、提交、勾选、右键菜单等 | 自定义表单校验、右键菜单 |
stopPropagation() |
事件传播 | 阻止继续冒泡或捕获,同元素其他监听器仍执行 | 弹窗内部点击不穿透 |
stopImmediatePropagation() |
事件传播 + 同元素监听器 | 连当前元素上后续的监听器也不执行 | 需要完全接管某个事件时 |
动手试试:preventDefault 的效果
下面是一个指向页面内锚点的链接。勾选复选框后,点击将不再产生任何跳转。
if (shouldBlock) {
event.preventDefault(); // 阻止跳转,但事件仍会冒泡
console.log('跳转被拦截');
}
});
三个高频真实场景
场景一:阻止表单提交。表单里的 <button> 不写 type 时默认是 submit,点击会触发表单提交并刷新页面。做前端校验时:
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% 的场景,值得收藏备查。
mousedown / mouseup
mousemove
mouseover / mouseout
mouseenter / mouseleave
contextmenu / auxclick
pointercancel
pointerover / pointerout
touchstart / touchmove / touchend
touchcancel
keypress(已废弃)
input / beforeinput
compositionstart / compositionend
focusin / focusout
change / input
submit / reset
invalid / select
load / beforeunload / unload
resize / scroll
visibilitychange
pagehide / pageshow
online / offline
message
readystatechange
progress / timeout
dragstart / drag / dragend
dragenter / dragover
dragleave / drop
timeupdate / volumechange
loadedmetadata / canplay
animationstart / animationend
transitionstart / transitionend
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('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 是另一个高频困惑点。规则其实很清晰,只是需要记住三种情况。
情况一:普通函数
console.log(this); // 绑定监听器的元素,即 currentTarget
});
// 等价写法:
btn.addEventListener('click', function (event) {
console.log(this === event.currentTarget); // true
});
情况二:箭头函数
name: 'widget',
init() {
btn.addEventListener('click', () => {
console.log(this); // widget 对象,而不是 btn
});
}
};
箭头函数没有自己的 this,它会从定义它的外层作用域继承。这条规则既带来了便利(轻松访问组件实例),也带来了陷阱(拿不到被点击的元素)。需要访问元素时,用 event.currentTarget 显式获取即可。
情况三:bind
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。后者是最不容易出错的做法。
九、自定义事件:让模块之间开口说话
事件机制不只能监听浏览器内置的行为,你还可以自己定义事件类型,让不同的模块通过事件解耦通信。
动手试试:派发与监听自定义事件
创建与派发
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 notCancelled = element.dispatchEvent(evt);
if (!notCancelled) {
console.log('有监听器阻止了保存流程');
}
// 监听端可以这样“否决”:
element.addEventListener('before:save', e => {
if (hasUnsavedChanges) e.preventDefault();
});
这个模式让自定义事件具备了“可拦截”的能力,非常适合做插件系统、表单前置校验、路由守卫这类需要多方参与决策的场景。
EventTarget:不依赖 DOM 的事件总线
EventTarget 是一个独立于 DOM 的类,你可以直接实例化它,得到一个轻量级的事件总线:
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,再决定要不要滚动页面。这个“等待”就是移动端滚动卡顿的头号元凶。
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 次事件(相当于用户快速滚动或高频输入)。点击按钮,观察三种策略下处理函数实际执行的次数。
两种策略的本质区别
适合:搜索输入联想、窗口 resize 后重排、表单自动保存。
特征:只在“最后”执行一次。
适合:滚动加载、鼠标跟随、拖拽时的位置更新。
特征:在过程中“均匀”执行多次。
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 更稳妥。如果设计稿上的按钮很小,可以用伪元素扩大它的可点击区域,而不必改变视觉尺寸:
position: relative;
width: 24px;
height: 24px;
}
/* 视觉上仍是 24px,但可点击区域扩大到 44px */
.icon-button::after {
content: '';
position: absolute;
inset: -10px;
}
拖动操作的键盘替身
拖拽(drag)是最容易忽略无障碍的交互。屏幕阅读器用户和键盘用户无法“按住并移动”,必须提供替代方案:通常是给每个可拖拽项目加一个“上移 / 下移”按钮,或者在选中项目后允许用方向键移动。这是拖拽排序组件能否通过无障碍审计的分水岭。
十二、十个最常见的事件处理误区
stopPropagation() 能阻止浏览器默认行为(它不能,那是 preventDefault() 的事)
addEventListener 里写 return false 并期待它起作用
removeEventListener 移除匿名函数或箭头函数
event.target 永远等于绑定监听器的元素
focus / blur 做事件委托(它们不冒泡)
scroll 事件里同步读取 offsetTop 等布局属性,造成布局抖动
passive: true,导致移动端滚动卡顿
onclick 给同一个元素绑定多个监听器,结果被静默覆盖
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自定义事件,用返回值判断是否被否决。 - 拖动类交互提供键盘替代方案。
- 只用键盘走一遍页面,确认所有交互都能完成。
一份可直接复用的事件处理基线代码
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 交互模型的缩影。