原生 DOM 事件模型:事件流、事件处理程序与事件对象
聊到跨端事件统一,咱们得先回到最根本的 DOM 事件模型。说实话,不管你是做 React、Vue 还是小程序,底层那套事件机制,万变不离其宗。今天我就带你把这套东西彻底捋清楚。
事件流:捕获、目标、冒泡
你点击一个按钮,浏览器是怎么知道该触发哪个元素的?它可不是直接定位到按钮就完事了。整个过程分三个阶段:捕获阶段、目标阶段、冒泡阶段。
我刚开始做前端时,以为事件就是从点击的元素开始往外冒。后来踩了个坑才明白——事件其实是先从根节点往下走,走到目标,再往回冒。这个「先下后上」的模型,就是 DOM 事件流。
核心要点:事件流三阶段
- 捕获阶段:事件从 window 向下传播到目标元素的父节点
- 目标阶段:事件到达目标元素本身
- 冒泡阶段:事件从目标元素向上传播回 window
下面这张图,我每次讲课时都会画一遍。它把事件流的路径说得明明白白:
你看,事件从 window 出发,一路向下穿过 document、html、body,直到目标元素。到了目标之后,再原路返回。这就是完整的「事件流」。
我的习惯:在调试复杂事件交互时,我经常在控制台用 console.log(event.eventPhase) 来确认当前处于哪个阶段。1 是捕获,2 是目标,3 是冒泡。这个小技巧帮我定位过不少问题。
事件处理程序
事件处理程序,说白了就是「事件来了,你打算怎么处理」。DOM 提供了好几种绑定方式,我一个个说。
HTML 事件处理程序
最古老的方式,直接在标签里写 onclick 属性:
<button onclick="alert('点击了')">点我</button>
这种方式我基本不用。它把 JS 和 HTML 混在一起,维护起来太痛苦了。而且作用域问题也容易出幺蛾子。
DOM 0 级事件处理程序
把一个函数赋值给元素的事件属性:
const btn = document.getElementById('myBtn');
btn.onclick = function() {
console.log('点击了按钮');
};
这种方式简单直接,但有个硬伤——同一个事件只能绑定一个处理函数。后赋值的会覆盖前面的。
DOM 2 级事件处理程序(推荐)
这才是现代前端的主流方式。addEventListener 和 removeEventListener 这对好兄弟:
const btn = document.getElementById('myBtn');
// 绑定事件
btn.addEventListener('click', function handler() {
console.log('点击了按钮');
}, false);
// 移除事件
btn.removeEventListener('click', handler, false);
第三个参数控制事件在哪个阶段触发:false 是冒泡阶段(默认),true 是捕获阶段。
我曾经踩过的坑:用 addEventListener 绑定了匿名函数,后来想移除时发现根本移不掉。因为匿名函数没有引用,removeEventListener 找不到它。所以,如果你需要移除事件,一定要用命名函数或者保存函数引用。
另外,addEventListener 还支持一个可选的配置对象:
btn.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发
once: true, // 只触发一次,自动移除
passive: true // 告诉浏览器你不会调用 preventDefault
});
passive: true 这个选项,我在做滚动优化时经常用。它能让滚动更流畅,因为浏览器知道你不会阻止默认行为,就不用等你的事件处理完再滚动。
事件对象详解
每次事件触发,浏览器都会创建一个事件对象,传给事件处理函数。这个对象里装满了你需要的信息。
| 属性/方法 | 说明 | 我的使用场景 |
|---|---|---|
type |
事件类型,如 'click'、'keydown' | 同一个处理函数处理多种事件时做分支 |
target |
触发事件的元素(实际点击的那个) | 事件委托时判断具体点了谁 |
currentTarget |
绑定事件处理程序的元素 | 区分事件流到哪一层了 |
eventPhase |
当前阶段:1捕获、2目标、3冒泡 | 调试事件流顺序 |
preventDefault() |
阻止默认行为(如链接跳转) | 表单提交前做校验 |
stopPropagation() |
阻止事件继续传播 | 防止冒泡引起父元素事件 |
stopImmediatePropagation() |
阻止传播 + 阻止同元素其他处理函数 | 紧急中断,后面的都不执行 |
这里有个细节我特别想强调——target 和 currentTarget 的区别。你想想看,如果你在父元素上绑定了事件,点击子元素时:
event.target是你实际点击的那个子元素event.currentTarget是绑定事件的父元素
这个区别在事件委托里特别重要。我做过一个大型表格,上千行数据,不可能给每行都绑事件。就在表格上绑一个点击事件,通过 event.target 判断点的是哪一行。性能提升非常明显。
事件委托的核心逻辑:
document.getElementById('table').addEventListener('click', function(event) {
const target = event.target;
// 判断点击的是不是行
if (target.tagName === 'TR') {
console.log('点击了行:', target.dataset.id);
}
});
事件对象的兼容性
说到事件对象,老版本的 IE 有个让人头疼的问题——它不把事件对象传给处理函数,而是挂在 window.event 上。我记得刚入行那会儿,为了兼容 IE8,代码里到处都是这种判断:
function handleClick(event) {
event = event || window.event; // 兼容 IE
const target = event.target || event.srcElement; // 兼容 IE
// ...
}
现在好了,现代浏览器都遵循标准,这种兼容代码基本用不上了。但如果你还在维护老项目,这个技巧还是得留着。
我的建议:虽然现在不用太担心 IE 了,但在做跨端开发时,不同平台的事件对象还是会有细微差异。比如移动端的 TouchEvent 和 PC 端的 MouseEvent,属性就不完全一样。后面几章我会专门讲怎么统一这些差异。
小结
原生 DOM 事件模型,说白了就是三件事:事件怎么流(捕获→目标→冒泡)、怎么绑(addEventListener)、事件对象里有什么(target、currentTarget、preventDefault 等)。
这些是基础中的基础。不管你以后用 React 的合成事件、还是自己封装跨端事件库,底层都是这套东西。把今天的内容吃透,后面讲跨端统一时你就能理解为什么有些设计要那么做了。