最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 深入理解浏览器的事件机制

    正文概述 掘金(sunshine小小倩)   2021-01-07   531

    深入理解浏览器的事件机制

    开始写这篇文章的初衷是遇到了一个问题:在移动端的网页中写弹出层,在弹出层弹出之后,滚动页面,底层元素会跟着一起滚动。这应该是一个很常见的问题,开始只是想搜一下解决方案,然后越查资料越觉得自己对浏览器的事件机制了解的太少。

    本篇文章,你能够学习到:

    • 事件流相关内容:
      • 什么是事件捕获
      • 什么是事件冒泡
      • DOM 事件流是怎么一回事
    • 事件处理程序相关:
      • 什么是 HTML 事件处理程序
      • 什么是 DOM0 事件处理程序
      • 什么是 DOM2 事件处理程序
        • addEventListener()第三个参数详解
      • IE 事件处理程序
      • 四者有什么区别
    • 事件对象相关内容:
      • DOM 事件对象

        • preventDefault()
        • stopPropagation()
          • 并不是所有事件都是冒泡的:? scroll
      • IE 事件对象

    • 怎么解决滚动穿透问题
      • addEventListener()第三个参数设置为 true
      • stopPropagation()阻止冒泡
      • ✅ preventDefault 阻止默认事件
      • ✅ 给外层元素设置 overflow:hidden

    浏览器的事件机制

    我们面试的时候很常见的一个问题:浏览器的事件机制,关键词:事件捕获、事件冒泡、DOM Level 0事件、DOM Level 2事件

    我个人的情况是在平时的开发中,虽然经常给元素添加事件,但是都是使用框架封装好的事件方法,比如 ReactonClick,极少数情况会使用到 addEventListener,之前我一直以为两者是一样的,只是写法的不同。

    之前在看面经的时候也看到过一些关于浏览器事件机制的文章,但是也都感觉对这块不是很清晰。知道一些概念但是不是很明确。

    最后突然想起来,这种概念性的东西,还是要翻一翻字典呀!于是就翻了下红宝书(《JavaScript 高级程序设计》),果然对这些虽然是我们经常使用的东西有了更深入的了解。这大概就是温故而知新吧,hhhhh

    事件流

    JS 与 HTML 的交互是用事件实现的。事件流描述了页面接收事件的顺序。

    事件冒泡

    事件冒泡是 IE 团队提出的事件流方案,根据名字我们就可以看出,事件冒泡是从最具体的元素开始触发事件,然后向上传播至没有那么具体的元素(或文档)。

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div id="div"> Click me</div>
    </body>
    <script>
      var div = document.querySelector('#div');
      div.addEventListener('click', () => {
        console.log('div click');
      })
    
      var body = document.querySelector('body');
      body.addEventListener('click', () => {
        console.log('body click');
      })
    
      var html = document.querySelector('html');
      html.addEventListener('click', () => {
        console.log('html click');
      })
    
      var document = document.querySelector('document');
      document.addEventListener('click', () => {
        console.log('document click');
      })
    </script>
    </html>
    

    如上是一个最基本的 HTML 代码,我们点击 div  之后,输出的 log 是:

    div click
    body click
    html click
    document click
    

    也就是说:我们点击了 div 元素之后,click 事件会沿着 dom 树一路向上,在经过的每一个节点上依次触发。直到 document  元素。(即使 div 元素没有绑定 click 事件, body  元素绑定了 click  事件, click  事件也是会冒泡到 body 的) 深入理解浏览器的事件机制 所有的现代浏览器都是支持事件冒泡的。

    事件捕获

    事件捕获是 Netscape 开发团队提出的事件流解决方案。和事件冒泡相反,事件捕获是从最不具体的节点最先接收事件,向下传播至最具体的节点。事件捕获实际上是为了在事件到达最终目标前拦截事件。 深入理解浏览器的事件机制 旧版本浏览器不支持事件捕获。

    DOM 事件流

    DOM2 Events 规范规定事件流分为 3 个阶段: 事件捕获、到达目标 和 事件冒泡。事件捕获最先发生,为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个阶段响应事件。深入理解浏览器的事件机制 所有现在浏览器都支持 DOM 事件流,只有 IE8 以及更早的浏览器不支持。

    这里我之前一直有一个误区,认为整个事件流中监听事件只能被触发一次,就是事件流中无论是在事件冒泡还是事件捕获阶段,只要被了响应一次,就不会再传递了。但实际上不是,只要没有显示的阻止( stopPropagation ) 事件传递,那么就会按照事件流传递。

    事件处理程序

    为了响应用户或者浏览器执行的某种动作( click 、 load 、 mouseover ... )而调用的 on 开头的函数被称为事件处理程序(事件监听器)。

    HTML 事件处理程序

    特定的元素支持的每个事件都可以用 HTML 属性的形式使用事件处理程序。其实也就是我们最常使用的方式,如下代码, onclick  属性的值是 JS 代码或者其他的调用方法。

    使用事件监听器,浏览器会先创建一个函数来封装属性的值,这个函数有一个特殊的局部变量:event 用来保存 event 对象,事件处理函数中的 this 指向事件的目标元素。

    <input type="button" value="click me" onclick="console.log('click')" />
    

    DOM0 事件处理程序

    在 JavaScript 中创建事件监听器的传统方式是把一个函数赋值给 DOM 元素。兼容性最好,所有的浏览器都支持此方法。

    每个元素(包括 window 和 document)都有事件处理程序的属性(一般都 onxxxx),这个属性的值为一个函数。

    const btn = document.getElementById("myBtn");
    btn.onclick = function(){
      console.log('Clicked')
    }
    

    这样使用 DOM0 事件处理是发生在程序赋值时注册在事件流的冒泡阶段的。

    所赋值的函数被视为元素的方法,在元素的作用域中运行,this 指向该元素本身。在事件处理程序中通过 this 可以访问元素的任何属性和方法。

    将事件处理程序属性设置为 null,即可移除通过 DOM0 方式添加的事件处理程序。

    btn.onclick = null;
    

    如果有多个 DOM0 事件处理程序的话,后面的是会把前面的给覆盖掉。只有执行最后一个调用的结果。

    DOM2 事件处理程序

    我们也可以通过在所有的 DOM 节点上通过 addEventListener()removeEventLinstener() 来添加和移除事件处理程序。

    addEventListener()removeEventLinstener() 接收 3 个参数:事件名、事件处理函数 和 一个 option 对象或一个布尔值 useCapturetrue  表示在捕获阶段调用事件处理程序, false (默认值)表示在冒泡阶段调用事件处理程序,因为跨浏览器兼容性好,所以事件处理程序默认会被添加到事件流的冒泡阶段(也就是默认最后一个参数为 false ))。 addEventListener(type, listener, useCapture | options)

    option 参数有一下几个选择

    capture:  Boolean,表示 listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。
    once:  Boolean,表示 listener 在添加之后最多只调用一次。如果是 true, listener 会在其被调用之后自动移除。
    passive: Boolean,设置为true时,表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。
    

    useCapture 参数如下

    useCapture  可选
    Boolean,在DOM树中,注册了listener的元素, 是否要先于它下面的EventTarget,调用该listener。 当useCapture(设为true) 时,沿着DOM树向上冒泡的事件,不会触发listener。当一个元素嵌套了另一个元素,并且两个元素都对同一事件注册了一个处理函数时,所发生的事件冒泡和事件捕获是两种不同的事件传播方式。事件传播模式决定了元素以哪个顺序接收事件。进一步的解释可以查看 事件流 及 JavaScript Event order 文档。 如果没有指定, useCapture 默认为 false 。
    

    简单的说,我个人的理解是 useCapture 参数指定了该事件处理程序触发的“时机” :是在事件流的捕获阶段还是冒泡阶段。但是,无论最后一个参数设置为什么,都不会阻碍事件流的传播。

    ?:一个页面上有一个 button,button 上添加了个 click 事件,然后我们给 body 上也新增一个 click 事件,然后分别将 useCapture 设置为 true 和 false,观察下会发生什么

      btn.addEventListener("click", (e) => {
        console.log('btn click capture ')
      }, true);
    
      btn.addEventListener("click", (e) => {
        console.log('btn click bubble ')
      });
    
      body.addEventListener("click", (e) => {
        console.log('body click capture')
      }, true);
    
      body.addEventListener("click", (e) => {
        console.log('body click bubble')
      });
    
    // body click capture
    // btn click capture 
    // btn click bubble
    // body click bubble
    

    通过 log 的结果我们可以看到,useCapture( option 中的 capture 也是一样)只是控制该事件处理程序是添加在事件流的捕获阶段还是冒泡阶段,对事件的传播是没有影响的!

    以上代码为会在事件流的指定阶段触发 click 的事件处理程序。与 DOM0 类似,这个事件处理程序同样被附加在元素的作用域中运行,所以,事件处理函数中的 this 指向的是该元素。

    DOM2 事件处理程序的一个优点是可以给一个元素添加多个事件处理程序,并按添加的顺序触发。

    使用addEventListener() 添加的事件处理程序只能使用 removeEventLinstener()移除(三个参数均一致才可以);所以,使用匿名函数添加的事件处理程序是不能被移除的。

    IE 事件处理程序

    IE 实现事件处理程序的方法是: attachEvent()detachEvent() 这两个方法接收两个同样的参数:事件处理程序的名称( eg: onclick )和事件处理函数。因为 IE8 及更早的版本只支持事件冒泡,所以使用 attachEvent() 添加的事件处理程序是添加在冒泡阶段。

    const btn = document.getElementById("myBtn");
    
    btn.attachEvent("onclick", function(){
      console.log("Clicked");
    })
    

    IE 事件处理程序和 DOM2 事件处理程序有两个不一样的地方

    1. 作用域:attachEvent()是在全局作用域中运行的,所以 attachEvent() 中的函数中的 thiswindow
    2. 执行顺序:IE 事件处理程序的执行顺序是和添加顺序相反的。

    四种事件处理程序的区别

    使用方法移除方法备注
    HTML 事件处理程序用 HTML 属性的形式使用事件处理程序 onclick="console.log('click')"同 DOM0 事件处理程序 方式event  局部变量用来保存 event  对象,函数中的 this 指向事件的目标元素DOM0 事件处理程序把一个函数赋值给 DOM 元素  btn.onclick = function(){}btn.onclick = null1. 发生在冒泡阶段2. this 指向元素本身DOM2 事件处理程序addEventListener()removeEventLinstener()1. 接收 3 个参数:事件名、事件处理函数 和 options 或一个布尔值( true 表示在捕获阶段调用事件处理程序,false(默认值)表示在冒泡阶段调用事件处理程序)2. 可以给一个元素添加多个事件处理程序,并按添加的顺序触发。3. 使用 addEventListener()  添加的事件处理程序只能使用 removeEventLinstener() 移除(三个参数一致才可以)IE 事件处理程序attachEvent()detachEvent()1.  IE8 及更早的版本只支持事件冒泡  2. attachEvent()是在全局作用域中运行的, thiswindow3. IE 事件处理程序的执行顺序是和添加顺序相反的

    事件对象

    在 DOM 中发生事件时,所有的相关信息都会被收集在一个名为 event 的对象中。这个对象包含了一些基本信息:触发事件的元素、事件的类型、以及一些与特定事件相关的其他数据(比如和鼠标事件相关的鼠标的位置信息)所有的浏览器都是支持这个 event 对象的。

    btn.onclick = function(event){
      console.log(event.type)     // click
    }
    
    btn.addEventListener("click", () => {
      console.log(event.type);    // click
    }, false);
    

    DOM 事件对象 event

    在事件处理函数的内部,this 对象始终等于 currentTarget (因为 this 是指向调用的对象的)。

    target 是事件触发的实际目标。(事件冒泡阶段可能出现 targetcurrentTarget 不相等的情况。

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div id="div"> Click me</div>
    </body>
    <script>
      var div = document.querySelector('#div');
      div.addEventListener('click', function(e){
        console.log('div click', e.currentTarget === this);    // true 
        console.log('div click', e.target === this);           // true
      })
    
      var body = document.querySelector('body');
      body.addEventListener('click', function(e){
        console.log('body click', e.currentTarget === this);   // true
        console.log('body click', e.target === this);          // false
      })
    </script>
    </html>
    

    preventDefault()

    preventDefault() 方法用于阻止事件的默认行为(比如,a 标签有跳转到 href 链接的默认行为,使用 preventDefault() 可以阻止这种导航行为)

    preventDefault()阻止的必需是可 cancelable 的元素 **

    const link = document.getElementById("myLink");
    
    link.onclick = function(event){
      event.preventDefault();
    }
    

    stopPropagation()

    stopPropagation() 方法用于立即阻止事件流在 DOM 中的传播,取消后续的事件捕获或冒泡。比如

    var div = document.querySelector('#div');
    
    div.addEventListener("click", (e) => {
      console.log("clicked");
      e.stopPropagation()
    }, false);
    
    document.body.addEventListener("click", () => {
      console.log("body clicked");
    }, false);
    
    // clicked 
    

    如果不调用 stopPropagation() 那么点击 div 会有两个 log 记录。如果加上的话,click 事件就不会传播到 body 上,只会有一个 log 记录( clicked )。

    但是需要注意的是,不是所有的事件都会冒泡!比如某些 scroll 事件就是不会冒泡的。

    scroll事件对于普通Element元素是不冒泡的

    我们可以看到在 MDN 上关于 scroll event 的描述 深入理解浏览器的事件机制

    也就是说如果触发 scroll 的元素是一个 element 的话,那么只有从 documentelement 的事件捕获和 element 的冒泡。我们写一个 dome 验证一下

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <style>
        body{
          position: relative;
          width: 100vw;
          height: 10000px;
          margin: 0;
          background-image: linear-gradient(#e66465, #9198e5);
        }
        #mask{
          position: fixed;
          width: 100vw;
          height: 100vh;
          background-color: rgba(0, 0, 0, 0.6);
          top: 0;
          left: 0;
        }
        .modal{
          position: fixed;
          top: 0;
          left: 0;
          width: 100vw;
          height: 50vh;
          background: #fff;
          overflow: scroll;
        }
      </style>
    </head>
    <body>
      <div id="mask">
        <div class="modal">
          <h1>A</h1>
          <h1>B</h1>
          <h1>C</h1>
          <h1>D</h1>
          <h1>E</h1>
          <h1>F</h1>
          <h1>G</h1>
          <h1>H</h1>
          <h1>I</h1>
          <h1>G</h1>
          <h1>K</h1>
          <h1>L</h1>
          <h1>M</h1>
        </div>
      </div>
    </body>
    <script>
      var modal = document.querySelector('.modal');
    
      function log(element, phase) {
        console.log(`scroll handler is trigged in ${element} during ${phase}`)
      }
    
      function bindEvent(element, elemName) {
        element.addEventListener('scroll', log.bind(null, elemName, 'capture'), true)
        element.addEventListener('scroll', log.bind(null, elemName, 'bubble'))
      }
    
      bindEvent(window, 'window')
      bindEvent(modal, 'modal')
    </script>
    </html>
    

    深入理解浏览器的事件机制 "但是documentdefaultViewscroll事件冒泡" 这句话的意思是如果是 defaultView默认是 window 上的 scroll 事件是冒泡的。但是由于其本身就是 DOM 树里最顶级的对象,因此只能在 window 里监视scroll的捕获阶段以及冒泡阶段。代码的话只需要将 body 内 放一个超出一屏的 div,然后监听 window 和 此 div 的 scroll 事件就可以。

    IE 事件对象

    IE 就是这么的与众不同(手动危笑)IE 事件对象是根据使用的事件处理程序不同而不同。

    • 使用 DOM0 事件处理程序,event 对象是全局对象 window 的一个属性
    • 使用 attachEvent() / HTML 属性方法处理事件处理程序,event 对象会作为唯一的参数传给处理函数(event 仍然是 window 对象的属性,只是方便将其作为参数参入)
    var div = document.querySelector('#div');
    
    div.onclick = function(){
      let event = window.event;
      console.log(evennt.type);     // click
    }
    
    div.attachEvent('onclick', function(event){
      console.log(event.type);      // click
    })
    

    防止弹窗底部滚动

    说了这么多,我们最初想解决的问题还记得吗?在弹出层弹出之后,滚动页面,由于事件冒泡,所以滚动事件会冒泡到底层元素(通常是 body)就会出现底层元素跟着一起滚动的问题(移动端触发的事件是 touchmove,不是 scroll)。 ~~ 那么我们是否能直接使用 stopPropagation()阻止事件冒泡就可以解决滚动穿透的问题呢?,大家可以自己写 dome 试验下。答案是不可以的,使用 stopPropagation()确实是可以阻止冒泡,但是导致背景层滚动的是 viewpoint。所以虽然阻止了事件冒泡,但是还是会导致背景层跟随滚动的。

    所有的滚动都是在 document 上形成了一个 pending 队列,然后按照一定的规则触发,详见W3C规范:

    当我们滚动鼠标滚轮,或者滑动手机屏幕时,触发对象可分为两种类型(详见W3C规范):

    1. viewport 被触发滚动, eventTarget 为关联的 Document
    2. element 元素被触发滚动,通常也就是我们添加 overflow 滚动属性的 element 元素, eventTarget 为相应的 node element

    注意到这里,只有两种类型,当我们触发滚轮或滑动时,如果当前元素没有设置 overflow 这样的属性,同时也没有 preventDefault 掉原生的滚动/滑动事件,那么此时触发的是 viewport 的滚动,position:fixed 的元素并没有什么例外。

    由此可见,滚动穿透问题其实并不是一个浏览器的bug(虽然在ios下fixed定位确实会导致很多bug),它是完全符合规范的,滚动的原则应该是 scrollforwhat can scroll,不应该因为某个元素的 CSS 定位导致滚轮失效或者滑动失效。

    那么如果不是事件冒泡,为什么会发生滚动穿透的问题呢?如果当前滚动的元素可以滚动,那么就会在当前元素触发 scroll 事件。如果当前的元素不能滚动(或滚动到边界不能再继续滚动),那么就会滚动外层元素。

    那么还有一个解决办法,直接使用 e.preventDefault() 阻止掉弹出层默认的滚动事件。

    preventDefault 阻止弹出层默认的滚动事件。

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <style>
        body{
          position: relative;
          width: 100vw;
          height: 10000px;
          margin: 0;
          background-image: linear-gradient(#e66465, #9198e5);
        }
        #mask{
          position: fixed;
          width: 100vw;
          height: 100vh;
          background-color: rgba(0, 0, 0, 0.6);
          top: 0;
          left: 0;
        }
      </style>
    </head>
    <body>
      <div id="mask"> mask </div>
    </body>
    <script>
      var mask = document.querySelector('#mask');
    
      mask.addEventListener("touchmove", (e) => {
        e.preventDefault()
      }, {
        passive: false
      });
    </script>
    </html>
    

    有一个小细节是我们将 addEventListener最后一个参数设置为了 { passive: false } ,这里是一个小优化:网站使用被动事件监听器以提升滚动性能。

    但是使用这个方法还有两个问题:

    • 因为阻止掉了弹出层的 touch event,所以弹出层内的滚动也会失效
    • 弹出层滚动到最下方的时候还是能够触发 body 的滚动的。

    所以我们还要进行优化,来确定什么时候禁止滚动:滚动到边界(上 下 左 右)的时候,如果继续滚动就禁止?。

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
      <style>
        body{
          position: relative;
          width: 100vw;
          height: 10000px;
          margin: 0;
          background-image: linear-gradient(#e66465, #9198e5);
        }
        #mask{
          position: fixed;
          width: 100vw;
          height: 100vh;
          background-color: rgba(0, 0, 0, 0.6);
          top: 0;
          left: 0;
        }
        .modal{
          position: fixed;
          top: 0;
          left: 0;
          width: 100vw;
          height: 50vh;
          background: #fff;
          overflow: scroll;
        }
      </style>
    </head>
    <body>
      <div id="mask">
        <div class="modal">
          <h1>A</h1>
          <h1>B</h1>
          <h1>C</h1>
          <h1>D</h1>
          <h1>E</h1>
          <h1>F</h1>
          <h1>G</h1>
          <h1>H</h1>
          <h1>I</h1>
          <h1>G</h1>
          <h1>K</h1>
          <h1>L</h1>
          <h1>M</h1>
        </div>
      </div>
    </body>
    <script>
      var mask = document.querySelector('#mask');
      var modal = document.querySelector('.modal');
    
      // 记录初次touch纵坐标
      let startY = 0;
    
      const modalHeight = modal.clientHeight;
      const modalScrollHeight = modal.scrollHeight;
    
      modal.addEventListener("touchstart", (e) => {
        startY = e.touches[0].pageY;
      })
    
      mask.addEventListener("touchmove", (e) => {
        let endY = e.touches[0].pageY;
        let delta = endY - startY;
    
        if(
          (modal.scrollTop === 0 && delta > 0) ||
          (modal.scrollTop + modalHeight === modalScrollHeight &&
                        delta < 0)
        ){
          e.preventDefault()
        }
      }, true);
    </script>
    </html>
    
    

    设置 body 的 overflow: hidden

    这种方式其实比较暴力,在弹窗弹出的时候,手动修改 body 的样式,然后记得在弹窗关闭的时候将 body 的样式更改回去。

    ps:我偷懒直接js控制了行间样式,但标准写法应该是给body添加类名来控制

    // 底部不滑动
    const bodyEl = document.querySelector('body');
    let top = 0;
    
    const stopBodyScroll = (isFixed: boolean) => {
      if (isFixed) {
        top = window.scrollY
        bodyEl && (bodyEl.style.position = 'fixed')
        bodyEl && (bodyEl.style.top = -top + 'px')
      } else {
        bodyEl && (bodyEl.style.position = '')
        bodyEl && (bodyEl.style.top = '')
    
        window.scrollTo(0, top) // 回到原先的top
      }
    }
    

    这个方法还可以改进,因为我们每次不一定是触发的 body 的滚动,也可以是其他元素导致的滚动。其次是要记录被滚动元素之前的样式(position)不能一味的将其设置为空。


    起源地下载网 » 深入理解浏览器的事件机制

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元