浏览器基础知识-浏览器事件机制

0 阅读16分钟

事件是什么?事件模型?(重点)

事件是用户操作网页时发生的交互动作,比如 click/move, 事件除了用户触发的动作外,还可以是网页本身的一些操作,比如文档加载、窗口滚动和大小调整。

事件被封装成一个 event 对象,包含了该事件发生时的所有相关信息(event 的属性)以及可以对事件进行的操作(event 的方法)。

现代浏览器一共有三种事件模型

(1)DOM0 级事件模型

DOM0 级事件模型本身没有事件流的概念,但现代浏览器在实现上会以冒泡的方式处理 DOM0 级事件。它可以在网页中直接定义监听函数,也可以通过 JS 属性来指定监听函数。

所有浏览器都兼容这种方式。

直接在 dom 对象上注册事件名称,就是 DOM0 写法。

<!-- 1. HTML属性写法 -->

<button onclick="alert('Clicked!')">点击</button>
// 2. JavaScript属性写法(最常用)

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

// 添加事件
btn.onclick = function() {
    console.log('按钮被点击');
    console.log(this); // this指向当前元素
};

// 可以覆盖前一个事件
btn.onclick = function() {
    console.log('新的点击处理函数');
};

// 移除事件
btn.onclick = null;

(2)IE 事件模型

在该事件模型中,一次事件共有两个过程,目标阶段和事件冒泡阶段。

目标阶段,会执行目标元素绑定的监听事件。

然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。

这种模型通过 attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行。

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

// 添加事件(注意事件名前要加'on')
btn.attachEvent('onclick', function() {
    console.log('按钮被点击');
    console.log(this === window); // true,this指向window
});

// 可以添加多个目标阶段的处理函数
btn.attachEvent('onclick', function() {
    console.log('第二个处理函数');
});

// 移除事件(需要保留函数引用)
const handler = function() {
    console.log('可移除的处理函数');
};
btn.attachEvent('onclick', handler);
btn.detachEvent('onclick', handler);

(3)DOM2 级事件模型

在该事件模型中,一次事件共有三个过程,包括事件捕获、目标阶段、事件冒泡

第一个过程是事件捕获阶段。捕获指的是事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。

后面两个阶段和 IE 事件模型的两个阶段相同。

这种事件模型,事件绑定的函数是 addEventListener,它的第三个参数可以指定事件是否在捕获阶段执行,默认是 false 即冒泡阶段执行。

对事件委托的理解(重点)

概念

事件委托本质上是利用了浏览器事件冒泡的机制。因为事件在冒泡过程中会上传到父节点,父节点可以通过事件对象获取到目标节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件,这种方式称为事件委托(事件代理)。

使用事件委托可以不必要为每一个子元素都绑定一个监听事件,这样减少了内存上的消耗。并且使用事件代理还可以实现事件的动态绑定,比如说新增了一个子节点,并不需要单独地为它添加一个监听事件,它绑定的事件会交给父元素中的监听函数来处理。

特点

(1)减少内存消耗

如果列表有大量的列表项且需要在点击列表项的时候响应事件,如果给每个列表项都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能。因此,比较好的方法就是把这个点击事件绑定到他的父层,然后在执行事件时再去匹配判断目标元素,所以事件委托可以减少大量的内存消耗,节约效率。

(2)动态绑定事件

给每个列表项都绑定事件,在很多时候,需要通过 AJAX 或者用户操作动态地增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件。

如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的,所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的。

局限性

focus、blur 之类的事件没有事件冒泡机制,但现代浏览器提供了对应的冒泡版本:focusin 和 focusout,可以用于事件委托。

mousemove、mouseout 这样的事件虽然有事件冒泡,但由于触发频率极高,使用事件委托会导致频繁的位置计算和匹配,对性能消耗较高,因此通常不适合使用事件委托。

事件委托会影响页面性能,主要影响因素有:

  1. 元素中,绑定事件委托的次数
  2. 点击的最底层元素,到绑定事件元素之间的 DOM 层数

在必须使用事件委托的地方,可以进行如下处理:

  1. 只在必须的地方,使用事件委托,比如:AJAX 的局部刷新区域
  2. 尽量地减少绑定的层级,不在 body 元素上进行绑定
  3. 减少绑定的次数,可以把多个事件的绑定合并到一次事件委托中去,由这个事件委托的回调来进行分发

性能优化实践

  1. 限制事件委托的范围:尽量在最接近目标元素的父元素上绑定事件,减少事件传播路径
  2. 使用事件类型过滤:在事件处理函数中先判断事件类型,避免不必要的处理
  3. 避免在事件委托中进行复杂计算:将复杂逻辑移到事件处理函数之外
  4. 使用 passive 选项:对于触摸和滚动事件,使用 passive: true 提升性能

事件委托的使用场景(重点)

场景:给页面的所有的 a 标签添加 click 事件,但是这些 a 标签可能包含一些像 span、img 等元素,如果点击到了这些 a 标签中的元素,就不会触发 click 事件,因为事件绑定上在 a 标签元素上,而触发这些内部的元素时,e.target 指向的是触发 click 事件的元素(span、img 等其他元素)。

这种情况下就可以使用事件委托来处理,将事件绑定在 a 标签的内部元素上,当点击它的时候,就会逐级向上查找,知道找到 a 标签为止。

document.addEventListener("click", function(e) {
    let node = e.target;
    // 使用closest方法简化 DOM 查找过程
    const link = node.closest('a');
    if (link) {
        console.log("a");
    }
}, false);

对事件循环的理解(重点)

因为 JS 是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。

在执行同步代码时,如果遇到异步事件,JS 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。

当异步事件执行完毕后,再将异步事件对应的回调加入到一个任务队列中等待执行。

任务队列可以分为宏任务队列微任务队列。当前执行栈中的事件执行完毕后,JS 引擎首先会判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行;当微任务队列中的任务都执行完成后再去执行宏任务队列中的任务。

Event Loop 的执行顺序

  1. 首先,执行宏任务中的同步代码

整个 <script> 标签内的代码本身就是一个宏任务。事件循环开始时,执行的就是这个初始的宏任务(包含所有同步代码)。

事件循环的每一轮,都会从宏任务队列中取出一个任务(这个任务可能是初始脚本、setTimeout 回调、事件回调等),这个任务的执行就是“同步代码”的执行阶段。在这个阶段中,可能会安排新的微任务和宏任务。

  1. 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行

每次从宏任务队列取出的第一个任务,其执行过程就是执行其中的同步代码,然后处理该任务产生的所有微任务。

  1. 执行所有微任务。

  2. 当执行完所有微任务后,如有必要会渲染页面。

  3. 然后开始下一轮 Event Loop

宏任务和微任务分别有哪些?(重点)

宏任务

包括: script 脚本的执行,setTimeout、setInterval、setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。

微任务

包括: Promise 的回调,对 DOM 变化监听的 MutationObserver,Node 中的 process.nextTick。

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(轮询阶段)

当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。

当回调队列为空时(没有回调或所有回调执行完毕):如果存在 setImmediate 没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的 I/O 操作完成,并马上执行相应的回调,直到所有回调执行完毕。

(5)Check(查询阶段)

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

(6)Close callbacks

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

例子

首先,在有些情况下,定时器的执行顺序其实是随机的。

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

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

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

在某些情况下,执行顺序一定是固定的。

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

在上述代码中,setImmediate 永远先执行,因为:

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

对于 microtask 微任务来说,它会在以上每个阶段完成前清空 microtask 队列。

setTimeout(() => {
    console.log('timeout')
}, 0)
Promise.resolve().then(function() {
    console.log('promise')
})

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

Node 中的 process.nextTick

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

setTimeout(() => {
    console.log('timeout')
    Promise.resolve().then(function() {
        console.log('promise')
    })
}, 0)
process.nextTick(() => {
  console.log('nextTick1')
  process.nextTick(() => {
      console.log('nextTick2')
      process.nextTick(() => {
          console.log('nextTick3')
          process.nextTick(() => {
              console.log('nextTick4')
          })
      })
  })
})

// 输出如下:
nextTick1
nextTick2
nextTick3
nextTick4
timeout
promise

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

如何阻止事件冒泡?

普通浏览器

使用:event.stopPropagation()

IE 浏览器

使用:event.cancelBubble = true

同步和异步的区别

同步

指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行。

异步

指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待这个请求的返回,当这个请求返回时系统再通知进程进行处理。

什么是执行栈?

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

当开始执行 JS 代码时,根据先进后出的原则,后执行的函数会先弹出栈。

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

// 平时在开发中,可以在报错中找到执行栈的痕迹。
// 可以看到,foo 函数后执行,当执行完毕后就从栈中弹出了。
// 可以看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。

当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题。

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

事件触发的过程是怎样的?

事件触发有三个阶段

  1. window 往事件触发处传播,遇到注册的捕获事件会触发
  2. 传播到事件触发处时触发注册的事件
  3. 从事件触发处往 window 传播,遇到注册的冒泡事件会触发
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)
  • 会先打印捕获然后是冒泡。
  • 因为,无论注册顺序如何,捕获阶段总是先于冒泡阶段执行,
  • 执行顺序仍由事件流阶段决定,而非注册顺序。

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果对目标元素同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。

<div id="parent">
    <button id="child">点击我</button>
</div>

<script>
    const parent = document.getElementById('parent')
    const child = document.getElementById('child')
    
    // 父元素:正常按阶段执行
    parent.addEventListener('click', () => console.log('父捕获'), true)
    parent.addEventListener('click', () => console.log('父冒泡'), false)
    
    // 目标元素:按注册顺序执行
    child.addEventListener('click', () => console.log('子冒泡1'), false)
    child.addEventListener('click', () => console.log('子捕获1'), true)
    child.addEventListener('click', () => console.log('子冒泡2'), false)
    child.addEventListener('click', () => console.log('子捕获2'), true)

    // 点击 child 时的输出
    父捕获
    子冒泡1  // 目标元素,按注册顺序
    子捕获1   // 目标元素,按注册顺序  
    子冒泡2  // 目标元素,按注册顺序
    子捕获2   // 目标元素,按注册顺序
    父冒泡
</script>

addEventListener

通常使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值(useCapture),也可以是配置对象(options)。

对于布尔值 useCapture 参数来说,该参数默认值为 false 即注册的是冒泡事件,useCapture 决定了注册的事件是捕获事件还是冒泡事件。

对于对象参数来说,可以使用以下几个属性:

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

阻止事件传播

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

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

可访问性最佳实践

  1. 使用语义化HTML:确保事件绑定在语义化元素上,提升屏幕阅读器兼容性
  2. 提供键盘支持:确保所有交互都可以通过键盘操作完成
  3. 避免阻止默认行为:除非必要,不要阻止浏览器默认行为,影响可访问性
  4. 使用ARIA属性:对于自定义交互组件,添加适当的 ARIA 角色和属性

总结

本文详细介绍了浏览器事件机制的核心概念,包括事件模型、事件委托、事件循环等重要知识点。通过学习这些内容,开发者可以深入理解浏览器事件的工作原理,掌握事件处理的最佳实践,从而编写出更加高效、健壮的前端代码。

文章主要涵盖了以下几个方面:

  1. 事件模型:介绍了 DOM0 级、IE 和 DOM2 级三种事件模型的特点和使用方法
  2. 事件委托:解释了事件委托的原理、特点、局限性和使用场景
  3. 事件循环:详细讲解了浏览器和 Node 中事件循环的执行机制
  4. 宏任务和微任务:区分了宏任务和微任务的类型和执行顺序
  5. 事件触发过程:描述了事件捕获、处理和冒泡的完整流程

掌握这些基础知识对于前端开发者来说至关重要,它们是构建复杂交互应用的基石。