第13章 历久弥新的事件

1. 深入事件循环

  • 事件循环基于两个基本原则:
    • 一次处理一个任务
    • 一个任务开始后直到运行完成,不会被其他任务中断
  • 事件循环通常至少需要两个任务队列:
    • 宏任务队列:鼠标事件、键盘事件、网络事件、HTML解析、计时器事件等
    • 微任务队列:DOM变化、Promises等
  • 两种队列在同一时刻都只执行一个任务
  • 单次循环迭代中,最多处理一个宏任务(其余在队列中等待),而队列中所有的微任务都会被处理
  • 事件循环细节:
    • 两类任务队列都是独立于事件循环的,这意味着任务队列的添加行为也发生在事件循环之外,即主线程在执行时,仍然可以向队列添加任务
    • 基于JS单线程,两类任务都是逐个执行的,除非浏览器决定中止执行该任务
    • 所有微任务会在下一次渲染之前执行完成,因为他们的目标是在渲染前更新应用状态
    • 浏览器通常会尝试每秒渲染60次页面,以达到每秒60帧(60fps)的速度。理想状态下,单个任务和该任务附属的所有微任务,都应该在16ms内完成
  • 浏览器完成页面渲染,进入下一轮事件循环迭代后,可能的3种情况:
    • 在下一个16ms结束之前,事件循环执行到“是否需要进行渲染”的决策环节。因为更新UI是一个复杂操作,如果没有显式地指定需要页面渲染,浏览器可能不会选择在当前的循环中执行UI渲染工作
    • 在最后一个渲染完成后大约16ms,事件循环执行到“是否需要进行渲染”的决策环节。浏览器会进行UI渲染,以便让用户能够感受到顺畅的应用体验
    • 执行下一个任务(和相关的所有微任务)耗时超过16ms。浏览器无法以目标帧率重新渲染页面,且Ui无法被更新。如果超时少(不超过几百毫秒),延迟可能无法察觉,如果超时多,用户可能会察觉网页卡顿而不响应。极端情况(任务执行超过好几秒)浏览器将会提示“无响应脚本”信息

注意事件处理函数发生频率以及执行耗时。如鼠标移动事件(mouse-move)将导致大量事件进入队列,其中执行的任何复杂操作都可能导致卡顿甚至无响应

如果微任务队列中含有微任务,不论队伍中等待的其他任务,微任务都将获得优先执行权,即在当前正在运行的任务执行完成后立即执行

在两个宏任务之间,可以重新渲染页面,而在微任务执行之前不允许重新渲染页面,即只有当微任务队列为空时,事件循环才会重新渲染页面

2. 玩转计时器:延迟执行和间隔执行

  • 计时器能延迟一段代码的执行,延迟时长至少是指定的时长(单位是ms)
  • 通过计时器,可以将长时间运行的任务分解为不阻塞事件循环的小任务
  • 计时器方法不是由JS本身定义的,是由宿主环境提供,并挂载在window对象上的
  • 计时器处理方法
方法 格式 描述
setTimeout id = setTimeout(fn, delay) 启动一个计时器,
在指定的延迟时间结束时
执行一次回调函数,
返回标识计时器的唯一值
clearTimeout clearTimeout(id) 当指定的计时器尚未触发时,
取消(消除)计时器
setInterval id = setInterval(fn, delay) 启动一个计时器,
按照指定的间隔时间
不断执行回调函数直至取消,
返回标识计时器的唯一值
clearInterval clearInterval(id) 取消(消除)指定的计时器

大部分浏览器都允许使用clearTimeout或clearInterval清除任意类型的计时器,但建议使用与之相对应的清除方法

2.1 在事件循环中执行计时器

  • 对于相同延迟时间的延迟计时器和间隔计时器,加入事件队列的顺序与初始化的顺序一致
  • 浏览器不会同时创建两个相同的间隔计时器,即如果interval事件触发,并且队列中已经有对应的任务在等待执行(不是正在执行)时,则不会添加新任务
  • 计时器至少会延迟指定的毫秒数,但我们只能控制计时器何时被加入事件队列,无法控制何时执行
  • setTimeout与setInterval的区别
setTimeout(function repeat() {
    /**Some long block of code...*/
    setTimeout(repeat, 10);
}, 10);
setInterval(() => {
    /**Some long block of code...*/
}, 10);

以上两段代码看起来功能一致,但实际上,setTimeout内的代码在前一个回调函数执行完成后,至少还会延迟10ms执行,而setInterval会尝试每10ms执行回调函数,不关心前一个回调函数是否执行

2.2 处理计算复杂度高的任务

<table><tbody></tbody></table>
<script>
    console.time("主线程耗时");
    const tbody = document.querySelector("tbody");
    // 创建240000个DOM节点。一个20000行,6列的表格
    for (let i = 0; i < 20000; i++) {
        const tr = document.createElement("tr");
        for (let t = 0; t < 6; t++) {
            const td = document.createElement("td");
            td.appendChild(document.createTextNode(i + "," + t));
            tr.appendChild(td);
        }
        tbody.appendChild(tr);
    }
    console.timeEnd("主线程耗时");
    // 主线程耗时: 204.097900390625 ms
</script>

使用计时器来中断一个长时间运行的任务

<table><tbody></tbody></table>
<script>
    console.time("主线程耗时");

    // 初始化数据        
    const rowCount = 20000;
    // 分割成四个计时器任务异步执行
    const devideInto = 4;
    // 每次计时器任务处理的数量
    const chunkSize = rowCount / devideInto;
    let iteration = 0;
    const tbody = document.getElementsByTagName("tbody")[0];
    
    setTimeout(function generateRows() {
        // 计算上一次任务完成后的位置
        const base = chunkSize * iteration;
        for (let i = 0; i < chunkSize; i++) {
            const tr = document.createElement("tr");
            for (let t = 0; t < 6; t++) {
                const td = document.createElement("td");
                td.appendChild(document.createTextNode((i + base) + "," + t));
                tr.appendChild(td);
            }
            tbody.appendChild(tr);
        }
        iteration++;
        // 安排下一次计时器任务
        if (iteration < devideInto) {
            setTimeout(generateRows, 0);
        }
    }, 0); // 设置为0表示下一次迭代应该“尽快”执行

    console.timeEnd("主线程耗时");
    // 主线程耗时: 0.072998046875 ms
</script>

使用计时器将长时间运行的任务分解为多个不会阻塞事件循环的小人物,这允许在任务之间进行重新渲染,而不会阻塞UI

3. 处理事件

<button id="btn">按钮</button>
<script>
    // 事件处理的回调函数接收一个事件对象event,
    // event的target属性指向发生事件的元素的引用
    
    // 回调函数中也可以使用this,根据函数类型和
    // 调用方式的不同,this的指向不同,
    // 使用函数声明时,this指向事件处理器注册的元素,
    // 而不一定时发生事件的元素
    const btn = document.getElementById("btn");
    btn.addEventListener("click", event => {
        console.log(event.target === btn);
        // true
        console.log(this === btn);
        // false
        console.log(this === window);
        // true
    });
    btn.addEventListener("click", function(event) {
        console.log(event.target === btn);
        // true
        console.log(this === btn);
        // true
        console.log(this === window);
        // false
    });
    btn.addEventListener("click", (function(event) {
        console.log(event.target === btn);
        // true
        console.log(this === btn);
        // false
        console.log(this === window);
        // true
    }).bind(window));
</script>

3.1 通过DOM代理事件

<div id="div1" style=" 300px;height: 300px;background-color: #00ff00;">
    <div id="div2" style=" 150px;height: 150px;background-color: #ff0000;margin: auto;"></div>
</div>
<script>
    const div1 = document.getElementById("div1");
    const div2 = document.getElementById("div2");
    
    // 事件冒泡
    document.addEventListener("click", function(event) {
        console.log("doc");
    });
    div1.addEventListener("click", function(event) {
        console.log("div1");
    });
    div2.addEventListener("click", function(event) {
        console.log("div2");
    });
    // div1
    // div2
    // doc

    // 事件捕获
    const div1 = document.getElementById("div1");
    const div2 = document.getElementById("div2");
    document.addEventListener("click", function(event) {
        console.log("doc");
    }, true);
    div1.addEventListener("click", function(event) {
        console.log("div1");
    }, true);
    div2.addEventListener("click", function(event) {
        console.log("div2");
    }, true);
    // doc
    // div2
    // div1
</script>
  • W3标准:一个事件的处理有两种
    • 捕获——首先被顶部元素捕获,并依次向下传递
    • 冒泡——目标元素被捕获后,事件处理转向冒泡,从目标元素向顶部元素冒泡
  • 默认时事件冒泡,第三个参数传入false,采用事件冒泡,传入true采用事件捕获

在上级元素上代理事件

<table id="tb">
    <tr><td>A</td><td>B</td><td>C</td></tr>
    <tr><td>A</td><td>B</td><td>C</td></tr>
    <tr><td>A</td><td>B</td><td>C</td></tr>
    <tr><td>A</td><td>B</td><td>C</td></tr>
</table>
<script>
    const tb = document.getElementById("tb");
    
    // 在父级元素上注册事件,通过冒泡处理所有单元格的点击事件
    // 避免了使用循环将同一个事件处理器注册到成千上万个元素上
    tb.addEventListener("click", function(event) {
        if (event.target.tagName.toLowerCase() === "td") {
            event.target.style["background-color"] = "red";
        }
    })
</script>

3.2 自定义事件

<button id="btn">START</button>
<span id="text" style="display: none;">Downloading...</span>

<script>
    /**
     * 启动一个自定义事件
     *
     * @param {Element} target 事件派发的对象 
     * @param {string} eventType 自定义事件名
     * @param {Object} eventDetail 事件的附带信息
     */
    function triggerEvent(target, eventType, eventDetail) {
        // 使用CustomEvent创建新事件
        const event = new CustomEvent(eventType, {
            detail: eventDetail
        });
        // 使用内置的dispatchEvent方法向指定的元素发送事件
        target.dispatchEvent(event);
    }

    // 用计时器模拟Ajax事件
    function performAjaxOperation() {
        // 事件开始时触发ajax-start事件,传入url作为事件的额外信息
        triggerEvent(document, "ajax-start", { "url": "my-url" });
        setTimeout(() => {
            // 事件结束后触发ajax-complete事件
            triggerEvent(document, "ajax-complete");
        }, 5000);
    }

    const btn = document.getElementById("btn");
    const text = document.getElementById("text");

    // 当单击按钮时,Ajax操作开始
    btn.addEventListener("click", event => {
        performAjaxOperation();
    });

    // document对象接收dispatch过来的event
    document.addEventListener("ajax-start", event => {
        text.style["display"] = "block";
        btn.style["display"] = "none";
        // 可以访问事件的附加信息
        console.log(event.detail.url);
        // my-url
    });
    document.addEventListener("ajax-complete", event => {
        text.innerHTML = "Download Complete!";
    });
</script>
原文地址:https://www.cnblogs.com/hycstar/p/14057245.html