JS在浏览器下的事件循环(EventLoop)

604 阅读26分钟

事件循环

W3C 的官方称法是: Event Loop

Chrome 的称法是: Message Loop

测试环境:Google Chrome 版本 120.0.6099.216(正式版本) (arm64)

本文的讲解主要也是以 W3C 标准为主,在 HTML Standard Event loops 中,这个规范定义了浏览器内核该如何的去实现它。

定义:

为了协调事件、用户交互、呈现、网络等等,用户代理必须使用本节所述的事件循环。每个代理都有一个关联的事件循环,该循环对该代理脚本、唯一的。 W3CDefinitions.png

从这个定义也可看出,事件循环主要是用来协调事件、网络、JavaScript 等之间的一个运行机制,我们以 JavaScript 为出发点来看下它们之间是如何交互的。

  • 一个事件循环有一个或多个任务队列。任务队列是一组任务(a set of tasks)
  • 任务队列是集合,而不是队列,因为事件循环处理模型从所选队列中抓取第一个可运行的任务,而不是将第一个任务出列。
  • 微任务队列不是任务队列。

处理模式(Processing model)

描述:

HTML事件处理模式大致可以这样描述:

while (true) {
    const taskStartTime = performance.now();
    // It's unspecified where UI events fit in. Should each have their own task?
    const task = eventQueue.pop();
    if (task)
        task.run();
    if (performance.now() - taskStartTime > 50)
        reportLongTask();
    if (!hasRenderingOpportunity())
        continue;
    invokeAnimationFrameCallbacks();
    while (needsStyleAndLayout()) {
        styleAndLayout();
        invokeResizeObservers();
    }
    markPaintTiming();
    render();
}

示意图:

事件循环不是 JavaScript 引擎的一部分,而是浏览器运行时中的一个组件,它使用单个执行线程不断异步等待下一条消息在某个时刻进入队列,如图所示: v202401.png

详细步骤如下:

1.在最开始的时候,渲染主线程会进入无限循环。开始第一个事件循环。
2.所有同步代码在主线程上执行,将函数调用推入调用栈。
3.遇到异步操作时,将其回调函数注册到主线程之外的任务队列(task queque),继续执行同步任务。

  • 不同类型任务(例如,setTimeout,XMLHttpRequest)放入对应同源的任务队列
  • 遇到微任务(例如,Promise 回调),放入微任务队列

4.当调用栈为空时,检查任务队列,如果有,从选中的任务队列中将可执行的任务取出并压入调用栈,执行该任务的回调函数。

  • 优先执行微任务队列中的所有微任务,清空微任务队列
  • 然后寻找下一个任务队列。

5.主线程循环执行第4步,处理同步和异步任务,直到清空所有任务队列。

主线程是循环不断的从任务队列中读取事件,整个执行机制又称为Event Loop(事件循环) 这种机制确保了 JavaScript 在处理异步操作时不会阻塞主线程,保持了响应性。

WHATWG 与 W3C

在浏览器环境中,关于事件循环相关定义是在 HTML 标准中,之前 HTML 规范由 WHATWG 和 W3C 制定,两个组织都有自己的不同,2019 年时两个组织签署了一项协议 就 HTML 和 DOM 的单一版本进行合作,最终,HTML、DOM 标准最终由 WHATWG 维护。

W3C 与 WHATWG 就共同开发 HTML 与 DOM 规范达成协议

WHATWG 维护HTML 和DOM 标准。 W3C 直接在WHATWG 存储库中促进社区工作(桥接社区,开发用例,提交问题,编写测试,调解问题解决方案)。 W3C 停止独立发布与HTML 和DOM 相关的指定规范列表,而是将WHWWG 审核草案纳入W3C 建议书。

进程与线程:

  1. 进程(Process): 在操作系统中运行的一个程序实例,拥有独立的内存空间和执行环境。进程之间相互独立,不会互相干扰。
  2. 线程(Thread):进程中更小的执行单位,是由进程创建和管理的。一个进程可以包含多个线程,它们共享进程的内存空间和其他资源,但拥有独立的执行栈和寄存器。在浏览器中,不同线程协同工作,处理渲染、网络请求和 JavaScript 执行等任务。
  3. Chrome 打开一个页面有多少进程: 浏览器从关闭到启动,然后新开一个页面至少需要:1个浏览器进程,1个GPU进程,1个网络进程,和1个渲染进程,一共4个进程。
  4. 渲染进程:默认情况下会为每一个标签页配置一个渲染进程。我们平时看到的浏览器呈现出页面过程中,大部分工作都是在渲染进程中完成。
  5. 浏览器tab页渲染进程中的线程协同:
    • 在浏览器中每个tab页通常对应一个独立渲染进程,以提高安全性和稳定性。
    • 这个进程中,又可以有多个线程来并行处理不同的任务。
    • 不同线程之间协同工作,但GUI渲染线程JS引擎线程是互斥的,以避免并发访问 DOM 树和样式表引起的冲突。

Chrome 打开一个页面有多少进程:

浏览器是一个 多进程 多线程 的应用程序

谷歌浏览器里,通过右击顶部的标签栏,可以打开任务管理器 Shift + Esc

  • 浏览器从关闭到启动,然后新开一个页面至少需要:1个浏览器进程,1个GPU进程,1个网络进程,和1个渲染进程,一共4个进程

  • 后续如果再打开新的标签页:浏览器进程,GPU进程,网络进程是共享的,不会重新启动,然后默认情况下会为每一个标签页配置一个渲染进程

  • 但是也有例外,比如从A页面里面打开一个新的页面B页面,而A页面和B页面又属于同一站点的话,A和B就共用一个渲染进程,其他情况就为B创建一个新的渲染进程;

  • 最新的Chrome浏览器包括:1个浏览器主进程1个GPU进程1个网络进程多个渲染进程,和多个插件进程

    • 浏览器进程: 负责控制浏览器除标签页外的界面,包括地址栏、书签、前进后退按钮等,以及负责与其他进程的协调工作,同时提供存储功能。

    • GPU进程:负责整个浏览器界面的渲染。Chrome刚开始发布的时候是没有GPU进程的,而使用GPU的初衷是为了实现3D CSS效果,只是后面网页、Chrome的UI界面都用GPU来绘制,这使GPU成为浏览器普遍的需求,最后Chrome在多进程架构上也引入了GPU进程。

    • 网络进程:负责发起和接受网络请求,以前是作为模块运行在浏览器进程一时在面的,后面才独立出来,成为一个单独的进程。

    • 存储进程:为 local/session storage, service worker,indexed_db提供存储服务。

    • 插件进程:主要是负责插件的运行,因为插件可能崩溃,所以需要通过插件进程来隔离,以保证插件崩溃也不会对浏览器和页面造成影响。

    • 渲染进程 (重点):负责控制显示tab标签页内的所有内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页

      • 排版引擎Blink和JS引擎V8都是运行在该进程中。
      • 默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签页之间不相互影响,无关乎是否为 same-site 站点 (目前)。更多参见 chrome 官方说明文档

渲染进程中的线程 (重点)

我们平时看到的浏览器呈现出页面过程中,大部分工作都是在渲染进程中完成,所以我们来看一下渲染进程中的线程:

  • GUI渲染线程(渲染主线程):负责渲染页面,解析html和CSS、构建DOM树、CSSOM树、渲染树、和绘制页面,重绘重排也是在该线程执行。

  • JS引擎线程:一个tab页中只有一个JS引擎线程(单线程),负责解析和执行JS。它GUI渲染进程不能同时执行,只能一个一个来,如果JS执行过长就会导致阻塞掉帧。

  • 事件触发线程:主要用来控制事件循环,比如JS执行遇到计时器,AJAX异步请求等,就会将对应任务添加到事件触发线程中,在对应事件符合触发条件触发时,就把事件添加到待处理队列的队尾,等JS引擎处理。

  • 计时器线程:指setInterval和setTimeout,因为JS引擎是单线程的,所以如果处于阻塞状态,那么计时器就会不准了,所以需要单独的线程来负责计时器工作。

  • 异步http请求线程: XMLHttpRequest连接后浏览器开的一个线程,比如请求有回调函数,异步线程就会将回调函数加入事件队列,等待JS引擎空闲执行。

渲染进程中的主要工作内容:

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括不限于以下:

  • 执行全局 JS 代码
  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次 (页面刷新的帧率)
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ……

那么渲染进程中如何调度执行这些任务:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我 “用户点击了按钮”, 与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ……

于是就需要事件循环的模型,任务通过排队交给渲染主线程来执行。

单线程的JavaScript:

由于 JavaScript 是单线程的,它在执行时只能按照顺序逐条执行代码。

JS中其实是没有线程概念的,所谓的单线程也只是相对于多线程而言。JS的设计初衷就没有考虑这些,针对JS这种不具备并行任务处理的特性,我们称之为“单线程”。

为什么JavaScript是单线程?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

这样做有两个优点:

  1. 节约内存开销: 单线程执行的优势之一是在运行时只需要一个线程来逐行执行代码,不需要为每个线程分配独立的内存空间。这使得 JavaScript 在资源消耗上相对较轻,尤其是对于前端开发中的浏览器环境,能够更高效地利用有限的内存。
  2. 没有锁的概念: 在多线程编程中,多个线程可能同时访问共享资源,为了保证数据的一致性,需要引入锁机制。然而,锁机制会增加上下文切换的开销,可能导致性能下降。在 JavaScript 的单线程模型中,由于不存在多线程同时访问的情况,避免了引入锁的复杂性和相关的开销,简化了代码的编写和维护。

任务队列(消息队列):

说明

  • 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

  • 如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

  • JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

  • 于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如onclick, setTimeout,ajax处理的方式都不同,这些异步操作是由浏览器内核的webcore来执行的,webcore包含下图中的3种 webAPI,分别是DOM Binding、network、timer模块。

  • DOM Binding 模块处理一些DOM绑定事件,如onclick事件触发时,回调函数会立即被webcore添加到任务队列中。
  • network 模块处理Ajax请求,在网络请求返回时,才会将对应的回调函数添加到任务队列中。
  • timer 模块会对setTimeout等计时器进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中。

队列类型(优先级)

  • 微队列 (必须有) 优先级 (最高): 用户存放需要最快执行的任务
  • 交互队列 优先级 (最高): 用于存放用户操作后产生的事件处理任务
  • 延时队列 优先级 (中): 用于存放计时器到达后的回调任务
  • ……

同步与异步编程:

同步任务: 在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。典型的同步任务包括函数调用、变量赋值、算术运算等。例如:

console.log('1');
function add(a, b) {
  return a + b;
}
let result = add(2, 3);
console.log(result);
console.log('2');

// 1
// 5
// 2

异步任务: 不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

  1. 事件监听:事件监听在 Web 开发中是非常常见的,用于响应用户交互、处理浏览器事件等。几乎所有的 Web 应用都会使用事件监听。
  2. 回调函数(callback): 在 JavaScript 中是一种基本的异步编程模式。在早期的 JavaScript 开发中,回调函数是处理异步操作的主要方式。
  3. Promise:是一种更先进的异步编程模式,用于处理异步操作和解决回调地狱问题。它在现代 JavaScript 开发中非常普遍,并为异步代码提供了更清晰、可读性更好的结构。
  4. async/await:是建立在 Promise 基础上的一种更高级的异步编程模式。它提供了更直观和同步的代码风格,被广泛应用于处理异步任务,特别是在处理 Promise 的情况下。
  5. 计时器:如 setTimeoutsetInterval,用于在一定时间后执行代码或周期性地执行代码。在定时执行某些任务时使用频率较高。
  6. 发布/订阅:是一种用于处理应用程序内组件之间通信的设计模式。在一些大型前端应用或框架中,这种模式可以提供一种松散耦合的通信机制。
  7. I/O操作:例如网络请求、文件读写等。
  8. requestAnimationFrame:用于执行动画效果,特别是在 Web 开发中实现平滑的动画时使用频率较高。
  9. process.nextTick:是在 Node.js 中用于将回调函数推迟到下一个事件循环迭代的工具。在某些 Node.js 应用程序中可能会使用,但在浏览器端使用频率较低。
  10. MutationObserver:用于监听 DOM 树的变化。在需要观察 DOM 变化并做出相应处理的情况下使用。
  11. ...

###宏任务与微任务

JavaScript 通过异步编程的方式来实现并发操作。它将异步任务分为宏任务和微任务两种类型。

宏任务(Macrotask)包括以下几种:

  • script:整体的 JavaScript 代码块。
  • setTimeout 和 setInterval:定时器任务。
  • setImmediate:在当前事件循环完成后立即执行的任务。(Node.js环境)
  • I/O 操作:例如网络请求、文件读写等。
  • UI 渲染:浏览器需要绘制页面时触发的任务。
  • requestAnimationFrame
  • 事件监听回调函数等
  • ...

微任务(Microtask)包括以下几种:

  • Promisethencatchfinally
  • async/await
  • process.nextTick():Node.js 中的微任务。
  • MutationObserver:DOM 变动观察器。
  • ...

相关API补充(链接🔗):

定时器

任务队列可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做“定时器”(timer)功能,也就是定时执行的代码。

定时器功能主要由setTimeout()setInterval()这两个函数来完成,它们内部机制完全一样,区别在于setTimeout指定的代码是一次性执行,后者为反复执行。

setTimeout()一般接收两个参数,第一个是fn回调函数,第二个是delay推迟执行的毫秒数.(第三个是param附加参数,一旦定时器到期,他们会作为参数传递给fn指定函数)。、

setTimeout(functionRef)
setTimeout(functionRef, delay)
setTimeout(functionRef, delay, param1)
setTimeout(functionRef, delay, param1, param2)
setTimeout(functionRef, delay, param1, param2, /* … ,*/ paramN)

延时比指定值更长的原因

嵌套超时:

正如 HTML 标准中规定的那样,一旦对 setTimeout 的嵌套调用被安排了 5 次,浏览器将强制执行 4 毫秒的最小超时。

超时延迟:

如果页面(或操作系统/浏览器)正忙于其他任务,超时也可能比预期的晚。需要注意的一个重要情况是,在调用 setTimeout() 的线程结束之前,函数或代码片段不能被执行。例如:

function foo() {
  console.log("foo 被调用");
}
setTimeout(foo,100);
sleep(1000) // sleep函数同步执行,接收一个等待时间,打印 'sleep end'

// sleep end
// foo 被调用

出现这个结果的原因是:

  • setTimeout 将foo带进webApi中的计时器处理模块100ms 的延迟来计时,计时开始
  • 主线程执行sleep函数,需要等待1000ms,开始计时
  • 100ms计时完毕,计时事件setTimeout 完成,foo函数内打印进入任务队列等待,此时主线程sleep还没执行完,等待继续
  • sleep执行完毕,打印进入主线程执行输出 foo 被调用

上述流程完毕,foo打印远大于100ms。因为队列中的等待函数被调用之前,当前代码必须全部运行完毕,因此这里运行结果并非预想的那样。

上面说完了setTimeout,当然不能错过它的孪生兄弟setInterval。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。

唯一需要注意的一点是,对于setInterval(fn,ms)来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦**setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了**。这句话请读者仔细品味。

  • Promise
    • promise本身只是一个容器,真正异步的是它的两个回调resolve()和reject()。
    • new Promise内的函数直接同步执行。
    • then被分发到微任务Event Queue中。
  • async/await
    • async函数返回一个要等待的 Promise 实例,Thenable 对象,或任意类型的值。
    • 当函数执行到 await 时,被等待的表达式会立即执行,所有依赖该表达式的值的代码会被暂停,并推送进微任务队列(microtask queue)
  • process.nextTick(callback):遇到process.nextTick(),其回调函数被分发到微任务Event Queue中

思考:

浏览器对事件队列的执行优先级

从上一节异步的讲述,我们可以发现,渲染主线程在遇到计时器、网络、事件监听等任务时,将其交给其他线程执行,自身转而拿取其他任务,这是否说明其他任务比计时器、网络、事件监听任务有更高的优先级呢?

答案其实是否定的,任务没有优先级,都是在消息队列中排队,有着类似先进先出的原则。

但是消息队列是有优先级的。

根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行。

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法

而目前在 Chrome 的实现中,至少包含了下面的队列:

  • 微队列:用户存放需要最快执行的任务,优先级「最高」;
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」;
  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」。

添加任务到微队列的主要方式主要是使用 Promise、MutationObserver

总结

一个事件循环可以有多个任务队列,队列之间可有不同的优先级,同一队列中的任务按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行。

事件循环:

  • JS 会创建一个类似于 while (true) 的循环,每执行一次循环体的过程称之为Tick。每次Tick的过程就是查看是否有待处理事件,如果有则取出相关事件及回调函数放入执行栈中由主线程执行。待处理的事件会存储在一个任务队列中,也就是每次Tick会查看任务队列中是否有需要执行的任务。

  • 一个事件循环可以有多个任务队列,队列之间可有不同的优先级,同一队列中的任务按先进先出的顺序执行,但是不保证多个任务队列中的任务优先级,具体实现可能会交叉执行。

任务队列: 异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如onclick, setTimeout,ajax处理的方式都不同,这些异步操作是由浏览器内核的webcore来执行的,webcore包含下图中的3种 webAPI,分别是DOM Binding、network、timer模块。

经典题目

例题1

async function async1() {
    console.log('async1 start'); 
    await async2(); 
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function (resolve) { 
    console.log('promise1');
    resolve(); 
}).then(function () {
    console.log('promise2');
});
console.log('script end');

// script start
// async1 start
// async2       
// promise1    
// script end   
// async1 end
// promise2  
// setTimeout 
  1. 同步代码执行,输出:script start;
  2. setTimeout异步丢入宏任务1;
  3. 调用async1()执行,输出 async1 start,碰到await async2() 输出:async2, await后面的代码标记微任务1;
  4. 执行new Promise,输出: promise1,执行resolve()改变promise状态,将后续绑定的.then()标记为微任务2;
  5. 执行输出:script end;
  6. 主线程清空,开始循环任务队列,先消耗微任务队列再处理宏任务队列;
  7. 执行微任务1,输出:async1 end;
  8. 执行微任务2,输出:promise2(此时微任务队列清空完毕);
  9. 执行宏任务1,输出:setTimeout;
  10. 完毕。

例题2

console.log(1); 

setTimeout(() => { 
    console.log(2);
}, 0);

console.log(3);

new Promise((resolve) => {
    console.log(4); 
    resolve();
    console.log(5);
}).then(() => { 
    console.log(6);
});

console.log(7);

// 1 
// 3
// 4 
// 5
// 7
// 6
// 2

例题3

console.log('script start')

setTimeout(function () {
  console.log('setTimeout')
}, 0)

Promise.resolve()
  .then(function () {
    console.log('promise1')
  })
  .then(function () {
    console.log('promise2')
  })

console.log('script end')
  1. 整体 script 作为第一个宏任务进入主线程,输出script start
  2. 遇到 setTimeout,setTimeout 为宏任务,加入任务队列
  3. 遇到 Promise,其 then 回调函数加入到微任务队列;第二个 then 回调函数也加入到微任务队列
  4. 继续往下执行,输出script end
  5. 检测微任务队列,输出promise1promise2
  6. 进入下一轮循环,执行 setTimeout 中的代码,输出setTimeout

最后执行结果为:

script start
script end
promise1
promise2
setTimeout

例题4

来看一道面试的经典题目

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2')
}
console.log('script start')
setTimeout(function () {
  console.log('setTimeout')
}, 0)
async1()
new Promise(function (resolve) {
  console.log('promise1')
  resolve()
}).then(function () {
  console.log('promise2')
})
console.log('script end')
  1. 整体 script 作为第一个宏任务进入主线程,代码自上而下执行,执行同步代码,输出 script start
  2. 遇到 setTimeout,加入到任务队列
  3. 执行 async1(),输出async1 start;然后遇到await async2(),await 实际上是让出线程的标志,首先执行 async2(),输出async2;把 async2() 后面的代码console.log('async1 end')加入微任务队列中,跳出整个 async 函数。(async 和 await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是微任务。)
  4. 继续执行,遇到 new Promise,输出promise1,把.then()之后的代码加入到微任务队列中
  5. 继续往下执行,输出script end。接着读取微任务队列,输出async1 endpromise2,执行完本轮的宏任务。继续执行下一轮宏任务的代码,输出setTimeout

最后执行结果为:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

例题5

我们来看下面一段代码:

setTimeout(function () {
  console.log('timer1')
}, 0)

requestAnimationFrame(function () {
  console.log('requestAnimationFrame')
})

setTimeout(function () {
  console.log('timer2')
}, 0)

new Promise(function executor(resolve) {
  console.log('promise 1')
  resolve()
  console.log('promise 2')
}).then(function () {
  console.log('promise then')
})

console.log('end')
  • 整体 script 代码执行,开局新增三个宏任务,两个 setTimeout 和一个 requestAnimationFrame
  • 遇到 Promise,先输出promise1, promise2,加把 then 回调加入微任务队列。
  • 继续往下执行,输出end
  • 执行 promise 的 then 回调,输出promise then
  • 接下来剩三个宏任务,我们可以知道的是timer1会比timer2先执行,那么requestAnimationFrame呢?

当每一轮事件循环的微任务队列被清空后,有可能发生 UI 渲染,也就是说执行任务的耗时会影响视图渲染的时机。

通常浏览器以每秒 60 帧(60fps)的速率刷新页面,这个帧率最适合人眼交互,大概 1000ms/60 约等于 16.7ms 渲染一帧,如果要让用户看得顺畅,单个宏任务及它相应的微任务最好能在 16.7ms 内完成。

requestAnimationFrame 是什么?

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。 requestAnimationFrame 的基本思想是 让页面重绘的频率和刷新频率保持同步,相比 setTimeout,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机。

但这个也不是每轮事件循环都会执行 UI 渲染,不同浏览器有自己的优化策略,比如把几次的视图更新累积到一起重绘,重绘之前会通知 requestAnimationFrame 执行回调函数,也就是说 requestAnimationFrame 回调的执行时机是在一次或多次事件循环的 UI render 阶段。

在我的谷歌浏览器执行结果:

promise 1
promise 2
end
promise then
requestAnimationFrame
timer1
timer2

在我的火狐浏览器执行结果:

promise 1
promise 2
end
promise then
timer1
timer2
requestAnimationFrame

谷歌浏览器中的结果 requestAnimationFrame()是在一次事件循环后执行,火狐浏览器中的结果是在三次事件循环结束后执行。

可以知道,浏览器只保证 requestAnimationFrame 的回调在重绘之前执行,但没有确定的时间,何时重绘由浏览器决定。

易考点:

  1. promise本身是一个同步的代码,只有它后面调用的then()方法里面的回调才是微任务。
  2. then方法需要Promise里的resolve传值才会执行。
  3. await右边的表达式还是会立即执行,表达式之后的代码才是微任务, await微任务可以转换成等价的promise微任务分析。
  4. script标签本身是一个宏任务, 当页面出现多个script标签的时候,浏览器会把script标签作为宏任务来解析。

致谢:

新版本:

《JavaScript任务队列的顺序机制(事件循环)》:杨子聪

《深入浏览器原理之事件循环》:无及物

《浏览器 - Chrome》:细粒丁

《彻底搞懂浏览器中的事件循环机制》:一纸荒年

《浏览器事件循环》:阮超民

《【前端进阶】深入理解浏览器事件循环----只知道宏任务跟微任务已经不够用了》:Hansel

老版本:

《JavaScript 运行机制详解:再谈Event Loop》:阮一峰

《你知道JS的执行原理吗?一文了解Event Loop事件循环、微任务、宏任务》:Sailing

《这一次,彻底弄懂 JavaScript 执行机制》:ssssyoki

《浏览器事件循环机制(event loop)》:追风筝的人er