事件设计
# 事件设计
# 目录介绍
# 8.1 基本概念
JavaScript 事件设计是前端开发中的核心部分,它允许开发者对用户交互(如点击、输入、滚动等)和浏览器行为(如加载、卸载等)做出响应。
事件系统的底层原理:浏览器的事件系统基于观察者模式(Observer Pattern) 实现。当用户触发一个事件(如点击),浏览器会创建一个事件对象(Event Object),然后按照 W3C 标准的三阶段模型分发事件:捕获阶段(从 window → document → ... → 目标元素的父节点)→ 目标阶段(到达目标元素)→ 冒泡阶段(从目标元素 → ... → document → window)。事件对象在整个传播过程中是同一个实例,这就是为什么 event.target 始终指向触发事件的原始元素,而 event.currentTarget 指向当前正在处理事件的元素。
# 8.1.1 基本概念
- 事件:用户或浏览器触发的动作,如点击、鼠标移动、键盘输入等。
- 事件目标(Event Target):触发事件的 DOM 元素。
- 事件监听器(Event Listener):用于监听事件并执行回调函数。
- 事件传播(Event Propagation):事件从目标元素向上或向下传播的过程,包括捕获阶段和冒泡阶段。
# 8.1.2 事件接口方法
DOM 节点的事件操作(监听和触发),都定义在EventTarget接口。所有节点对象都部署了这个接口,其他一些需要事件通信的浏览器内置对象(比如,XMLHttpRequest、AudioNode、AudioContext)也部署了这个接口。
该接口主要提供三个实例方法。
addEventListener():绑定事件的监听函数removeEventListener():移除事件的监听函数dispatchEvent():触发事件
# 8.1.3 事件驱动的设计原理
疑惑:为什么浏览器要采用事件驱动模型,而不是轮询模型?
答疑:轮询模型需要不断检查是否有事件发生,浪费 CPU 资源。事件驱动模型基于中断机制——操作系统监测到用户输入(鼠标点击、键盘按下),产生硬件中断,浏览器接收到中断信号后创建事件对象并放入事件队列,事件循环从队列中取出事件进行分发。
论证:
用户点击鼠标
↓
操作系统产生中断信号
↓
浏览器进程接收信号
↓
渲染进程创建 MouseEvent 对象
↓
进行命中测试(Hit Testing)确定目标元素
↓
按照捕获→目标→冒泡三阶段分发事件
↓
执行匹配的事件监听器
2
3
4
5
6
7
8
9
10
11
12
13
结果展示:这种设计使得 JavaScript 主线程可以在没有事件时做其他工作(如执行脚本、处理异步任务),只在事件到来时才做出响应,极大提高了效率。这就是为什么 JavaScript 虽然是单线程,但能流畅处理用户交互的原因。
# 8.2 事件监听
# 8.2.1 addEventListener
EventTarget.addEventListener()用于在当前节点或对象上(即部署了 EventTarget 接口的对象),定义一个特定事件的监听函数。一旦这个事件发生,就会执行监听函数。该方法没有返回值。
const button = document.querySelector("button");
button.addEventListener("click", function (event) {
console.log("Button clicked!");
});
2
3
4
5
该方法接受三个参数。
type:事件名称,大小写敏感。listener:监听函数。事件发生时,会调用该监听函数。useCapture:布尔值,如果设为true,表示监听函数将在捕获阶段(capture)触发(参见后文《事件的传播》部分)。该参数可选,默认值为false(监听函数只在冒泡阶段被触发)。
# 8.2.2 使用HTML属性
直接在 HTML 元素上定义事件处理函数(不推荐,因为混合了 HTML 和 JavaScript)。
<button onclick="handleClick()">Click me</button>
<script>
function handleClick() {
console.log("Button clicked!");
}
</script>
2
3
4
5
6
# 8.2.3 使用DOM属性
通过 DOM 元素的属性绑定事件处理函数(不推荐,因为只能绑定一个处理函数)。
const button = document.querySelector("button");
button.onclick = function (event) {
console.log("Button clicked!");
};
2
3
4
5
三种事件绑定方式的对比:
| 方式 | 多个监听器 | 控制传播阶段 | HTML/JS 分离 | 移除方便 |
|---|---|---|---|---|
| HTML 属性 | 否 | 否 | 否 | 否 |
| DOM 属性 | 否 | 否 | 是 | 是 |
| addEventListener | 是 | 是 | 是 | 是 |
# 8.2.4 removeEventListener
EventTarget.removeEventListener()方法用来移除addEventListener()方法添加的事件监听函数。该方法没有返回值。
function handleClick(event) {
console.log("Button clicked!");
}
button.addEventListener("click", handleClick);
// 移除事件监听器
button.removeEventListener("click", handleClick);
2
3
4
5
6
7
注意,removeEventListener()方法移除的监听函数,必须是addEventListener()方法添加的那个监听函数,而且必须在同一个元素节点,否则无效。
element.addEventListener('mousedown', handleMouseDown, true);
element.removeEventListener("mousedown", handleMouseDown, false);
2
上面代码中,removeEventListener()方法也是无效的,因为第三个参数不一样。
常见错误:匿名函数无法被移除。
// 错误!这两个是不同的函数实例
button.addEventListener('click', () => console.log('click'));
button.removeEventListener('click', () => console.log('click')); // 无效!
// 正确做法:保存函数引用
const handler = () => console.log('click');
button.addEventListener('click', handler);
button.removeEventListener('click', handler); // 有效
2
3
4
5
6
7
8
# 8.2.5 dispatchEvent
EventTarget.dispatchEvent()方法在当前节点上触发指定事件,从而触发监听函数的执行。该方法返回一个布尔值,只要有一个监听函数调用了Event.preventDefault(),则返回值为false,否则为true。
target.dispatchEvent(event)
dispatchEvent()方法的参数是一个Event对象的实例(详见《Event 对象》章节)。
para.addEventListener('click', hello, false);
var event = new Event('click');
para.dispatchEvent(event);
2
3
上面代码在当前节点触发了click事件。
# 8.2.6 addEventListener高级选项
addEventListener 的第三个参数可以是一个选项对象,提供更精细的控制:
element.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发
once: true, // 只触发一次,然后自动移除
passive: true, // 不会调用 preventDefault()(性能优化)
signal: controller.signal // 通过 AbortController 取消
});
// once 的妙用——一次性事件
button.addEventListener('click', () => {
console.log('只触发一次');
}, { once: true });
// passive 的性能优化——滚动事件
// 告诉浏览器不会调用 preventDefault(),浏览器可以立即开始滚动
document.addEventListener('touchstart', handler, { passive: true });
// 使用 AbortController 批量移除事件
const controller = new AbortController();
button.addEventListener('click', handler1, { signal: controller.signal });
button.addEventListener('mouseover', handler2, { signal: controller.signal });
input.addEventListener('input', handler3, { signal: controller.signal });
// 一次性移除所有事件
controller.abort();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 8.3 事件对象
事件处理函数会接收一个事件对象(event),它包含与事件相关的信息。
button.addEventListener("click", function (event) {
console.log(event.target); // 触发事件的元素
console.log(event.type); // 事件类型(如 "click")
console.log(event.clientX, event.clientY); // 鼠标位置
});
2
3
4
5
# 8.3.1 事件对象常用属性
| 属性/方法 | 说明 |
|---|---|
event.type | 事件类型(如 "click"、"keydown") |
event.target | 触发事件的原始元素 |
event.currentTarget | 当前正在处理事件的元素(绑定监听器的元素) |
event.bubbles | 事件是否冒泡 |
event.cancelable | 事件是否可以取消默认行为 |
event.eventPhase | 事件当前所在阶段(1=捕获,2=目标,3=冒泡) |
event.timeStamp | 事件创建时间 |
event.preventDefault() | 阻止默认行为 |
event.stopPropagation() | 阻止事件继续传播 |
event.stopImmediatePropagation() | 阻止传播,且阻止同元素上其他监听器执行 |
event.composedPath() | 返回事件经过的所有节点路径 |
// event.composedPath() 示例
document.addEventListener('click', (e) => {
console.log(e.composedPath());
// [button, div#container, body, html, document, Window]
});
// event.target vs event.currentTarget
document.getElementById('parent').addEventListener('click', function(e) {
console.log('target:', e.target.id); // 实际被点击的元素
console.log('currentTarget:', e.currentTarget.id); // 绑定监听器的元素
});
2
3
4
5
6
7
8
9
10
11
# 8.4 事件传播
事件传播分为三个阶段:
- 捕获阶段(Capture Phase):从
window向下传播到目标元素。 - 目标阶段(Target Phase):到达目标元素。
- 冒泡阶段(Bubble Phase):从目标元素向上传播到
window。
# 8.4.1 捕获阶段
在捕获阶段监听事件,将 addEventListener 的第三个参数设为 true。
document.addEventListener("click", function (event) {
console.log("Document clicked (capture)");
}, true);
2
3
# 8.4.2 冒泡阶段
默认情况下,事件在冒泡阶段触发。
document.addEventListener("click", function (event) {
console.log("Document clicked (bubble)");
});
2
3
# 8.4.3 阻止事件传播
使用 event.stopPropagation() 阻止事件传播。
button.addEventListener("click", function (event) {
event.stopPropagation();
console.log("Button clicked (propagation stopped)");
});
2
3
4
stopPropagation vs stopImmediatePropagation:
const btn = document.getElementById('btn');
// 给同一个元素绑定两个监听器
btn.addEventListener('click', (e) => {
console.log('监听器 1');
e.stopPropagation(); // 只阻止向父元素传播
// e.stopImmediatePropagation(); // 阻止传播 + 阻止监听器2执行
});
btn.addEventListener('click', (e) => {
console.log('监听器 2'); // 使用 stopPropagation 时仍会执行
});
2
3
4
5
6
7
8
9
10
11
12
# 8.4.4 事件传播的底层原理
疑惑:为什么事件传播要分为捕获和冒泡两个阶段?只有冒泡不行吗?
答疑:这是历史原因。早期浏览器大战中,Netscape 实现了捕获模型(从外到内),IE 实现了冒泡模型(从内到外)。W3C 在制定标准时,为了兼顾两种模型,决定采用捕获 + 冒泡的完整模型。
论证:
<div id="grandparent">
<div id="parent">
<button id="child">Click me</button>
</div>
</div>
<script>
const log = (phase, id) =>
console.log(`${phase}: ${id}`);
['grandparent', 'parent', 'child'].forEach(id => {
const el = document.getElementById(id);
// 捕获阶段
el.addEventListener('click', () => log('捕获', id), true);
// 冒泡阶段
el.addEventListener('click', () => log('冒泡', id), false);
});
// 点击 button 后的输出:
// 捕获: grandparent
// 捕获: parent
// 捕获: child(目标阶段,按注册顺序执行)
// 冒泡: child(目标阶段)
// 冒泡: parent
// 冒泡: grandparent
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
结果展示:在目标阶段,捕获和冒泡的区分消失了——监听器按照注册顺序执行。这是一个容易被忽略的细节。
# 8.5 事件委托
事件委托利用事件冒泡机制,将事件监听器绑定到父元素,从而处理子元素的事件。
# 8.5.1 事件委托的原理与实现
<ul id="list">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<script>
const list = document.getElementById("list");
list.addEventListener("click", function (event) {
if (event.target.tagName === "LI") {
console.log("Clicked on:", event.target.textContent);
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
事件委托的优势:
- 减少内存占用:1 个监听器替代 N 个监听器
- 动态元素支持:新增的子元素自动被委托处理
- 简化代码:不需要为每个子元素单独绑定/解绑
// 没有事件委托:每个 li 都需要绑定
document.querySelectorAll('li').forEach(li => {
li.addEventListener('click', handler); // 100个 li = 100个监听器
});
// 使用事件委托:只需一个监听器
document.getElementById('list').addEventListener('click', (e) => {
if (e.target.matches('li')) {
handler.call(e.target, e);
}
});
// 动态添加元素也能生效
const newLi = document.createElement('li');
newLi.textContent = 'New Item';
list.appendChild(newLi); // 不需要额外绑定事件
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 8.5.2 通用事件委托函数
function delegate(parent, eventType, selector, handler) {
parent.addEventListener(eventType, function(event) {
// 从 target 向上查找匹配的元素
let target = event.target;
while (target && target !== parent) {
if (target.matches(selector)) {
handler.call(target, event);
return;
}
target = target.parentElement;
}
});
}
// 使用
delegate(document.body, 'click', '.btn', function(e) {
console.log('按钮被点击:', this.textContent);
});
delegate(document.body, 'click', 'a[data-action]', function(e) {
e.preventDefault();
console.log('操作:', this.dataset.action);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 8.6 自定义事件
# 8.6.1 CustomEvent详解
可以通过 CustomEvent 创建和触发自定义事件。
const button = document.querySelector("button");
// 创建自定义事件
const customEvent = new CustomEvent("customClick", {
detail: { message: "This is a custom event" },
bubbles: true, // 允许冒泡
cancelable: true, // 允许取消
composed: false // 是否穿过 Shadow DOM 边界
});
// 监听自定义事件
button.addEventListener("customClick", function (event) {
console.log(event.detail.message); // This is a custom event
});
// 触发自定义事件
button.dispatchEvent(customEvent);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
自定义事件的实际应用——组件通信:
// 子组件发出事件
class SearchBox extends HTMLElement {
connectedCallback() {
this.innerHTML = '<input type="text"><button>搜索</button>';
this.querySelector('button').addEventListener('click', () => {
const query = this.querySelector('input').value;
this.dispatchEvent(new CustomEvent('search', {
detail: { query },
bubbles: true // 冒泡到父组件
}));
});
}
}
customElements.define('search-box', SearchBox);
// 父组件监听
document.addEventListener('search', (e) => {
console.log('搜索:', e.detail.query);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 8.6.2 非DOM的事件系统
在不依赖 DOM 的场景下(如 Node.js 或纯逻辑模块),可以手动实现事件系统:
class EventEmitter {
constructor() {
this._events = new Map();
}
on(event, listener) {
if (!this._events.has(event)) {
this._events.set(event, []);
}
this._events.get(event).push(listener);
return this; // 支持链式调用
}
off(event, listener) {
const listeners = this._events.get(event);
if (listeners) {
this._events.set(event, listeners.filter(fn => fn !== listener && fn._original !== listener));
}
return this;
}
emit(event, ...args) {
const listeners = this._events.get(event);
if (listeners) {
listeners.forEach(fn => fn.apply(this, args));
}
return this;
}
once(event, listener) {
const wrapper = (...args) => {
listener.apply(this, args);
this.off(event, wrapper);
};
wrapper._original = listener; // 保存原始引用,方便 off
return this.on(event, wrapper);
}
removeAllListeners(event) {
if (event) {
this._events.delete(event);
} else {
this._events.clear();
}
return this;
}
listenerCount(event) {
const listeners = this._events.get(event);
return listeners ? listeners.length : 0;
}
}
// 使用
const bus = new EventEmitter();
bus.on('data', (msg) => console.log('收到:', msg));
bus.once('ready', () => console.log('就绪'));
bus.emit('data', 'Hello'); // 收到: Hello
bus.emit('ready'); // 就绪
bus.emit('ready'); // (无输出,once 已自动移除)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 8.7 事件类型
# 8.7.1 常见DOM事件
常见的 DOM 事件类型包括:
- 鼠标事件:
click,dblclick,mouseover,mouseout,mousedown,mouseup,mousemove,mouseenter,mouseleave,contextmenu - 键盘事件:
keydown,keyup,keypress(已弃用) - 表单事件:
submit,change,focus,blur,input,reset,select - 窗口事件:
load,DOMContentLoaded,unload,beforeunload,resize,scroll - 触摸事件:
touchstart,touchmove,touchend,touchcancel - 拖放事件:
dragstart,drag,dragenter,dragleave,dragover,drop,dragend - 剪贴板事件:
copy,cut,paste
# 8.7.2 最佳实践
- 使用事件委托:减少事件监听器的数量,提高性能。
- 避免内联事件:将 JavaScript 代码与 HTML 分离。
- 移除无用的事件监听器:防止内存泄漏。
- 使用
event.preventDefault():阻止默认行为(如表单提交、链接跳转)。 - 考虑事件传播:理解捕获和冒泡阶段,避免意外行为。
- 使用
passive: true:在滚动和触摸事件中优化性能。 - 使用防抖/节流:对高频事件(scroll、resize、input)进行优化。
// 防抖应用:搜索框输入
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((e) => {
console.log('搜索:', e.target.value);
}, 300));
// 节流应用:滚动事件
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
window.addEventListener('scroll', throttle(() => {
console.log('滚动位置:', window.scrollY);
}, 100), { passive: true });
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 8.7.3 表单验证
<form id="myForm">
<input type="text" id="username" placeholder="Username" required />
<button type="submit">Submit</button>
</form>
<script>
const form = document.getElementById("myForm");
form.addEventListener("submit", function (event) {
event.preventDefault(); // 阻止表单提交
const username = document.getElementById("username").value;
if (username.length < 3) {
alert("Username must be at least 3 characters long!");
} else {
alert("Form submitted successfully!");
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 8.7.4 鼠标和键盘事件详解
鼠标事件的坐标系统:
element.addEventListener('click', (e) => {
// 相对于视口(可视区域)
console.log(e.clientX, e.clientY);
// 相对于页面(包含滚动偏移)
console.log(e.pageX, e.pageY);
// 相对于屏幕
console.log(e.screenX, e.screenY);
// 相对于目标元素
console.log(e.offsetX, e.offsetY);
// 修饰键
console.log(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey);
// 鼠标按键
console.log(e.button); // 0=左键, 1=中键, 2=右键
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mouseenter/mouseleave vs mouseover/mouseout:
// mouseover/mouseout 会在进入/离开子元素时也触发
// mouseenter/mouseleave 只在进入/离开目标元素时触发(不冒泡)
parent.addEventListener('mouseenter', () => console.log('进入'));
parent.addEventListener('mouseleave', () => console.log('离开'));
2
3
4
键盘事件:
document.addEventListener('keydown', (e) => {
console.log(e.key); // "a", "Enter", "ArrowUp" 等(推荐使用)
console.log(e.code); // "KeyA", "Enter", "ArrowUp"(物理键位)
console.log(e.keyCode); // 65(已弃用,不推荐)
// 快捷键检测
if (e.ctrlKey && e.key === 's') {
e.preventDefault(); // 阻止浏览器保存
console.log('保存');
}
});
2
3
4
5
6
7
8
9
10
11