第 28 章 事件基础

第 28 章 事件基础

想象一下,你在网上商城买了一件衣服,点击了"立即购买"按钮,然后…什么都没发生。你以为坏了,但其实是 JavaScript 在"等待指令"。事件就是 JavaScript 和用户之间的"对讲机"——用户做点什么(比如点击、输入、移动鼠标),JavaScript 就收到"信号"然后做出响应。

28.1 事件绑定

onclick = function(){}:同类型只能绑定一个,会覆盖

onclick 是最古老、最简单的绑定事件的方式。但它有一个致命缺陷——同一种事件类型只能绑定一个处理函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 获取按钮元素
const button = document.getElementById('myButton');

// 第一次绑定
button.onclick = function() {
    console.log('第一次绑定的处理函数');
};

// 第二次绑定 —— 第一个函数被"覆盖"了!
button.onclick = function() {
    console.log('第二次绑定的处理函数');
};

button.click();
// 打印结果: 第二次绑定的处理函数(只有这一个,第一个永远不会被调用)

实际案例:假设你想让按钮点击两次后失效,用 onclick 实现会很麻烦。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const button = document.getElementById('myButton');
let clickCount = 0;

button.onclick = function() {
    clickCount++;
    console.log('点击了 ' + clickCount + ' 次');
    
    if (clickCount >= 2) {
        // 想禁用,但 onclick 的问题在于...
        // 你没法"追加"处理函数,只能替换
        button.onclick = null; // 直接清空
    }
};

addEventListener():可绑定多个,不覆盖,推荐使用

addEventListener 是现代浏览器推荐的事件绑定方式。它最大的优点是——可以绑定多个处理函数,它们会依次执行,不会覆盖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const button = document.getElementById('myButton');

// 第一个处理函数
button.addEventListener('click', function() {
    console.log('处理函数 A');
});

// 第二个处理函数 —— 不会覆盖 A
button.addEventListener('click', function() {
    console.log('处理函数 B');
});

button.click();
// 打印结果: 处理函数 A
// 处理函数 B

addEventListener 的语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 完整语法
element.addEventListener(eventType, handler, options);

// eventType: 事件类型,如 'click'、'mouseover'(不带 on 前缀)
// handler: 处理函数
// options: 可选配置对象

// 示例
button.addEventListener('click', function(event) {
    console.log('按钮被点击了');
}, false);

// 第三个参数为 false 表示在冒泡阶段处理(默认)

第三个参数还能是个配置对象(比传 true/false 强大得多):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// options 对象支持的四个选项(示意写法,假设 handleClick 已定义)
const controller = new AbortController();

button.addEventListener('click', handleClick, {
  capture: true,    // 是否在捕获阶段触发(等价于传 true)
  once: true,       // 只触发一次,之后自动解绑(省去手写 removeEventListener)
  passive: true,    // 承诺"绝不调用 preventDefault()",让浏览器可以立刻滚动,不等待 JS
  signal: controller.signal  // 传一个 AbortSignal,调用 abort() 就能批量解绑
});

// ⭐ passive 的实际意义:移动端滚动时,浏览器原本要等监听器跑完才敢滚动,
//    加了 passive: true 就能立刻滚。代价是里面调用 preventDefault() 会失效(控制台会警告)。
//    像 scroll、touchstart、touchmove、wheel 这类高频事件,推荐加上 passive。

// ⭐ once 的常见用途:只在第一次交互时初始化
document.addEventListener('click', initOnce, { once: true });

// ⭐ signal 的常见用途:一次性解绑同一个组件里的所有监听器
const controller = new AbortController();
panel.addEventListener('click', onPanelClick, { signal: controller.signal });
panel.addEventListener('mouseenter', onPanelEnter, { signal: controller.signal });
// 组件卸载时:
controller.abort();   // 上面两个监听器同时被移除,不用逐个 remove

📌 一个容易忽略的细节:同一个元素、同一个事件类型、同一个函数引用、且选项一致时, 重复 addEventListener 不会重复绑定(浏览器会自动去重)。 但只要是不同的函数(哪怕是内容完全一样的两个匿名函数),就会被当成两个监听器。

removeEventListener():解绑(匿名函数无法解绑,需保存函数引用)

当你不再需要某个事件处理函数时,应该把它解绑,否则可能会造成内存泄漏和意外的 bug。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const button = document.getElementById('myButton');

// 定义一个命名函数
function handleClick() {
    console.log('点击了一次');
    // 满足某个条件后,解除绑定
    if (clickCount >= 3) {
        button.removeEventListener('click', handleClick);
        console.log('已解除事件绑定');
    }
    clickCount++;
}

let clickCount = 0;
button.addEventListener('click', handleClick);

❌ 匿名函数无法解绑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ❌ 匿名函数的问题:无法解绑
button.addEventListener('click', function() {
    console.log('匿名函数');
});

// 没有办法解绑这个函数!
// 因为你无法引用它

// ✅ 解决:使用具名函数或保存引用
const handler = function() {
    console.log('可以解绑');
};
button.addEventListener('click', handler);
button.removeEventListener('click', handler);

onclick vs addEventListener 对比

特性onclickaddEventListener
绑定多个处理函数❌ 会覆盖✅ 依次执行
解绑⚠️ 只能整体清空(onclick = null),无法只移除其中一个✅ 精确移除指定函数
支持事件捕获❌ 不支持✅ 支持
支持选项配置❌ 不支持✅ 支持
兼容性IE6+IE9+(IE8 用 attachEvent)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// onclick 用法
button.onclick = function() {
    console.log('onclick 方式');
};

// addEventListener 方式
button.addEventListener('click', function() {
    console.log('addEventListener 方式');
});

// onclick 只支持冒泡阶段,addEventListener 支持捕获阶段
// 第三个参数 true 表示捕获阶段处理
button.addEventListener('click', function() {
    console.log('捕获阶段');
}, true);

// 第三个参数 false 表示冒泡阶段处理(默认)
button.addEventListener('click', function() {
    console.log('冒泡阶段');
}, false);

下一节,我们来学习事件对象!

28.2 事件对象

当事件触发时,浏览器会自动创建一个事件对象(Event Object),包含了很多有用的信息,比如:触发事件的元素是什么?鼠标在哪里点击的?按了什么键?

e.target:触发事件的元素

target 是触发事件的源头元素,也就是你实际点击的那个元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 事件委托场景:给父元素绑定事件,通过 target 判断点击了哪个子元素
const container = document.getElementById('container');

// 点击按钮、div、span 都会触发这个事件
container.addEventListener('click', function(event) {
    console.log('触发事件的元素是:', event.target);
    console.log('元素标签名:', event.target.tagName);
    console.log('元素 ID:', event.target.id);
    console.log('元素内容:', event.target.textContent);
});

e.currentTarget:绑定事件的元素

currentTarget 是绑定事件的元素,也就是绑定了这个事件监听器的元素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 对比 target 和 currentTarget
const parent = document.getElementById('parent');
const child = document.getElementById('child');

parent.addEventListener('click', function(event) {
    console.log('target:', event.target.id);       // 实际点击的元素
    console.log('currentTarget:', event.currentTarget.id); // 绑定事件的元素
});

child.addEventListener('click', function(event) {
    // 如果 child 也绑定了事件
    console.log('target:', event.target.id);
    console.log('currentTarget:', event.currentTarget.id);
});

// 点击 child 时:
// child 自己的监听器:target=child, currentTarget=child
// parent 的监听器:target=child, currentTarget=parent

e.type:事件类型

type 告诉你这是什么类型的事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const button = document.getElementById('myButton');

button.addEventListener('click', function(event) {
    console.log('事件类型是:', event.type); // 打印结果: click
});

button.addEventListener('mouseover', function(event) {
    console.log('事件类型是:', event.type); // 打印结果: mouseover
});

// 一个通用的日志函数
function logEvent(event) {
    console.log('事件类型: ' + event.type + ', 目标元素: ' + event.target.tagName);
}

e.preventDefault():阻止默认行为

有些元素有默认行为,比如链接会跳转、表单会提交。preventDefault() 可以阻止这些默认行为。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 阻止链接跳转
document.querySelector('a.no-follow').addEventListener('click', function(event) {
    event.preventDefault(); // 阻止跳转
    console.log('链接跳转被阻止了');
});

// 阻止表单提交
document.querySelector('form').addEventListener('submit', function(event) {
    event.preventDefault(); // 阻止提交
    console.log('表单提交被阻止了');
});

// 阻止右键菜单
document.addEventListener('contextmenu', function(event) {
    event.preventDefault();
    console.log('右键菜单被阻止了');
});

// 阻止文本选择
document.querySelector('.no-select').addEventListener('selectstart', function(event) {
    event.preventDefault();
});

e.stopPropagation():阻止冒泡

事件会从触发元素向上传播到父元素,这叫"冒泡"。stopPropagation() 可以阻止这个传播过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const parent = document.getElementById('parent');
const child = document.getElementById('child');

parent.addEventListener('click', function() {
    console.log('parent 被点击了');
});

child.addEventListener('click', function(event) {
    console.log('child 被点击了');
    event.stopPropagation(); // 阻止事件继续向上冒泡
});

child.click();
// 打印结果: child 被点击了
// parent 的点击事件不会被触发!

e.stopImmediatePropagation():阻止后续同类型事件

如果一个元素绑定了多个同类型事件,stopImmediatePropagation() 不仅阻止冒泡,还会阻止后续的处理函数执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const button = document.getElementById('myButton');

button.addEventListener('click', function(event) {
    console.log('处理函数 1');
});

button.addEventListener('click', function(event) {
    console.log('处理函数 2');
    event.stopImmediatePropagation(); // 不仅阻止冒泡,还阻止后续处理函数
});

button.addEventListener('click', function(event) {
    console.log('处理函数 3 —— 不会执行');
});

button.click();
// 打印结果: 处理函数 1
// 处理函数 2
// 处理函数 3 不会打印!

下一节,我们来学习事件流!

28.3 事件流

三个阶段:捕获阶段 → 目标阶段 → 冒泡阶段

当一个事件触发时,它会经历三个阶段:

flowchart TD
    A["事件触发"] --> B["捕获阶段<br/>从 window 向下到目标元素"]
    B --> C["目标阶段<br/>事件到达目标元素"]
    C --> D["冒泡阶段<br/>从目标元素向上到 window"]
    
    style B fill:#fff3cd
    style C fill:#d4edda
    style D fill:#cce5ff
 1
 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
// 三个阶段的监听器(顺序按注册顺序写)
window.addEventListener('click', function() {
    console.log('1. 捕获阶段 - window');
}, true);

document.addEventListener('click', function() {
    console.log('2. 捕获阶段 - document');
}, true);

document.body.addEventListener('click', function() {
    console.log('3. 捕获阶段 - body');
}, true);

const button = document.getElementById('myButton');

button.addEventListener('click', function() {
    console.log('4. 目标阶段 - button(先注册的先执行)');
}, true);

button.addEventListener('click', function() {
    console.log('5. 目标阶段 - button(后注册的后执行)');
}, false);

document.body.addEventListener('click', function() {
    console.log('6. 冒泡阶段 - body');
}, false);

document.addEventListener('click', function() {
    console.log('7. 冒泡阶段 - document');
}, false);

window.addEventListener('click', function() {
    console.log('8. 冒泡阶段 - window');
}, false);

// 点击 button,实际输出严格按 1 → 8 的顺序

⭐ 最容易考也最容易记错的一点:目标元素自己身上的两个监听器(第 4、5 条), 不是"捕获的先、冒泡的后",而是严格按注册顺序执行。 只有当事件真正处于"捕获"或"冒泡"阶段(也就是目标元素之外的祖先节点)时,capture 才有区分作用。

事件捕获:addEventListener 第三个参数为 true

默认情况下,事件处理在冒泡阶段执行。如果想在捕获阶段处理,第三个参数传 true。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// 捕获阶段:外层先处理,内层后处理
parent.addEventListener('click', function() {
    console.log('parent 捕获阶段');
}, true);

child.addEventListener('click', function() {
    console.log('child 捕获阶段');
}, true);

child.click();
// 打印结果: parent 捕获阶段 -> child 捕获阶段

事件冒泡:默认行为,事件向上传播

冒泡是事件的默认行为。当子元素的事件触发时,父元素同类型的事件也会被触发。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');

outer.addEventListener('click', function() {
    console.log('outer 冒泡');
});

inner.addEventListener('click', function() {
    console.log('inner 冒泡');
});

inner.click();
// 打印结果:
// inner 冒泡
// outer 冒泡
// 事件从内向外"冒泡"

事件委托:绑定到父元素,利用冒泡处理子元素事件

事件委托是一种高级技巧——把事件绑定到父元素,利用冒泡机制统一处理子元素的事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ❌ 传统方式:给每个 li 都绑定事件
document.querySelectorAll('li').forEach(function(li) {
    li.addEventListener('click', function() {
        console.log('点击了:' + this.textContent);
    });
});

// ✅ 事件委托方式:只绑定一个事件
document.querySelector('ul').addEventListener('click', function(event) {
    // event.target 是实际点击的元素
    if (event.target.tagName === 'LI') {
        console.log('点击了:' + event.target.textContent);
    }
});

事件委托的优点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 1. 性能优化:减少事件绑定数量
// 假设有一个 1000 项的列表
// ❌ 传统方式:绑定 1000 个事件
// ✅ 事件委托:只绑定 1 个事件

// 2. 动态内容支持:新增的子元素自动获得事件
const list = document.getElementById('list');
list.addEventListener('click', function(event) {
    if (event.target.classList.contains('item')) {
        console.log('点击了:' + event.target.textContent);
    }
});

// 新增的 li 不用单独绑定事件
const newItem = document.createElement('li');
newItem.className = 'item';
newItem.textContent = '新项目';
list.appendChild(newItem); // 自动就有点击事件了!
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ⚠️ 事件委托最常见的坑:target 不一定是"你想的那个元素"
// 如果 <li> 里面还套了 <span> 或 <img>,点击时 event.target 是那个内层元素,
// 上面用 event.target.classList.contains('item') 判断就会失效。

// ✅ 正确姿势一:用 closest() 从实际点击点向上找最近的匹配祖先
list.addEventListener('click', function(event) {
  const item = event.target.closest('.item');
  if (!item || !list.contains(item)) return;   // 别忘了限定在容器内
  console.log('点击了:' + item.textContent);
});

// ✅ 正确姿势二:给内层元素加 pointer-events: none(样式层面的规避)

// ⭐ 另外两个注意事项:
// 1. 不是所有事件都会冒泡!focus / blur / mouseenter / mouseleave 都不冒泡,
//    它们没法用事件委托(需要时请换成 focusin / focusout、mouseover / mouseout)。
// 2. 委托让"容器"承担所有子元素的事件,如果子元素里随手 stopPropagation(),
//    委托就会失效——所以在组件内部尽量别随便阻止冒泡。

本章小结

本章我们学习了 JavaScript 事件的基础知识:

  1. 事件绑定:onclick 简单但会覆盖,addEventListener 可绑定多个且可解绑。
  2. 事件对象:target、currentTarget、type、preventDefault、stopPropagation、stopImmediatePropagation。
  3. 事件流:三个阶段——捕获阶段、目标阶段、冒泡阶段。
  4. 事件委托:利用冒泡机制,在父元素统一处理子元素的事件,性能高且支持动态内容。

几个必须记住的细节:

  • addEventListener 的第三个参数除了 true/false,还可以传 { capture, once, passive, signal }:once 自动解绑、passive 显著改善滚动性能、signal 可以一次性解绑整组监听器。
  • 同一个元素上重复绑定"同一个函数引用 + 相同选项"不会重复生效;但两个内容相同的匿名函数算两个监听器,这就是要用具名函数才好解绑的原因。
  • 在目标元素自己身上,捕获和冒泡监听器按注册顺序执行,capture 在那里不起区分作用。
  • stopPropagation() 只阻止继续传播,不会阻止同元素上的其他监听器;要连它们一起拦下来得用 stopImmediatePropagation()。
  • 事件委托要用 closest() 从真实点击点往上找;并且并非所有事件都会冒泡(focus、blur、mouseenter、mouseleave 就不冒泡)。

下一章,我们要学习各种具体的事件类型——鼠标事件、键盘事件、表单事件等!

最后修改 September 19, 2026: 更新 (3489033b1)