Skip to content

八、浏览器事件机制

1. 事件是什么?事件模型?

JavaScript 的事件模型决定了如何监听、触发和处理事件,影响事件的传播方式和作用范围。现代浏览器主要支持 DOM0、IE、DOM2 及 DOM3 事件模型


1. 事件的基本概念

在网页中,事件可以分为:

  • 用户触发事件:点击(click)、鼠标移动(mousemove)、键盘按下(keydown
  • 浏览器触发事件:页面加载(load)、窗口滚动(scroll)、元素变化(change
  • 其他事件:自定义事件(CustomEvent

每个事件会创建一个 event 对象,其中包含:

  • 事件类型type):事件名称(如 click
  • 目标对象target):触发事件的元素
  • 鼠标/键盘信息clientXkeyCode
  • 事件的方法
    • preventDefault():阻止默认行为(如 form 提交)
    • stopPropagation():阻止事件冒泡

2. JavaScript 事件模型

JavaScript 的事件模型随着浏览器发展不断演变,目前有 3 种主要的事件模型。

2.1 DOM0 级事件模型

特点

  • 直接在 HTML 标签中定义,或者在 JavaScript 中通过 element.onclick 绑定。
  • 事件不会传播,没有事件捕获和冒泡的概念。
  • 只能绑定一个事件处理函数,后绑定的会覆盖之前的。

示例方式 1:HTML 直接绑定

html
<button onclick="alert('点击按钮')">点我</button>

方式 2:JavaScript 绑定

js
let btn = document.getElementById('btn');
btn.onclick = function () {
  alert('DOM0 方式');
};

缺点

  • 不能绑定多个事件处理函数,后者会覆盖前者。
  • 不支持事件捕获和冒泡

2.2 IE 事件模型

特点

  • 事件有两个阶段事件处理事件冒泡(没有捕获)。
  • 事件通过 attachEvent() 绑定,可以绑定多个事件。
  • 事件冒泡:事件从目标元素传播到 document,经过的每个元素都会触发事件。
  • this 指向 window,不同于 DOM2。

示例

js
let btn = document.getElementById('btn');
btn.attachEvent('onclick', function () {
  alert('IE 事件模型');
});

缺点

  • 现代浏览器已经不支持 attachEvent()(IE9+ 推荐使用 addEventListener())。
  • this 关键字会指向 window,可能会导致作用域问题。

2.3 DOM2 级事件模型

特点

  • 事件有 三个阶段
    1. 事件捕获(从 document 向下传递)
    2. 目标阶段(事件触发)
    3. 事件冒泡(从目标元素向上传递)
  • 通过 addEventListener(event, handler, useCapture) 绑定:
    • useCapture = true:捕获阶段触发
    • useCapture = false(默认):冒泡阶段触发
  • 可以绑定多个事件处理函数,不会相互覆盖。
示例
js
let btn = document.getElementById('btn');

function handleClick() {
  alert('DOM2 方式');
}

// 绑定事件(冒泡)
btn.addEventListener('click', handleClick, false);

优点

  • 可以绑定多个事件处理函数(不会互相覆盖)。
  • 支持事件捕获和冒泡(可控制事件的传播方式)。

2.4 DOM3 级事件模型(现代浏览器)

DOM3 事件模型 是 DOM2 事件模型的升级版,增加了:

  1. 更多的事件类型:如 input 事件(文本框输入)、focusin 事件(冒泡的 focus)。
  2. 支持事件监听选项
    • capture: true(捕获阶段触发)
    • once: true(事件执行一次后自动移除)
    • passive: true(用于 scroll 事件,防止 preventDefault() 阻塞页面)

示例

js
document.addEventListener('scroll', function () {
  console.log('滚动事件');
}, { passive: true });

3. 事件流(Event Flow)

事件流 指的是事件触发后如何传播,分为 捕获、目标、冒泡 三个阶段。

事件流过程

txt
1️⃣ 捕获阶段:document → html → body → div → button
2️⃣ 目标阶段:button
3️⃣ 冒泡阶段:button → div → body → html → document

示例

html
<div id="outer">
  <button id="btn">点我</button>
</div>
js
document.getElementById('outer').addEventListener('click', function () {
  console.log('div 被点击(冒泡阶段)');
}, false);

document.getElementById('btn').addEventListener('click', function () {
  console.log('button 被点击');
}, false);

输出顺序

button 被点击
div 被点击(冒泡阶段)

如果 useCapture = true,顺序变为

div 被点击(捕获阶段)
button 被点击

4. 事件方法

4.1 阻止事件冒泡

js
event.stopPropagation();
js
document.getElementById('btn').addEventListener('click', function (event) {
  event.stopPropagation(); // 阻止事件冒泡
  console.log('button 被点击');
}, false);

4.2 阻止默认行为

js
event.preventDefault();
js
document.getElementById('link').addEventListener('click', function (event) {
  event.preventDefault(); // 阻止超链接跳转
  console.log('链接被点击');
});

5. 事件代理(Event Delegation)

事件代理 通过事件冒泡机制,在父元素上监听子元素的事件,提高性能。

示例

html
<ul id="list">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>
js
document.getElementById('list').addEventListener('click', function (event) {
  if (event.target.tagName === 'LI') {
    alert('点击了 ' + event.target.innerText);
  }
});

优点

  • 减少事件绑定数量,提高性能。
  • 动态元素也能触发事件

6. 事件模型对比总结

事件模型事件流阶段事件绑定方式是否支持多个监听函数this 指向
DOM0无传播onclick = fn❌(会覆盖)事件目标
IE 事件事件处理 + 冒泡attachEvent✅(顺序执行)window
DOM2/3捕获 + 目标 + 冒泡addEventListener事件目标

总结

  • DOM2/3 事件模型是现代前端开发的标准。
  • 事件流 = 捕获 → 目标 → 冒泡
  • 事件代理是提高性能的重要技巧。
  • 阻止冒泡stopPropagation())和阻止默认行为preventDefault())是常用的事件控制方法。

2. 如何阻止事件冒泡

  • 普通浏览器使用:event.stopPropagation()
  • IE浏览器使用:event.cancelBubble = true;

3.事件委托(Event Delegation)

1. 什么是事件委托?

事件委托(Event Delegation)是一种基于事件冒泡的技术,通过给父元素绑定事件,让子元素触发时,事件通过冒泡机制传递到父元素,由父元素统一处理,而不是给每个子元素都绑定事件。

2. 为什么使用事件委托?

优势

  • 减少事件绑定,提高性能:特别适用于有大量动态子元素的情况。
  • 可以处理动态元素:即使是后添加的元素,仍然可以触发事件,不需要重新绑定。
  • 代码简洁:只在父元素上绑定一次,而不是每个子元素都绑定。

🚫 局限

  • 某些事件不会冒泡,如 blurfocus 需要使用 focusinfocusout 代替。
  • 事件目标(event.target)需要判断,确保只对特定子元素生效。

3. 事件委托的工作原理

事件流(事件冒泡)

  1. 用户点击子元素
  2. 事件从子元素冒泡到父元素
  3. 父元素的事件监听器触发
  4. 监听器检查 event.target,确认事件来自哪个子元素
  5. 执行相应逻辑

4. 事件委托示例

4.1 传统方式(每个子元素绑定事件)

html
<ul>
  <li id="item1">Item 1</li>
  <li id="item2">Item 2</li>
  <li id="item3">Item 3</li>
</ul>
js
document.getElementById('item1').addEventListener('click', function () {
  alert('Item 1 被点击');
});
document.getElementById('item2').addEventListener('click', function () {
  alert('Item 2 被点击');
});
document.getElementById('item3').addEventListener('click', function () {
  alert('Item 3 被点击');
});

问题

  • 每个子元素都要绑定事件,浪费资源,代码冗余。
  • 如果动态添加新元素,不会触发事件

4.2 使用事件委托(给父元素绑定事件)

js
document.querySelector('ul').addEventListener('click', function (event) {
  if (event.target.tagName === 'LI') {
    alert(event.target.innerText + ' 被点击');
  }
});

优化点

  • 只绑定一次事件
  • 支持动态新增的 li

5. 适用场景

5.1 处理动态列表

html
<ul id="list">
  <li>Item 1</li>
  <li>Item 2</li>
</ul>
<button id="addItem">添加新项</button>
js
document.getElementById('list').addEventListener('click', function (event) {
  if (event.target.tagName === 'LI') {
    alert(event.target.innerText + ' 被点击');
  }
});

document.getElementById('addItem').addEventListener('click', function () {
  let newItem = document.createElement('li');
  newItem.innerText = '新项';
  document.getElementById('list').appendChild(newItem);
});

效果

  • 点击 添加新项 按钮,新增 li
  • 点击 li,仍然可以触发事件

5.2 处理表单事件

有时,我们希望在多个 input 框上监听输入事件

html
<form id="myForm">
  <input type="text" name="username" placeholder="用户名">
  <input type="text" name="email" placeholder="邮箱">
</form>
js
document.getElementById('myForm').addEventListener('input', function (event) {
  if (event.target.tagName === 'INPUT') {
    console.log(event.target.name + ': ' + event.target.value);
  }
});

好处

  • 只绑定一次事件
  • 可以处理所有 input 事件,即使是动态添加的 input

5.3 处理按钮点击

html
<div id="container">
  <button class="btn">按钮 1</button>
  <button class="btn">按钮 2</button>
</div>
js
document.getElementById('container').addEventListener('click', function (event) {
  if (event.target.classList.contains('btn')) {
    alert(event.target.innerText + ' 被点击');
  }
});

使用 classList.contains() 过滤目标


6. 事件委托适用于哪些事件?

✅ 适用于冒泡事件

事件说明
click用户点击
dblclick双击
mousedown / mouseup鼠标按下/释放
mousemove鼠标移动
mouseover / mouseout鼠标进入/离开
keydown / keyup键盘按下/释放
input输入框变化
change复选框、选择框变化

🚫 不适用于不会冒泡的事件

事件替代方案
blur / focusfocusin / focusout
mouseenter / mouseleavemouseover / mouseout
scroll不能用事件委托

7. 事件委托 vs 直接绑定

方式事件绑定位置是否支持动态元素性能代码量
直接绑定目标元素❌ 否差(多个事件)代码多
事件委托父级元素✅ 是优(一个事件)代码少

8. 事件委托 + stopPropagation()

有时,我们可能不希望事件进一步冒泡,可以使用 stopPropagation()

html
<div id="parent">
  <button id="child">子按钮</button>
</div>
js
document.getElementById('parent').addEventListener('click', function () {
  alert('父元素被点击');
});

document.getElementById('child').addEventListener('click', function (event) {
  event.stopPropagation(); // 阻止冒泡
  alert('子按钮被点击');
});

效果

  • 点击 子按钮只弹出 "子按钮被点击"
  • 点击 父元素只弹出 "父元素被点击"

9. 总结

  • 事件委托通过事件冒泡机制,在父元素上监听子元素的事件,提高性能。
  • 适用于动态元素,减少事件绑定,代码更简洁。
  • 适用于 clickinput 等冒泡事件,不适用于 focusscroll
  • 可以配合 stopPropagation() 控制事件传播。

🔥 事件委托是高性能 JavaScript 代码的最佳实践之一,前端开发必须掌握! 🔥

4. 同步和异步

在 JavaScript 中,同步(Synchronous)异步(Asynchronous) 是代码执行的两种方式,影响程序的执行顺序和性能。


1. 什么是同步(Synchronous)?

同步 指的是 代码按顺序执行,当前代码执行完毕后,才会继续执行下一个操作。所有任务在主线程上排队执行,不会跳过任何任务

示例:同步代码

js
console.log("1");
console.log("2");
console.log("3");

执行顺序

1
2
3

特点

  • 代码按顺序执行,一个任务执行完才能执行下一个。
  • 阻塞:如果某个任务执行时间过长,整个程序会被卡住,不能执行后续代码。

🚫 缺点

  • 执行慢:如果某个任务(如 while 循环或 I/O 操作)耗时过长,会阻塞后面的代码,导致页面无响应(卡死)。

2. 什么是异步(Asynchronous)?

异步 指的是 不会阻塞主线程,而是让任务执行时先跳过,继续执行后续代码,等到异步任务完成后再执行回调。

示例:异步代码

js
console.log("1");

setTimeout(() => {
  console.log("2");
}, 1000);

console.log("3");

执行顺序

1
3
2  (延迟 1 秒后执行)

特点

  • 不会阻塞主线程,能同时处理多个任务。
  • 执行效率高,适用于 I/O 操作网络请求定时器事件监听 等。
  • 任务完成后,使用回调函数或 Promise 处理结果

3. 同步 vs 异步

同步(Synchronous)异步(Asynchronous)
执行顺序按顺序执行,等待前一个任务完成先执行后续代码,异步任务完成后再执行
是否阻塞主线程
适用场景计算任务、简单逻辑I/O 操作、网络请求、定时任务
缺点执行慢,可能卡住需要回调、Promise、async/await

4. JavaScript 中的异步实现方式

4.1 回调函数(Callback)

回调函数 是最基础的异步处理方式,常用于 事件监听、定时器、文件读取、网络请求

示例:回调函数

js
function fetchData(callback) {
  setTimeout(() => {
    callback("数据加载完成");
  }, 1000);
}

fetchData((data) => {
  console.log(data);
});
console.log("程序继续执行");

执行顺序

程序继续执行
数据加载完成(1 秒后)

🚫 问题

  • 回调地狱(Callback Hell):嵌套太深,代码难以阅读和维护。
  • 可读性差,容易出错

回调地狱示例

js
getData1(() => {
  getData2(() => {
    getData3(() => {
      console.log("数据获取完成");
    });
  });
});

4.2 Promise(ES6)

Promise解决回调地狱的一种方式,提供 .then().catch() 方法,使代码更清晰。

示例:Promise

js
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("数据加载成功");
    }, 1000);
  });
}

fetchData().then((data) => {
  console.log(data);
});
console.log("程序继续执行");

执行顺序

程序继续执行
数据加载成功(1 秒后)

优点

  • 避免回调地狱,代码更清晰。
  • 支持链式调用

🚫 缺点

  • 仍然需要 .then(),逻辑较复杂。

4.3 async/await(ES7)

async/awaitPromise 的语法糖,可以让异步代码看起来像同步代码,提高可读性。

示例:async/await

js
function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("数据加载成功");
    }, 1000);
  });
}

async function loadData() {
  console.log("开始请求数据");
  let data = await fetchData();
  console.log(data);
}

loadData();
console.log("程序继续执行");

执行顺序

开始请求数据
程序继续执行
数据加载成功(1 秒后)

优点

  • 语法简洁,易读易写
  • 像同步代码一样执行,减少回调嵌套。

🚫 缺点

  • 需要配合 Promise 使用。
  • try/catch 处理错误

5. 真实场景:网络请求

同步请求(阻塞)

js
// 发送 HTTP 请求(同步方式)
let response = fetch('https://api.example.com/data');
console.log(response);

⚠️ 问题:同步请求会阻塞代码,用户体验差。


异步请求(推荐)

js
// 方式 1:Promise
fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

// 方式 2:async/await
async function getData() {
  try {
    let response = await fetch("https://api.example.com/data");
    let data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

getData();

优点

  • 异步请求,不会阻塞主线程
  • 支持 .catch() 处理错误

6. 事件循环(Event Loop)

JavaScript 是单线程的,但它通过 事件循环(Event Loop) 处理异步任务。

事件循环工作原理

  1. 同步代码 先执行(主线程)。
  2. 异步任务(如 setTimeoutPromise)放入任务队列
  3. 主线程空闲时,从任务队列取出任务执行。
  4. 继续循环,直到所有任务完成

示例:事件循环

js
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

执行顺序

1
4
3
2

规则

  1. 同步代码 先执行(1、4)。
  2. Promise 任务(微任务) 先执行(3)。
  3. setTimeout 任务(宏任务) 后执行(2)。

7. 总结

同步(Synchronous)异步(Asynchronous)
执行顺序按顺序执行先执行同步代码,异步任务稍后执行
是否阻塞
实现方式直接执行setTimeout / Promise / async/await
适用场景简单逻辑I/O、网络请求、事件监听

🔥 现代 JavaScript 推荐使用 async/await 处理异步,提高代码可读性!

5. 事件循环

因为 js 是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码时,如果遇到异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件执行完毕后,再将异步事件对应的回调加入到一个任务队列中等待执行。任务队列可以分为宏任务队列和微任务队列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务队列中的任务都执行完成后再去执行宏任务队列中的任务。

Event Loop 执行顺序如下所示:

  • 首先执行同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码

6. 宏任务和微任务

  • 微任务包括: promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。
  • 宏任务包括: script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。

8. 执行栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。 当开始执行 JS 代码时,根据先进后出的原则,后执行的函数会先弹出栈,可以看到,foo 函数后执行,当执行完毕后就从栈中弹出了。

平时在开发中,可以在报错中找到执行栈的痕迹:

js
function foo() {   
	throw new Error('error') 
} 

function bar() {   
	foo() 
} 

bar()

可以看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题

js
function bar() {  bar()}bar()

9. Node 中的 Event Loop 和浏览器中的有什么区别?process.nextTick 执行顺序?

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

(1)Timers(计时器阶段):初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeout 和 setInterval),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。

(2)Pending callbacks:执行推迟到下一个循环迭代的I / O回调(系统调用相关的回调)。

(3)Idle/Prepare:仅供内部使用。

(4)Poll(轮询阶段)

  • 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
  • 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(setTimeout、setInterval和setImmediate)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的I/O操作完成,并马上执行相应的回调,直到所有回调执行完毕。

(5)Check(查询阶段):会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。

(6)Close callbacks:执行一些关闭回调,比如socket.on('close', ...)等。

下面来看一个例子,首先在有些情况下,定时器的执行顺序其实是随机

js
setTimeout(() => {    
	console.log('setTimeout')
}, 0)

setImmediate(() => {    
	console.log('setImmediate')
})

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

js
const fs = require('fs') 
fs.readFile(__filename, () => {     
	setTimeout(() => {         
		console.log('timeout');     
	}, 0)     
	
	setImmediate(() => {         
		console.log('immediate')     
	}) 
})

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask

js
setTimeout(() => {
  console.log('timer21')
}, 0)
Promise.resolve().then(function() {
  console.log('promise1')
})

对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。

最后来看 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

js
setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})

对于以上代码,永远都是先把 nextTick 全部打印出来。

10. 事件触发的过程是怎样的

事件触发有三个阶段:

  • window 往事件触发处传播,遇到注册的捕获事件会触发
  • 传播到事件触发处时触发注册的事件
  • 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行

js
// 以下会先打印冒泡然后是捕获
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)

通常使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 falseuseCapture 决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性:

  • capture:布尔值,和 useCapture 作用一样
  • once:布尔值,值为 true 表示该回调只会调用一次,调用后会移除监听
  • passive:布尔值,表示永远不会调用 preventDefault

一般来说,如果只希望事件只触发在目标上,这时候可以使用 stopPropagation 来阻止事件的进一步传播。通常认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。

stopImmediatePropagation 同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。

js
node.addEventListener(
  'click',
  event => {
    event.stopImmediatePropagation()
    console.log('冒泡')
  },
  false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)

评论区

欢迎留言、补充或勘误。

xiaoba.blog