一文说清浏览器事件循环机制所有细节

js运行时

定义

js 运行的环境。

作用

JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理

每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。

除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其它组成部分对该代理来说都是唯一的。

js与浏览器的单线程&多线程

进程与线程

进程是应用程序运行的实例,是资源分配的独立单位,拥有独立的内存空间与系统资源。进程间互相通信需要相互同意。

而线程是进程中的执行单元,自身不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可以与同属一个进程的其他线程共享进程所拥有的全部资源。

js与浏览器的线程

js本身是单线程,负责解释和执行js代码的只有一个线程,即js引擎线程。

浏览器本身的渲染进程是有多个线程的。分别为:

  • JS引擎线程: 执行代码、垃圾回收
  • 事件触发线程:维护事件循环机制,将各种回调推入宏任务、微任务队列
  • 定时触发器线程:给定时任务计时
  • 异步http请求线程:请求 http
  • GUI渲染线程: 进行渲染工作

宏任务以及微任务

前置知识

Promise

定义

所有的任务分为两类:宏任务或微任务

宏任务:主代码块、计时任务等,事件队列中的每一个事件都是一个宏任务

微任务:Promise、MutationObserver、Object.observe的触发的回调等

宏任务和微任务区别

  • 在每一次新的事件循环开始迭代的时候,运行时都会执行队列中的每个来自宏任务队列中的宏任务。在每次迭代开始之后加入到队列中的任务需要在下一次事件循环迭代开始之后才会被执行
  • 每次当一个任务退出且执行上下文为空的时候【可能是事件循环还没进入尾声时,即宏任务队列在本次循环要执行的任务还有的时候,也可能执行微任务】,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行------即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。

特殊的宏任务 requestAnimationFrame

作用

在浏览器下一次重绘前调用用户提供的回调函数。

若是一些会导致重绘或重排的任务交予该 API 有以下好处:

  • 与下一次渲染同步,性能好,该绘制改动会与原本浏览器要做的改动整合在一起去处理,不用再多渲染一次。
  • 避免丢帧,如果在requestIdleCallback中执行DOM操作,并且这些操作没有在单帧内完成,可能会导致页面丢帧,影响用户体验。
  • 使得动画加载更平滑:如果任务是更新动画的一部分,使用requestAnimationFrame可以确保动画的平滑性,因为回调会在浏览器准备好绘制下一帧时被调用。
  • 减少页面闪烁或不连贯。
  • 自动节流:当页面不在当前视图中或者在后台标签页时,requestAnimationFrame 会减少回调的调用频率,甚至暂停回调,从而减少资源消耗。
丢帧

"丢帧"(Dropping Frames)通常是指在视频播放、动画渲染或者游戏运行过程中,由于渲染速度跟不上显示设备的刷新率,导致某些帧没有被正确渲染或显示出来,从而错过了显示机会。

常见的代码实现导致的原因:

代码效率低下,或者资源管理不当,亦或是系统或应用程序同时处理多个任务,导致渲染任务得不到足够的处理时间。

参数

  • callback: 该函数会在下一次重绘更新你的动画时被调用到。这个回调函数只会传递一个参数:一个时间戳【毫秒数】,用于表示上一帧渲染的结束时间

requestAnimationFrame() 队列中的多个回调开始在同一帧中触发时,它们都会收到相同的时间戳,即便在计算前一个回调函数工作量时这一帧的时间已经过去。

返回值

请求 ID 是一个 long 类型整数值,是在回调列表里的唯一标识符。这是一个非零值,但你不能对该值做任何其他假设。你可以将此值传递给 window.cancelAnimationFrame() 函数以取消该刷新回调请求。

与其他宏任务差别

rAF 属于宏任务,但具有特殊的调度规则。它会在浏览器每一帧的渲染前执行(通常每 16.67ms 一次),目的是与屏幕刷新率同步,确保动画流畅性。

所以其他宏任务的顺序并不固定。

影响执行顺序的因素

与其他宏任务的顺序可能因以下因素变化:

  1. 事件循环的阶段差异
  • 如果 rAF 的回调注册时,浏览器已进入下一帧的渲染阶段,则可能被推迟到下一帧执行,而当前帧的宏任务可能先执行。
javascript 复制代码
setTimeout(() => console.log("setTimeout"), 0);
requestAnimationFrame(() => console.log("rAF"));

输出可能是 rAF → setTimeoutsetTimeout → rAF,取决于代码执行时机。

  1. 浏览器环境差异
  • 控制台直接执行 :在浏览器控制台中,由于没有实际页面渲染,rAF 可能延迟到下一个事件循环周期执行,导致其回调排在宏任务之后****。
    • 页面脚本执行:在 HTML 文件中,rAF 更可能按预期在渲染前执行。
  1. 浏览器优化策略
  • 浏览器可能合并多个 rAF 回调,或在空闲时调整宏任务队列顺序。

特殊任务------空闲任务

定义

是浏览器在当前帧中渲染之后,若是还有空余时间时,执行的一些任务。由 requestIdleCallback 生成对应任务。

帧的概念

大多数设备现在每秒 60 帧,1 帧大约 16.7 毫秒,浏览器需要在这段时间完成一次事件循环迭代、空闲任务、gui渲染线程渲染内容的处理来保证平滑。

在一帧时间内,如果有剩余时间并且事件循环队列中还有待处理的任务,事件循环会继续执行下一个任务,直到队列为空或者达到了某个时间阈值(比如接近下一帧的开始时间且当前事件循环的微任务队列已经清空),这时浏览器会暂停JavaScript执行,优先进行UI渲染,确保视觉更新的连续性。

空闲时间

当前帧在宏任务、微任务执行完成,渲染工作也已完成,在下一帧开始前等待下一个宏任务的这段时间我们称为空闲时间。

requestIdleCallback

作用

将回调在 js 主线程的空闲时间中执行,这个回调既不对应一个宏任务也不对应一个微任务。所有的空闲回调【其实就是空闲时候要执行的函数】都会被放入到空闲回调队列中,并且在空闲时间挨个执行队列中的回调。故,这个 API 适合执行一些优先级比较低的任务。

不过空闲任务若是一直执行下去不进行中断【函数结束或是return退出】时,会阻塞后续事件循环迭代的重新开启。所以要是要在空闲任务中进行耗时的任务处理应该将一个大任务拆分成多个小任务执行,并且根据空闲时间的剩余时间进行合适的中断小任务的继续执行【指的是当前小任务完成后看是否剩余时间还够下一个小任务,若是不够则不执行】,等待下一个空闲时间的到来,再去执行剩余小任务。

语法

requestIdleCallback(callback, option)

参数
  • callback :在空闲时间执行的空闲回调,该回调会接收一个 IdleDeadline 对象,该对象可提供剩余空闲时间以及回调当前运行是否已经超出空闲时间
  • options:一个配置对象,还有一个 timeout 属性,该参数应为一个 number 数据,表示对应的毫秒数过去后,如果该回调还未调用,那么任务会在下一个事件循环开启之前,也就是此时的宏任务执行完、微任务队列已经清空、 UI 渲染完毕,就会执行空闲回调。
返回值

一个该回调的唯一标识 ID

可以通过 cancelIdleCallback(ID) 来取消该回调【 cancelIdleCallback 函数返回值为 undefined 】。

注意事项 safe to update the display

safe to update the display 指的是一段代码更新 dom 或是样式时不会引起负面影响,比如性能问题,或是不良用户体验。具体一点就是不会引起重排或是重绘。这些操作室性能密集型的操作,会导致页面卡顿或是延迟。若是需要涉及到重绘或是重排的话,可以将布局改动安排到一个新的渲染周期前,即事件循环迭代中清空完微任务队列时。

所以若是回调中有对 dom 中的修改的话,那就尽量不要使用 requestIdleCallback 而是 requestAnimationFrame。

IdleDeadline

作用

用于告知空闲回调: 剩余空闲时间以及回调当前运行是否已经超出空闲时间

属性
  • didTimeout:boolean,表示当前运行是否已经超时,true 表示超出空闲时间。
  • timeRemaining:一个函数,无需参数,调用返回一个毫秒数,表示当前帧剩余空闲时间的毫秒数。

CookBook

该 API 可以用来进行重复逻辑任务的优化,但是在某些情况下不是最佳选择:

  • 适合优先级不是很高,且没那么看重即时性允许延时一段时间给出结果的任务可使用该 API 。否则可以使用worker处理
  • 如果需要频繁间隔固定时间执行处理的不要用该函数, 使用 setTimeout 以及 setInterival
  • 数据量特别大的不太适合或复杂的计算任务,因为会很久之后才给出结果, 此时使用 worker 比较好
  • 需要与动画帧同步或是有 DOM 结构更新导致需要重新渲染时,不要使用该 API。使用 requestAnimationFrame 更合适,因为可以保证浏览器在绘制下一帧前执行该任务,使得事件中的一些 渲染 可以和当前要渲染的内容一同渲染。

进行重复逻辑任务的优化时,可以按照以下思路:

  1. 首先将大任务看作循环执行的小任务,然后将小任务写在一个回调函数中。
  2. 创造个队列用于存储每个小任务中需要的数据
  3. 实现任务调度机制函数,在队列不空且剩余空闲时间还有时执行小任务,空闲时间不足时执行 requestIdleCallback 将任务调度机制函数作为参数,此时就会在有空余时间时再次续上小任务的继续遍历执行。若是队列为空则推出任务调度机制函数
  4. 在想开始执行整体任务时,执行 requestIdleCallback 将任务调度机制函数作为参数
scss 复制代码
let levelFiberQueue: FiberUnitDataType[] = [];
levelFiberQueue.push({});
/** 一个 fiber 任务 */
const performUnitOfFiber = () => {
  /** 拿取队列中任务,操作队列并执行处理 */
};
/** 任务调度机制: 空闲时间执行对应任务,空闲时间快结束时停止任务执行,剩余任务放置下一次执行 */
const FiberLoop = (deadline: IdleDeadline) => {
  while (levelFiberQueue.length) {
    // 挂载节点
    if (deadline.timeRemaining() < 1) {
      // 需要中断
      break;
    }
    // 执行任务
    performUnitOfFiber();
  }
  // 中断后等待空闲继续执行剩余任务
  if (levelFiberQueue.length) {
    requestIdleCallback(FiberLoop);
  } else {
    // 说明任务完成
    
  }
};
// 执行任务调度机制,
requestIdleCallback(FiberLoop);
    

示例

说明

以频繁对一个列表中插入十万个列表项为例,我们分别实现 requestIdleCallback 版本代码【 test1 】和无优化版本代码【test2】来进行比较

xml 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <button onclick="test1()">requestIdleCallback</button>
    <button onclick="test2()">朴素</button>
    <ul id="list"></ul>
    <script>
        function test2() {
            // 模拟一个大数据量的列表数据
            const START = new Date();
            const largeListData = [];
            for (let i = 0; i < 100000; i++) {
                largeListData.push(`Item ${i}`);
            }
            let list = document.getElementById('list');
            let itemCount = 0;
            while (itemCount < largeListData.length) {
                let listItem = document.createElement('li');
                listItem.textContent = largeListData[itemCount];
                list.appendChild(listItem);
                itemCount++;
            }
            setTimeout(() => {
                console.log(new Date() - START);
            }, 0)

        }
        function test1() {
            const START = new Date();
            // 模拟一个大数据量的列表数据
            const levelFiberQueue = [];
            for (let i = 0; i < 100000; i++) {
                levelFiberQueue.push(`Item ${i}`);
            }
            /** 一个 fiber 任务 */
            const performUnitOfFiber = () => {
                let listItem = document.createElement('li');
                listItem.textContent = levelFiberQueue.shift();
                list.appendChild(listItem);
                /** 拿取队列中任务,操作队列并执行处理 */
            };
            /** 任务调度机制: 空闲时间执行对应任务,空闲时间快结束时停止任务执行,剩余任务放置下一次执行 */
            const FiberLoop = (deadline) => {
                while (levelFiberQueue.length) {
                    // 挂载节点
                    if (deadline.timeRemaining() < 1) {
                        // 需要中断
                        break;
                    }
                    // 执行任务
                    performUnitOfFiber();
                }
                // 中断后等待空闲继续执行剩余任务
                if (levelFiberQueue.length) {
                    console.log('ZA');
                    
                    requestIdleCallback(FiberLoop);
                }
                else {
                    console.log(new Date() - START);
                }
            };
            // 执行任务调度机制,
            requestIdleCallback(FiberLoop);
        }


    </script>
</body>

</html>
运行结果

我们发现在执行 requestIdleCallback 过程中选中列表项等活动仍旧可以正常执行,但是时间加载速度为 30多秒。

但是无优化版本则会造成页面卡顿,虽然时间上只有近两秒,但是中间造成页面卡顿,导致选中列表项等操作在渲染过程中无法进行。

而且 requestIdleCallback 执行30多秒是因为我们让浏览器进行频繁渲染,每个小任务进行渲染一次,导致每一帧都有渲染工作占据该帧时间,导致空余时间大大缩小,最终代码执行总时间拉长。

针对 requestIdleCallback 渲染耗时优化后对比

代码

xml 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <button onclick="test1()">requestIdleCallback</button>
    <button onclick="test2()">朴素</button>
    <ul id="list"></ul>
    <script>
        function test2() {
            // 模拟一个大数据量的列表数据
            const START = new Date();
            const largeListData = [];
            for (let i = 0; i < 100000; i++) {
                largeListData.push(`Item ${i}`);
            }
            let list = document.getElementById('list');
            let itemCount = 0;
            while (itemCount < largeListData.length) {
                let listItem = document.createElement('li');
                listItem.textContent = largeListData[itemCount];
                list.appendChild(listItem);
                itemCount++;
            }
            setTimeout(() => {
                console.log(new Date() - START);
            }, 0)

        }
        function test1() {
            const START = new Date(), frame = document.createDocumentFragment();
            // 模拟一个大数据量的列表数据
            const levelFiberQueue = [];
            for (let i = 0; i < 100000; i++) {
                levelFiberQueue.push(`Item ${i}`);
            }
            /** 一个 fiber 任务 */
            const performUnitOfFiber = () => {
                let listItem = document.createElement('li');
                listItem.textContent = levelFiberQueue.shift();
                frame.appendChild(listItem);
                /** 拿取队列中任务,操作队列并执行处理 */
            };
            /** 任务调度机制: 空闲时间执行对应任务,空闲时间快结束时停止任务执行,剩余任务放置下一次执行 */
            const FiberLoop = (deadline) => {
                while (levelFiberQueue.length) {
                    // 挂载节点
                    if (deadline.timeRemaining() < 1) {
                        // 需要中断
                        break;
                    }
                    // 执行任务
                    performUnitOfFiber();
                }
                // 中断后等待空闲继续执行剩余任务
                if (levelFiberQueue.length) {
                    requestIdleCallback(FiberLoop);
                }
                else {
                    requestAnimationFrame(() => {
                        list.appendChild(frame);
                        setTimeout(() => {
                            console.log(new Date() - START);
                        }, 0)

                    })
                }
            };
            // 执行任务调度机制,
            requestIdleCallback(FiberLoop);
        }


    </script>
</body>

</html>

js代码运行机制------事件循环

运行机制

普通的宏任务会按照消息队列机制运行。

在执行宏任务时遇到Promise等,会创建微任务(.then()里面的回调),并加入到微任务队列队尾。

在下一个宏任务开始之前,浏览器判断页面重新渲染若满足条件则渲染。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。

整理后得到运行机制如下:

  1. 执行一个宏任务
  2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  4. 当前宏任务及微任务执行完毕,触发页面更新相关操作:包括样式计算(Style)、布局(Layout)、绘制(Paint)等,并执行requestAnimationFrame
  5. GUI线程接管渲染
  6. 渲染完毕后,看当前帧是否有空余时间,若有则在空余时间内执行空余时间任务以及垃圾回收工作
  7. 当前事件循环机制结束

示例:

javascript 复制代码
let a=new Promise((resolve)=>resolve())

let m=function(){console.log("mmm")}

setTimeout(()=>console.log("aaa"),0);

a.then(()=>{console.log("111");});

console.log("start1");

console.log("start2")

m();

结果:

解析:

首先除了计时事件回调代码以及then的回调代码之外,剩余的所有的代码是一个宏任务。

而then回调代码是一个微任务,计时回调代码是另一个宏任务。

当第一个宏任务执行之后,第二个宏任务执行前,微任务被执行。

如何判断应该渲染

浏览器通常与显示器的垂直同步信号(VSync)对齐来决定渲染时机,每帧进行一次渲染尝试。

在一次事件循环迭代中,宏任务执行完毕且微任务队列清空后,浏览器并不会直接进入下一个事件循环迭代,而是会评估是否需要进行渲染

  • 等待渲染的条件:
  • 接近VSync时刻:如果当前时间点距离下一帧的开始(VSync信号)很近,浏览器可能会选择等待,以便与显示器的刷新周期保持同步,从而避免不必要的渲染操作和资源浪费。
  • 渲染需求评估:如果DOM的改变足以影响视觉输出,并且这些变化还未被渲染,浏览器会倾向于等待并进行渲染。这包括检查是否有足够的变化(如大量元素的布局或样式改变)或变化是否位于可视区域内。
  • 资源和性能考量:如果当前系统资源紧张(如CPU或GPU负载高),浏览器可能会选择延迟非紧急的渲染操作,以优先处理其他更重要的任务,比如用户输入响应。
  • 直接进入下一个事件循环的条件:

无紧急渲染需求:如果当前DOM的变化不大,或变化对用户不可见,且距离下一帧的开始还有较长时间,浏览器可能会选择不等待,直接开始下一个事件循环的处理,以继续执行JavaScript代码或处理其他任务。

  • 任务队列非空:

如果有更多的宏任务或微任务排队等待执行,表明有更高优先级的任务需要处理,浏览器通常会优先执行这些任务,而不是等待渲染。

  • 性能优化策略:

某些情况下,即使可以渲染,浏览器的智能调度系统也可能决定优先执行其他优化操作(如垃圾回收、预加载资源等),然后在合适的时机再进行渲染。

同源窗口、web worker 或者一个跨域的 iframe 是否共享事件循环

在特定情况下,同源窗口之间共享事件循环,例如:

  • 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
  • 如果窗口是包含在 中,则它可能会和包含它的窗口共享一个事件循环。
  • 在多进程浏览器中多个窗口碰巧共享了同一个进程

具体看浏览器实现。

一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

事件循环会作用于哪些场景

以下事件循环都是同样的流程

  • Window 事件循环 : window 事件循环驱动所有同源的窗口
  • Worker 事件循环 : worker 事件循环顾名思义就是驱动 worker 的事件循环。这包括了所有种类的 worker:最基本的 web worker 以及 shared workerservice worker。 Worker 被放在一个或多个独立于 "主代码" 的代理中。浏览器可能会用单个或多个事件循环来处理给定类型的所有 worker。
相关推荐
黄智勇9 分钟前
xlsx-handlebars 一个用于处理 XLSX 文件 Handlebars 模板的 Rust 库,支持多平台使
前端
brzhang1 小时前
为什么 OpenAI 不让 LLM 生成 UI?深度解析 OpenAI Apps SDK 背后的新一代交互范式
前端·后端·架构
brzhang2 小时前
OpenAI Apps SDK ,一个好的 App,不是让用户知道它该怎么用,而是让用户自然地知道自己在做什么。
前端·后端·架构
爱看书的小沐2 小时前
【小沐学WebGIS】基于Three.JS绘制飞行轨迹Flight Tracker(Three.JS/ vue / react / WebGL)
javascript·vue·webgl·three.js·航班·航迹·飞行轨迹
井柏然3 小时前
前端工程化—实战npm包深入理解 external 及实例唯一性
前端·javascript·前端工程化
IT_陈寒3 小时前
Redis 高性能缓存设计:7个核心优化策略让你的QPS提升300%
前端·人工智能·后端
aklry3 小时前
elpis之动态组件机制
javascript·vue.js·架构
井柏然3 小时前
从 npm 包实战深入理解 external 及实例唯一性
前端·javascript·前端工程化
羊锦磊4 小时前
[ vue 前端框架 ] 基本用法和vue.cli脚手架搭建
前端·vue.js·前端框架
brzhang4 小时前
高通把Arduino买了,你的“小破板”要变“AI核弹”了?
前端·后端·架构