面试官:你会如何阻止iframe中的点击事件?

11,603 阅读23分钟

前言

这道题的答案其实有很多,想要取巧的话,也可以说写一个div盖在iframe上面,从而也能实现用户无法点击iframe的效果。

比如,在禁止点击事件之前,这张页面长这样,点击iframe内部会触发一次alert,如下图所示👇:

iframe事件.gif

通过取巧的方式我们可以这么做(为了有那么一点辨识度,我给div设置了黑色背景色,并将opacity设置为10%):

      <style>
        .iframe-container {
            position: relative;
            /* 确保子元素可以使用绝对定位 */
            width: 400px;
            height: 200px;
        }

        iframe {
            width: 400px;
            height: 200px;
        }

        .overlay-div {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            opacity: 10%;
            background: black;
        }
    </style>
    <div class="iframe-container">
        <iframe id="myIframe" src="page2.html"></iframe>
        <div class="overlay-div"></div>
    </div>

可以看到,实际效果如下:

iframe事件div.gif

从上图可以看到,这时再点击按钮,不会触发alert了。

当然,也有的同学会说了,要论取巧的话,这种方式还是太麻烦了,新增了2个块级元素,直接通过CSS禁用iframe的指针事件就好了。

    iframe {
        pointer-events: none;
    }

这种方法当然也是没问题的,如下图:

iframe事件css.gif

从上图可以看到,这时再点击按钮,同样也不会触发alert了。

但是这些取巧方式实际上与面试官的意图背道而驰,因为面试官的本意是通过这道题考察你对于事件流的理解。

并且你如果理解的不错的话,还能追问你React的事件合成。如果你也能照样答得不错,那基本上这道题你的得分就比较高了。

但是如果你答不上来有关事件的问题,那么这很有可能导致满盘皆输,一面直接挂

不要问我为什么知道😟

image.png

因此本篇文章以这道面试题为引,和大家一起来聊一聊事件和事件流。

(在正文内容里,我们会一起聊聊如何通过事件捕获/冒泡的方式去阻止iframe的点击事件)

事件

定义

最近在阅读《JavaScript高级程序设计》这本书,我发现它的语言真是写的很妙,因此忍不住和大家分享一下,它是如何定义事件的:

JavaScriptHTML的交互是通过事件实现的,事件代表文档或浏览器窗口中某个有意义的时刻。 可以使用仅在事件发生时执行的监听器(也叫处理程序)订阅事件。

在传统软件工程领域,这个模型叫 “观察者模式”,其能够做到页面行为(在JavaScript中定义)与页面展示(在HTMLCSS中定义)的 分离。——《JavaScript高级程序设计》

当我们打开一张网页,我们会发现,页面上提供了非常多的交互入口,以掘金的首页为例:

image.png

从上图可以看到,红色框选的部分均是可以点击的区域。

但是不知道你是否会产生这样一个疑问,即以左侧的导航边栏为例:

image.png

【关注】、【综合】、【后端】、【前端】等等这些标签均被包含在左侧边栏之中,当我们点击某一个具体的导航标签时,我们实际上是不是也点击到了侧边栏本身之上呢?

这就像我们咬了一口带馅的包子,虽然我吃到了馅,但是同时也吃到了皮。

会产生这种疑惑的不仅仅是你与我,在第四代Web浏览器(IE4和Netscape Communicator 4)开始开发时,这两家浏览器的开发团队也遇到了这样的问题。

虽然这么说有点倒反天罡了,他们比我们更早有这种疑问

他们在这个问题上达成了共识,即当你点击一个按钮时,实际上不光点击了这个按钮,还点击了它的容器以及整个页面。

上面的这个共识很好理解,也让人认同,但是还是存在一个问题

  • 点击事件到底是先落在按钮上,再落在包含按钮的容器上,最后再落在整个页面上,
    Button => Container => Page
    
  • 还是先落在整个页面上,再落在包含按钮的容器上,最后再落在按钮上?
    Page => Container => Button
    

很显然,这个过程有2种不同的顺序,这种顺序我们称之为页面接收事件的顺序,而事件流就是用来描述页面接收事件的顺序的。

虽然这两家浏览器的开发团队针对页面的哪个部分拥有特定的事件这件事情达成了共识,但是在决定事件流到底采用哪种顺序的时候,却提示了完全相反的两种方案:

  • IE将支持事件冒泡流
    • 即先落在按钮上,再落在包含按钮的容器上,最后再落在整个页面上
  • Netscape Communicator将支持事件捕获流
    • 即先落在整个页面上,再落在包含按钮的容器上,最后再落在按钮上

addEventListener

在【定义】章节,我们学习了相关的描述语言,并且我们也从定义中看到了这样一句话:

事件代表文档或浏览器窗口中某个有意义的时刻。 可以使用仅在事件发生时执行的监听器(也叫处理程序)订阅事件

JavaScript中,我们可以使用addEventListener去作为监听器,监听事件的执行,比如监听一个按钮的点击事件,并针对此点击事件做进一步的处理(弹窗、发起接口调用、提交表单等等)。

addEventListener接收3个参数:

  • type:事件名,比如:"click""message"
  • listener:事件处理函数,比如:() => { console.log("button click") }
  • flag:布尔值,默认值为false
    • true:表示在捕获阶段调用事件处理程序
    • false:表示在冒泡阶段调用事件处理程序

示例:

// 给body添加点击事件 
body.addEventListener('click', function () { alert('body被点击了!'); }, false);

事件冒泡

IE事件流被称为事件冒泡,这是因为事件被定义为从最具体的元素(文档树中最深的节点)开始触 发,然后向上传播至没有那么具体的元素(文档)——《JavaScript高级程序设计》

事件冒泡是真的非常的形象化,光从字面意思就可以想象。

image.png

一个泡泡从海底慢慢浮上水面。

示例

我们通过一个代码例子和示意图,一起来看看事件冒泡:

<!DOCTYPE html>
<html lang="zh-CN" id="htmlElement">

<head>
    <meta charset="UTF-8">
    <title>事件冒泡示例</title>
    <style>
        .container {
            width: 300px;
            height: 200px;
            background-color: #f0f0f0;
            padding: 20px;
            text-align: center;
        }

        button {
            padding: 10px 20px;
        }
    </style>
</head>

<body>

    <div class="container" id="container">
        <p>点击按钮,查看事件冒泡。</p>
        <button id="myButton">点击我</button>
    </div>

    <script>
        // 获取元素
        var container = document.getElementById('container');
        var button = document.getElementById('myButton');
        var body = document.body;
        var html = document.getElementById('htmlElement');


        // 给document添加点击事件
        document.addEventListener('click', function () {
            alert('document被点击了!');
        });

        // 给html添加点击事件
        html.addEventListener('click', function () {
            alert('html被点击了!');
        });
        // 给body添加点击事件
        body.addEventListener('click', function () {
            alert('body被点击了!');
        });

        // 给容器添加点击事件
        container.addEventListener('click', function () {
            alert('容器被点击了!');
        });

        // 给按钮添加点击事件
        button.addEventListener('click', function (e) {
            alert('按钮被点击了!');
            // 如果你想阻止事件冒泡,可以取消以下注释
            // e.stopPropagation();
        });
    </script>

</body>

</html>

👇运行后,当我们点击按钮,效果如下图所示👇:

事件冒泡演示true-2.gif

从上方动图我们可以看到,页面接收事件的顺序是从里往外,从下往上的。

Button => Container => Body => Html => Document

image.png

这里还有一个需要知悉的特性:

所有现代浏览器都支持事件冒泡,只是在实现方式上会有一些变化。IE5.5及早期版本会跳过<html>元素(从<body>直接到document)。

现代浏览器中的事件会一直冒泡到window对象。

事件捕获

Netscape Communicator 团队提出了另一种名为事件捕获的事件流。事件捕获的意思是最不具体的节点应该最先收到事件,而最具体的节点应该最后收到事件。事件捕获实际上是为了在事件到达最终目标前拦截事件。——《JavaScript高级程序设计》

示例

我们同样可以通过一个代码例子和示意图,一起来看看事件捕获:

<!DOCTYPE html>
<html lang="zh-CN" id="htmlElement">

<head>
    <meta charset="UTF-8">
    <title>事件捕获示例</title>
    <style>
        .container {
            width: 300px;
            height: 200px;
            background-color: #f0f0f0;
            padding: 20px;
            text-align: center;
        }

        button {
            padding: 10px 20px;
        }
    </style>
</head>

<body>

    <div class="container" id="container">
        <p>点击按钮,查看事件捕获。</p>
        <button id="myButton">点击我</button>
    </div>

    <script>
        // 获取元素
        var container = document.getElementById('container');
        var button = document.getElementById('myButton');
        var body = document.body;
        var html = document.getElementById('htmlElement');


        // 给document添加点击事件
        document.addEventListener('click', function () {
            alert('document被点击了!');
        }, true);

        // 给html添加点击事件
        html.addEventListener('click', function () {
            alert('html被点击了!');
        }, true);
        // 给body添加点击事件
        body.addEventListener('click', function () {
            alert('body被点击了!');
        }, true);

        // 给容器添加点击事件
        container.addEventListener('click', function () {
            alert('容器被点击了!');
        }, true);

        // 给按钮添加点击事件
        button.addEventListener('click', function (e) {
            alert('按钮被点击了!');
            // 如果你想阻止事件冒泡,可以取消以下注释
            // e.stopPropagation();
        }, true);
    </script>

</body>

</html>

👇运行后,当我们点击按钮,效果如下图所示👇:

事件捕获.gif

从上方动图我们可以看到,页面接收事件的顺序是从外往里,从上往下的。

Document => Html => Body => Container => Button

image.png

事件对象(Event)

在上面的章节里,我们介绍了“事件”的定义,介绍了页面接收“事件”的顺序,还介绍了监听“事件”的监听器方法addEventListener,但是我们似乎还没有从代码层面认识一下“事件”,即事件对象的数据结构

DOM中发生事件时,所有相关信息都会被收集并存储在一个名为event的对象中。这个对象包含了一些基本信息,比如导致事件的元素、发生的事件类型,以及可能与特定事件相关的任何其他数据。 例如,鼠标操作导致的事件会生成鼠标位置信息,而键盘操作导致的事件会生成与被按下的键有关的信息。所有浏览器都支持这个event对象,尽管支持方式不同。 ——《JavaScript高级程序设计》

实例属性类型说明
type字符串被触发的事件类型
target元素事件目标
currentTarget元素当前事件处理程序所在的元素
eventPhase整数表示调用事件处理程序的阶段:1代表捕获阶段,2代表 到达目标,3代表冒泡阶段
cancelable布尔值表示是否可以取消事件的默认行为
bubbles布尔值表示事件是否冒泡
preventDefault()函数用于取消事件的默认行为。只有cancelabletrue才 可以调用这个方法
stopPropagation()函数用于取消所有后续事件捕获或事件冒泡。只有bubblestrue才可以调用这个方法
stopImmediatePropagation()函数用于取消所有后续事件捕获或事件冒泡,并阻止调用任 何后续事件处理程序(DOM3 Events中新增)

(上方表格罗列了部分Event实例属性实例方法,如果大家期望了解更多的性质,可以访问MDN文档)

标题问题的解法

当我们在阅读【事件捕获】这个章节的定义时,我们能发现其中有这么一句话:

事件捕获实际上是为了在事件到达最终目标前拦截事件。

这不正好就撞到咱们标题里的问题上了不~

我们现在就是期望能够阻止iframe中的点击事件,因此利用事件捕获,我们就可以解决这个问题。

在此之前,我们要利用刚刚罗列的实例属性中的typetargetcurrentTargetstopPropagation()

  • type:用来指定要监听的事件,比如在这里,我们需要监听的就是click。
  • target:事件目标,比如这里,触发事件的目标就是btn,按钮
  • currentTarget:当前事件处理程序所在的元素,由于我们采用的是事件捕获,因此这里的元素就是body
  • stopPropagation():阻止后续的事件捕获和冒泡行为。

在实际动手写代码之前,我认为标题中的问题其实问得很宽泛,我们往细节去扣的话,可以将题干补充成以下3种题目:

  • iframe内部阻止自己的点击事件,某些元素的点击事件
  • 在嵌入同源iframe主页面阻止iframe的全部点击事件
  • 在嵌入不同源iframe主页面阻止iframe的全部点击事件

接下来,我们就动手写一个代码示例,看看是否真能如我们的预期,解决这个问题:

iframe内部阻止自己的点击事件,某些元素的点击事件

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>postMessage Example</title>

</head>

<body>

    <p>iframe页面</p>
    <button id="btn">点击</button>
    <button id="btn2">点击2</button>

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

        const body = document.body;
        // body.addEventListener('click', function (event) {

        // }, true);
        btn.onclick = () => {
            alert('iframe 弹窗')
        }

        btn2.onclick = () => {
            alert('iframe 弹窗 2号')
        }
        window.addEventListener('message', function (event) {

            // alert('Message from Page 1: ' + event.data);
            console.log('Message from Page 1: ' + event.data)
        });

    </script>
</body>

</html>

我们给iframe的页面里设置了2个button,并且添加了不同的点击事件(分别alert不同的内容),如下图所示:

iframe2.gif

接下来,如果我们期望禁用btn的点击事件,则我们可以在body元素捕获btn的点击事件,使用stopPropagation()阻止它的后续冒泡/捕获行为。

那我们知道,btn和btn2都可以触发body元素的事件捕获,如何进行区分呢?这就用到了我们在本章节开头介绍的两个实例属性:

  • target:事件目标,比如这里,触发事件的目标就是btn,按钮
  • currentTarget:当前事件处理程序所在的元素,由于我们采用的是事件捕获,因此这里的元素就是body

一起来看一下代码吧:

        body.addEventListener('click', function (event) {
            if (event.target === btn) {
                event.stopPropagation()
            }
        }, true);

添加之后的效果如下:

iframe2-捕获.gif

从上图可以看到,无论我们怎么点击btn,都不会再触发任何弹窗了,但是点击btn2,还是可以正常弹窗的。

我们成功阻止了btn的点击事件。

在嵌入同源的iframe主页面阻止iframe的全部点击事件

iframe 元素创建了一个独立的文档环境,其中包含的HTML文档与父文档是分离的。这意味着默认情况下,iframe 内部的事件不会冒泡到父文档。这是出于安全考虑,防止跨域脚本攻击。

因此如果我们要在主页面(也就是刚刚说的父文档)里阻止iframe的全部点击事件,我们是无法直接通过给主页面的body元素加个事件捕获,去捕获iframe的点击事件的。

但是我们可以在主页面,通过document.getElementById('myIframe').contentWindow获取到 iframewindow对象,然后给这个window对象设置事件捕获即可。

代码如下:

    <script>
        var iframeWindow = document.getElementById('myIframe').contentWindow;
        var btn = document.getElementById('mainBtn')
        iframeWindow.addEventListener('click', function (event) {

            event.stopPropagation()

        }, true);


        btn.addEventListener("click", () => { alert("本页面的点击事件") })

        window.onload = function () {
            var iframeWindow = document.getElementById('myIframe').contentWindow;
            // 注意这里的第二个参数应该是 "http://127.0.0.1:8080/public/page1.html",因为这是iframe的源
            iframeWindow.postMessage('Hello from Page 1!', 'http://127.0.0.1:8080/public/page1.html');
        };

    </script>

👇设置之后,运行的效果如下图所示👇:

iframe2-捕获-主页面阻止.gif

从上图可以看到,无论我们点击多少次iframe中的btnbtn2,都不会触发弹窗。

我们成功在主页面阻止了iframe的点击事件。

image.png

在嵌入不同源的iframe主页面阻止iframe的全部点击事件

在实际业务中,更多遇到的是需要在主页面使用iframe嵌入不同源的页面,比如在视频网站通过iframe的形式展示一些广告。

由于同源策略的限制,如果我们尝试给不同源的iframecontentWindow添加事件监听器,例如onclick,实际上不会起作用。

这是因为同源策略禁止跨源访问,所以父窗口无法直接操作或监听不同源iframe内部的事件。

什么是同源,什么是不同源

这里我简单介绍一下区分的方式和例子。

源的定义:

如果两个 URL 的协议端口(如果有指定的话)和主机都相同的话,则这两个 URL 是同源的。这个方案也被称为“协议/主机/端口元组”,或者直接是“元组”。(“元组”是指一组项目构成的整体,具有双重/三重/四重/五重等通用形式。) ——MDN

我们拿掘金的网址举个例子:https://juejin.cn

image.png

URL结果原因
https://juejin.cn/pins同源只有路径不同
https://juejin.cn/events/all同源只有路径不同
http://juejin.cn失败协议不同,一个是https,另一个是http
https://juejin.cn:442失败端口不同(https:// 默认端口是 443)
https://cn.bing.com失败主机不同

基础的知识了解完毕后,我们回到问题本身。

我使用live-server这个第三方库帮助运行page1.html这个文件,此时这个页面的源是127.0.0.1:8080

image.png

然后我再写一个server.js文件,通过node server.js的方式运行page2.html这个文件,此时其源为127.0.0.1:3000

server.js代码如下:

const http = require("http");
const fs = require("fs");
const path = require("path");

const hostname = "127.0.0.1";
const port = 3000;

const server = http.createServer((req, res) => {
  console.log(`Request for ${req.url} by method${req.method}`);

  if (req.method === "GET") {
    let fileUrl;
    if (req.url === "/") {
      fileUrl = "/page2.html";
    } else {
      fileUrl = req.url;
    }

    const filePath = path.resolve("./public" + fileUrl);
    const fileExt = path.extname(filePath);

    if (fileExt === ".html") {
      fs.access(filePath, (err) => {
        if (err) {
          res.statusCode = 404;
          res.setHeader("Content-Type", "text/html");
          res.end(
            `<html><body><h1>Error 404: ${fileUrl} not found</h1></body></html>`
          );
          return;
        }
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/html");
        fs.createReadStream(filePath).pipe(res);
      });
    } else {
      res.statusCode = 404;
      res.setHeader("Content-Type", "text/html");
      res.end(
        `<html><body><h1>Error 404: ${fileUrl} is not an HTML file</h1></body></html>`
      );
    }
  } else {
    res.statusCode = 404;
    res.setHeader("Content-Type", "text/html");
    res.end(
      `<html><body><h1>Error 404: ${req.method} not supported</h1></body></html>`
    );
  }
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

image.png

这样,我们就在本地成功模拟了父子页面不同源的场景了(80803000端口不同,因此不同源)

page1.html的代码如下:

<style>
    .iframe-container {

        width: 400px;
        height: 200px;
    }

    iframe {
        width: 400px;
        height: 200px;
    }
</style>
<div class="iframe-container">
    <iframe id="myIframe" src="http://127.0.0.1:3000/"></iframe>
    <div class="overlay-div"></div>
    <script>
        var iframeWindow = document.getElementById('myIframe').contentWindow;
        // 事件监听,尝试通过捕获阻止
        iframeWindow.addEventListener('click', function (event) {
            console.log('event', event)
            event.stopPropagation()

        }, true);
        
        // 跨源通信
        window.onload = function () {
            var iframeWindow = document.getElementById('myIframe').contentWindow;
            // 注意这里的第二个参数应该是 "http://127.0.0.1:3000",因为这是iframe的源
            iframeWindow.postMessage('Hello from Page 1!', 'http://127.0.0.1:3000');
        };

    </script>
</div>

image.png

此时,我们继续按照之前的逻辑,给iframeWindow添加click的事件监听时,我们会发现,毫无作用。

iframe内部的点击事件照常可以顺利执行,并不会被捕获而阻止。并且console.log都打印不出来消息。

👇运行后的实际效果如下图所示👇

ifream跨源.gif

从上图可以看到,点击iframe触发的alert,显示的也从之前的8080变成了3000

image.png

image.png

那么这种情况下,我们该如何实现父页面阻止iframe内嵌页面的点击事件呢?

我们就需要用到Web API: window.postMessage,跨窗口通信了。

跨窗口通信

window.postMessage()  方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage()  方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。——MDN

不过利用跨窗口通信的前提是,你能够修改iframe内部的代码

比如:

父页面向iframe发送消息,iframe内部得回应消息才行。否则任凭你父页面发什么,iframe都无动于衷的话,那也阻止不了点击事件。

如果是无法修改iframe内部代码的情况下,并且父子不同源的话,那还是用【前言】中介绍的方式吧。

OK,接下来就让我们一起来看看,假如能够修改iframe内部的代码,那我们可以怎么利用window.postMessage去阻止子页面的点击事件。

我们可以让父页面发一条名为preventClick的消息给子页面,代码如下:

 // 跨源通信
        window.onload = function () {
            var iframeWindow = document.getElementById('myIframe').contentWindow;
            // 注意这里的第二个参数应该是 "http://127.0.0.1:3000",因为这是iframe的源
            iframeWindow.postMessage('preventClick', 'http://127.0.0.1:3000');
        };

然后我们再修改一下子页面,在子页面内部监听父页面发来的消息:

      window.addEventListener('message', function (event) {
            console.log('Message from Page 1: ' + event.data)
        });

运行后看下效果:

image.png

紧接着呢,我们在iframe内部判断evnet.origin字段的值是否是127.0.0.1:8080,这样做的好处是确定逻辑的影响面。

比如:

可能我们的这个页面B会被嵌入到很多张不同源的网页中(页面A、页面C、页面D、页面E、页面F),但是呢,只有部分网页是需要阻止页面B的点击事件的。

于是我们就修改一下addEventListener('message',...的回调函数内部的逻辑,如下:

     window.addEventListener('message', function (event) {
            if (event.origin === "http://127.0.0.1:8080" && event.data === 'preventClick') {
                console.log(`Message from Page ${event.origin}: ` + event.data)
            }
        });

运行后效果如下:

image.png

最后呢,我们利用iframe内部的事件捕获,去阻止全部的点击事件。

可以理解成,只有满足event.origin是指定页面并且event.data'preventClick'的情况下,才给body元素增设onclick的事件监听,并在内部阻止事件捕获。

我们再修改一下代码:

       window.addEventListener('message', function (event) {
            if (event.origin === "http://127.0.0.1:8080" && event.data === 'preventClick') {
                console.log(`Message from Page ${event.origin}: ` + event.data)

                document.addEventListener('click', function (e) {
                    e.preventDefault();
                    e.stopPropagation();
                }, true); // 使用事件捕获阶段来阻止点击事件

            }
        });

最后,我们通过一张动图,感受下运行结果:

ifream跨源2.gif

上方动图一开始是在端口3000页面进行点击操作,均可正常触发。

后续切换至端口8080页面,点击iframe内部的按钮,点击事件不会触发,被成功阻止。

性能优化

又是它,又是性能优化,之前我们在CSS系列的文章里也和大家说,答题的时候也能往性能优化的方面聊,增加广度。

《我所知道的CSS——答题时的锦上添花01》

那么自然,我们在聊事件这个和页面关联性如此紧密的知识点,也离不开性能优化了。

JavaScript中,页面中事件处理程序的数量与页面整体性能直接相关。原因有很多。

首先,每个函数都是对象,都占用内存空间,对象越多,性能越差

其次,为指定事件处理 程序所需访问DOM的次数会先期造成整个页面交互的延迟。只要在使用事件处理程序时多注意一些方法,就可以改善页面性能。——《JavaScript高级程序设计》

事件委托

正如本章节所引用的内容所说,对象越多,性能越差,因此我们要提升性能,最直接的方式就是减少对象的数量呗,即减少事件处理程序的数量

事件委托”就是问题的解决方案。

在此前的章节里我们提到了,可以利用事件捕获来阻止某些元素的点击事件。

在本章节,我们可以利用事件冒泡来实现事件委托。

我们可以把页面中的全部事件(某个按钮的点击事件、某个容器的点击事件等等)看做一个整体,让它们都冒泡到document上,然后再给document添加事件处理程序,这样就实现了只使用一个事件处理程序就能管理一种类型的事件

我们来看一个代码例子(10000个li,并给每个li添加点击事件):

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>10000 List Items with Click Event</title>
    <script>
        // 当文档加载完毕时,为每个li元素添加事件监听器
        document.addEventListener('DOMContentLoaded', function () {
            var myList = document.getElementById('myList');
            for (var i = 1; i <= 10000; i++) {
                var li = document.createElement('li');
                li.id = 'item-' + i;
                li.textContent = 'Item ' + i;
                li.addEventListener('click', function listItemClicked(event) {
                    var listItem = event.target;
                    alert('Clicked on item: ' + listItem.id);
                });
                myList.appendChild(li);
            }
        });

    </script>
</head>

<body>

    <ul id="myList">
        <!-- li元素将在这里被JavaScript动态添加 -->
    </ul>

</body>

</html>

image.png

可以看到,此时堆快照的大小是6.2MB

当我们通过事件委托,完成优化,将点击事件统一放到ul元素处理时

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>10000 List Items with Click Event</title>
    <script>
        // 当文档加载完毕时,为每个li元素添加事件监听器
        document.addEventListener('DOMContentLoaded', function () {
            var myList = document.getElementById('myList');
            for (var i = 1; i <= 10000; i++) {
                var li = document.createElement('li');
                li.id = 'item-' + i;
                li.textContent = 'Item ' + i;
                myList.appendChild(li);
            }
            myList.addEventListener('click', function (event) {
                var target = event.target;
                console.log('target', target)
                if (target.tagName === 'LI') {
                    // 处理点击事件
                    alert('Clicked on item: ' + target.id);
                }
            });
        });
    </script>
</head>

<body>

    <ul id="myList">
        <!-- li元素将在这里被JavaScript动态添加 -->
    </ul>

</body>

</html>

image.png

可以看到,此时堆快照的大小是4.6MB

是不是很直观地可以看到事件委托所带来的性能优化了?

删除事件处理程序

我们在《【源码阅读】探索“一键复制文本”的最佳实践》这篇文章里,review自己写的代码时,提到了性能优化方面的不足之处,并加以改正。什么不足之处呢?就是定时器的问题,我们要及时在定时器用完之后,手动去清理定时器,避免可能会发生的内存泄漏。

同样的,我们也要及时去删除事件处理程序。

不知道你在阅读本文时,浏览addEventListener这一章节,是否会有这样一个想法:

既然有add,那么应该也有delete或者clear吧?为啥作者不一起写了呢

哈哈,没错,当然有,只不过处于我自己的写文思路,将它放在了本章节,它就是removeEventListener

它的参数和addEventListener一致,再带大家回顾一遍:

  • type:事件名,比如:"click""message"
  • listener:事件处理函数,比如:() => { console.log("button click") }
  • flag:布尔值,默认值为false
    • true:表示在捕获阶段调用事件处理程序
    • false:表示在冒泡阶段调用事件处理程序

怎么用

通过addEventListener()添加的事件处理程序只能使用removeEventListener()传入与添加时同样的参数来移除

我们来看一个代码例子:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>addEventListener and removeEventListener Example</title>
    <script>
        // 定义一个可以被移除的事件处理函数
        function handleButtonClick() {
            alert("Button clicked!");
        }

        // 当文档加载完毕时,设置事件监听器
        document.addEventListener('DOMContentLoaded', function () {
            var button = document.getElementById('myButton');

            // 使用addEventListener添加事件监听器
            button.addEventListener('click', handleButtonClick, false);

            // 移除事件监听器的按钮点击处理函数
            var removeButton = document.getElementById('removeButton');
            removeButton.addEventListener('click', function () {
                // 移除之前添加的事件监听器
                // 注意:必须传入与添加时同样的参数
                button.removeEventListener('click', handleButtonClick, false);
                console.log("Event listener removed!");
            });
        });
    </script>
</head>

<body>

    <button id="myButton">Click Me</button>
    <button id="removeButton">Remove Event Listener</button>

</body>

</html>

👇运行之后,效果如下图所示👇:

删除回调1.gif

从上图可以看到,正常点击左侧按钮,会有弹窗,点击右侧按钮删除处理函数后,则不再有弹窗。

请注意这个使用条件,重要的事情说三遍,传入与添加时同样的参数来移除传入与添加时同样的参数来移除传入与添加时同样的参数来移除

这就意味着,着使用addEventListener()添加的匿名函数无法移除。

我们修改一下刚刚的代码示例:

    <script>
        // 定义一个可以被移除的事件处理函数
        // function handleButtonClick() {
        //     alert("Button clicked!");
        // }

        // 当文档加载完毕时,设置事件监听器
        document.addEventListener('DOMContentLoaded', function () {
            var button = document.getElementById('myButton');

            // 使用addEventListener添加事件监听器
            button.addEventListener('click', () => { alert("Button clicked!") }, false);

            // 移除事件监听器的按钮点击处理函数
            var removeButton = document.getElementById('removeButton');
            removeButton.addEventListener('click', function () {
                // 移除之前添加的事件监听器
                // 注意:必须传入与添加时同样的参数
                button.removeEventListener('click', () => { alert("Button clicked!") }, false);
                console.log("Event listener removed!");
            });
        });
    </script>

👇再运行之后,效果如下👇:

删除回调2.gif

从上图可以看到,正常点击左侧按钮,会有弹窗,点击右侧按钮删除处理函数后,照样还是有弹窗

不过真遇到这种情况,我们可以手动的通过设置此元素相应的onXYZ = null来实现移除,比如:btn.onclick = null

(前提是此时也不能使用addEventListener去给btn加上事件处理函数,而是通过button.onclick = () => { alert("Button clicked!") }的形式添加)

我们再再再修改一下代码示例:

    <script>
        // 定义一个可以被移除的事件处理函数
        // function handleButtonClick() {
        //     alert("Button clicked!");
        // }

        // 当文档加载完毕时,设置事件监听器
        document.addEventListener('DOMContentLoaded', function () {
            var button = document.getElementById('myButton');
            // 使用addEventListener添加事件监听器
            // button.addEventListener('click', , false);
            button.onclick = () => { alert("Button clicked!") }
            // 移除事件监听器的按钮点击处理函数
            var removeButton = document.getElementById('removeButton');
            removeButton.addEventListener('click', function () {
                // 移除之前添加的事件监听器
                // 注意:必须传入与添加时同样的参数
                // button.removeEventListener('click', () => { alert("Button clicked!") }, false);
                document.getElementById('myButton').onclick = null
                console.log("Event listener removed!");
            });
        });
    </script>

👇再再再运行之后,效果如下👇:

删除回调3.gif

从上图可以看到,正常点击左侧按钮,会有弹窗,点击右侧按钮删除处理函数后,则不再有弹窗。

好了,关于怎么删除事件处理函数,我们已经聊了很多了,也提到了一些注意事项。

现在我们该聊聊何时需要删除事件处理函数了

何时用

把事件处理程序指定给元素后,在浏览器代码和负责页面交互的JavaScript代码之间就建立了联系。 这种联系建立得越多,页面性能就越差。除了通过事件委托来限制这种连接之外,还应该及时删除不用的事件处理程序。很多Web应用性能不佳都是由于无用的事件处理程序长驻内存导致的。——《JavaScript高级程序设计》

比较常见的一种情况是使用innerHTML整体替换页面的某一部分。这时候,被innerHTML删除的元素上如果有事件处理程序,就不会被垃圾收集程序正常清理

这种情况真的很常见吗?暂且按下不表

这里我直接引用《JavaScript高级程序设计》中的代码例子,和大家一起了解了解即可:

    <div id="myDiv">
        <input type="button" value="Click Me" id="myBtn">
    </div>
    <script type="text/javascript">
        let btn = document.getElementById("myBtn");
        btn.onclick = function () {
            // 
            执行操作
            不好!
            document.getElementById("myDiv").innerHTML = "Processing...";
            // 
        };  
    </script>

这里的按钮在<div>元素中。单击按钮,会将自己删除并替换为一条消息,以阻止双击发生。这是 很多网站上常见的做法。问题在于,按钮被删除之后仍然关联着一个事件处理程序。在<div> 元素上设置innerHTML会完全删除按钮,但事件处理程序仍然挂在按钮上面

那么我们要怎么做呢?

还记得在本章节的【怎么用】小节里我们提到过的第三种方法吗?没错,就是通过btn.onclick = null的形式,在调用document.getElementById("myDiv").innerHTML之前,删除掉事件处理程序即可。

    <div id="myDiv">
        <input type="button" value="Click Me" id="myBtn">
    </div>
    <script type="text/javascript">
        let btn = document.getElementById("myBtn");
        btn.onclick = function () {
            btn.onclick = null;
            document.getElementById("myDiv").innerHTML = "Processing...";
            
        };  
    </script>

结语

在本篇文章里,我们以一道真实的面试题为引,仔细聊了聊网页中的事件,以及网页接收事件的顺序(事件流),并且对事件对象的实例属性和实例方法以代码例子动手实践并掌握其特性。

但是我们也发现,当我们在性能优化章节,学习事件委托删除事件处理程序时,和我们平时工作中的业务开发写的代码并不一样。

我们好像在平时的工作中,并没有主动去写过事件委托,但是我们的页面性能好像也还可以,这是为什么呢?

这就要提到React了,React的事件机制里藏着这些问题的答案。

出于篇幅考虑(正文6200字),期待与你在下一篇相遇,一起聊一聊React的事件机制、事件合成~

image.png