【前端进阶】浏览器事件代理机制的原理是什么?

背景:由高级前端工程师@刘小夕 在github上发起的一个开源项目:每个工作日发布一个前端相关的问题,每周进行一次汇总。我觉得很不错,也参与其中,如果你也感兴趣,可以一起参与,项目地址:https://github.com/YvetteLau/Step-By-Step

在此之前,我们先来了解下其他相关的一些概念,有助于我们理解浏览器事件代理机制的原理。

事件

JavaScript与HTML之间的交互是通过事件实现的。

事件,就是文档或浏览器窗口中发生的一些特定的交互瞬间。可以使用侦听器(或事件处理程序)来预定事件,以便事件发生时执行相应的代码。

像鼠标点击、页面或图像载入、键盘按键等操作。事件通常与函数配合使用,当事件发生时函数才会执行。

事件名称:clickmouseoverblur等(不带on)。

响应某个事件的函数就是事件处理程序(事件侦听器)。

事件处理程序函数名称:onclickonmouseoveronblur等。

事件流

事件流描述的是从页面中接受事件的顺序。

IE和Netscape开发团队(网景)居然提出了差不多是完全相反的事件流的概念。

IE的事件流是事件冒泡流,而网景的事件流是事件捕获流。

事件冒泡

即事件开始时由最具体的元素(文档中嵌套层次最深的那个节点)接收,然后逐级向上传播到较为不具体的节点(文档)。

<!DOCTYPE html>
<html>
<head>
  <title>Event Bubbling Example</title>
</head>
<body>
  <div id="myDiv">Click Me</div>
</body>
</html>

如果你单击了页面中的div元素,那么这个click事件会按照如下顺序传播:

  • <div>
  • <body>
  • <html>
  • document

事件捕获

事件捕获的思想是不太具体的节点应该更早接收到事件,而最具体的节点应该最后接收到事件。

仍以上面的代码做例子,那么单击div元素就会以下列顺序触发click事件:

  • document
  • <html>
  • <body>
  • <div>

DOM事件流

DOM2级事件规定的事件流包括三个阶段:事件捕获阶段、处于目标阶段和事件冒泡阶段。

首先发生的是事件捕获,为截获事件提供了机会。然后是实际的目标接收到事件。最后一个阶段是冒泡阶段,可以在这个阶段对事件作出相应。

事件处理程序

  • HTML事件处理程序
  • DOM0 级事件处理程序
  • DOM2 级事件处理程序
  • IE事件处理程序
  • 跨浏览器的事件处理程序

HTML事件处理程序

它是写在HTML里的,是全局作用域。

<button onclick="alert('hello')"></button>

当我们需要使用一个复杂的函数时,将js代码写在这里,显然很不合适,所以有了下面这种写法:

<!-- 点击事件触发doSomething()函数,这个函数写在单独的js或<script>之中 -->
<button onclick="doSomething()"></button>

这样会出现一个时差问题,当用户在HTML元素出现一开始就进行点击,有可能js还没加载好,这时候就会报错。但我们可以将函数封装在try-catch来处理:

<button onclick="try{doSomething();}catch(err){}"></button>

同时,一个函数的改变,同时可能会涉及html和js的修改,这样是很不方便的,综上,才有了DOM0 级事件处理程序。

DOM0 级事件处理程序

<button id="btn">点击</button>

<script>
  var btn = document.getElementById('btn');
  btn.onclick = function() {
    alert('hello');
  }
</script>

可以看到button.onlick这种形式,这里事件处理程序作为btn对象的方法,是局部作用域。

所以我们可以用

btn.onclick = null;  // 删除指定的事件处理程序

如果我们尝试添加两个事件:

<button id="btn">点击</button>

<script>
  var btn = document.getElementById('btn');
  btn.onclick = function() {
    alert('hello');
  }

  btn.onclick = function() {
    alert('hello again');
  }
</script>

结果输出hello again,很明显第一个事件函数被第二个事件函数给覆盖了。所以,DOM0 级事件处理程序不能添加多个,也不能控制事件流到底是捕获还是冒泡。

DOM2 级事件处理程序(不支持IE)

进一步规范之后,有了DOM2 级事件处理程序,其中定义了两个方法:

  • addEventListener:添加事件侦听器
  • removeEventListener:删除事件侦听器

具体用法看:

https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener

这两个方法都有三个参数:

  • 第一个参数:要处理的事件名(不带on的前缀才是事件名)
  • 第二个参数:作为事件处理程序的函数
  • 第三个参数:是一个boolean值,默认false表示使用冒泡机制,true表示捕获机制
<button id="btn">点击</button>

<script>
  var btn = document.getElementById('btn');

  btn.addEventListener('click', 'hello', false);
  btn.addEventListener('click', 'helloAgain', false);

  function hello() {
    alert('hello');
  }

  function helloAgain() {
    alert('hello again');
  }
</script>

这时候,这两个事件处理程序都能够被触发,说明可以绑定多个事件处理程序,但是注意,如果定义了一模一样的监听方法,是会发生覆盖的,即同样的事件和事件流机制下相同方法只会触发一次。比如:

<button id="btn">点击</button>

<script>
  var btn = document.getElementById('btn');

  btn.addEventListener('click', 'hello', false);
  btn.addEventListener('click', 'hello', false);

  function hello() {
    alert('hello');
  }
</script>

removeEventListener()的用法几乎和添加时的用法一模一样:

<button id="btn">点击</button>

<script>
  var btn = document.getElementById('btn');

  btn.addEventListener('click', 'hello', false);
  btn.removeEventListener('click', 'hello', false);

  function hello() {
    alert('hello');
  }
</script>

这样的话,事件处理程序只会执行一次。

但是要注意,如果同一个监听事件分别为“事件捕获”和“事件冒泡”注册了一次,一共两次,这两次事件需要分别移除。两者不会互相干扰。

这时候的this指向该元素的引用。这里事件触发的顺序是添加的顺序。

IE事件处理程序

对于IE来说,在IE9之前,你必须使用attachEvent而不是使用标准方法addEventListener

IE事件处理程序中有类似DOM2 级事件处理程序的两个方法:

  • attachEvent()
  • detachEvent()

它们都接收两个参数:

  • 事件处理程序名称:如onclickonmouseover,注意:这里不是事件,而是事件处理程序的名称,所以有on
  • 事件处理程序函数

之所以没有和DOM2 级事件处理程序中类似的第三个参数,是因为IE8及更早版本只支持冒泡事件流。

<button id="btn">点击</button>

<script>
    var btn = document.getElementById('btn');

    btn.attachEvent('onclick', hello);
    btn.detachEvent('onclick', hello);

    function hello() {
      alert('hello');
    }
</script>

注意:这里事件触发的顺序不是添加的顺序而是添加顺序的想法顺序。

使用attachEvent方法有个缺点,this的值会变成window对象的引用而不是触发事件的元素。

事件对象

事件对象是用来记录一些事件发生时的相关信息的对象。事件对象只有事件发生时才会产生,并且只能是事件处理程序内部访问,在所有事件处理函数运行结束后,事件对象就被销毁。

2级DOM中的Event对象

常用的属性和方法:

  • type: 获取事件类型
  • target:触发此事件的元素(事件的目标节点)
  • preventDefault():取消事件的默认操作,比如链接的跳转或者表单的提交,主要是用来阻止标签的默认行为
  • stopPropagation():冒泡机制下,阻止事件的进一步网上冒泡

IE中的Event对象

常用的属性和方法:

  • type:事件类型
  • srcElement:事件目标
  • 取消事件的默认操作:returnvalue = false
  • 阻止事件冒泡:cancelBubble = false

兼容性

事件对象也存在一定的兼容性问题,在IE8及以前版本之中,通过设置属性注册事件处理程序时,调用的时候并未传递事件对象,需要通过全局对象window.event来获取。解决方法如下:

function getEvent(event) {
    event = event || window.event;
}

事件代理机制的原理

事件代理又称事件委托,上面我们学习了事件会在冒泡阶段向上传播到父节点,而事件代理正是利用事件冒泡机制把一个或一组元素的事件委托到它的父节点或更外层的元素上,由父节点的监听函数统一处理多个子元素的事件。

看个例子:

var ul = document.querySelector('ul');
ul.addEventListener('click', function (event) {
    if (event.target.tagName.toLowerCase() === 'li') {
        // some code
    }
});

上面代码中,click事件的监听函数定义在<ul>节点,但是实际上,它处理的是子节点liclick事件。这样做的好处是,只要定义一个监听函数,就能处理多个子节点的事件,而不用在每个<li>节点上定义监听函数。而且以后再动态添加节点,监听函数依然有效。

如果希望事件到某个节点为止,不再传播,可以使用事件对象的stopPropagation()方法。

// 事件传播到p元素后,就不再向下传播了
p.addEventListener('click', function (event) {
    event.stopPropagation();
}, true);

// 事件冒泡到p元素后,就不再向上冒泡了
p.addEventListener('click', function (event) {
    event.stopPropagation();
}, false);

上面代码中,stopPropagation方法分别在捕获阶段和冒泡阶段,阻止了事件的传播。

注意:stopPropagation方法只会阻止事件的传播,不会阻止该事件触发<p>节点的其他click事件的监听函数。也就是说,不是彻底取消click事件。

p.addEventListener('click', function (event) {
    event.stopPropagation();
    console.log(1);
});

p.addEventListener('click', function (event) {
    // 会触发
    console.log(2);
});

上面代码中,p元素绑定了两个click事件的监听函数。stopPropagation方法只能阻止这个事件向其他元素传播。因此,第二个监听函数会触发,输出结果会先是1,再是2。

如果想要彻底阻止这个事件的传播,不再触发后面所有click的监听函数,可以使用stopImmediatePropagation方法。

p.addEventListener('click', function (event) {
    event.stopImmediatePropagation();
    console.log(1);
});

p.addEventListener('click', function (event) {
    // 不会被触发
    console.log(2);
});

事件代理机制的优点和局限性

优点

  1. 添加到页面上的事件数量过多会影响页面的运行性能,采用事件代理的方式,可以大大减少注册事件的个数
  2. 当我们动态添加子元素时,不用再对其进行事件绑定,直接的减少了DOM操作
  3. 不用担心某个注册了事件的DOM元素被移除后,可能无法回收其事件处理程序,我们只要把事件处理程序委托给更高层级的元素,就可以避免此问题
  4. 允许给一个事件注册多个监听
  5. 提供了一种更精细的手段控制listener的触发阶段(可以选择捕获或者是冒泡)
  6. 对任何DOM元素都是有效的,而不仅仅是对HTML元素有效

局限性

  • focusblur之类的事件本身没有事件冒泡机制,所以无法委托;
  • mousemovemouseout这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合事件委托的
  • 层级过多,冒泡过程中,可能会被某层阻止掉(建议就近委托)

参考:

前端小知识–JavaScript事件流

事件代理