一道题彻底理清 Browser Context 的 Event Loop

作者:胡晓晓  职位:前端工程师

背景

众所周知,JavaScript 是一门单线程语言,同一时刻只能执行一个任务。同步任务都在主线程上执行,形成一个执行栈。遇到异步任务,如果未拿到结果一直等待的话,就会阻塞后续代码执行,这种结果是我们不想看到的。

那么是否存在这么一种机制:遇到异步代码,JavaScript 单线程运行时不会阻塞?答案是肯定的:Event Loop 事件循环就是解决异步阻塞的一种机制。

一、基本概念回顾

在进入本文的整体之前,我们先来回顾一些基本的概念。

1、执行和运行

JavaScript 执行和运行是两个概念:

  1. 执行:依赖于环境,如浏览器、node 等等,在不同环境执行机制不尽相同。

  2. 运行:JavaScript 的解析引擎。

2、浏览器线程

浏览器内核是多线程的,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  1. GUI 渲染线程

    • 主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等。

    • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。

    • 该线程与 JavaScript 引擎线程互斥,当执行 JavaScript 引擎线程时,GUI 渲染会被挂起,当任务队列空闲时,JavaScript 引擎才会去执行 GUI 渲染。

  2. JavaScript 引擎线程

    • 主要负责处理 JavaScript 脚本,执行代码。

    • 也是主要负责执行准备好待执行的事件,即定时器计数结束或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS 引擎线程的执行。

    • 该线程与 GUI 渲染线程互斥,当 JavaScript 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。

  3. 定时触发器线程

    • 负责执行异步定时器一类的线程,如:setTimeout,setInterval。

    • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行。

  4. 事件触发线程

    • 主要负责将准备好的事件交给 JavaScript 引擎线程执行。

    • 比如 setTimeout 定时器计数结束, ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS 引擎线程的执行。

  5. 异步 http 请求线程

    • 负责执行异步请求一类的线程,如:Promise,axios,ajax 等。

    • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JavaScript 引擎线程执行。

3、执行栈

JavaScript 执行栈又叫调用栈(LIFO),用来存储代码运行时创建的所有执行上下文。

-->当 JavaScript 引擎第一次遇到脚本 script 标签

    --> 创建一个全局的执行上下文并且压入当前执行栈 

        --> 当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文,压入栈的顶部

        --> 该函数执行结束时,执行上下文从栈中弹出
        --> 控制流程到达当前栈中的下一个上下文

        ... ...

二、Browser Context 的 Event Loop

众所周知,JavaScript 是一门单线程语言,有同步任务和异步任务之分。

异步任务回调函数是放在任务队列的。

任务队列有两种:宏任务(macro-task)队列和微任务( micro-task)队列。

  • 宏任务队列是一次只执行一个任务

  • 微任务是一次执行当前微任务队列中的所有任务,

 

常见的macro-task 如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染等。
常见的 micro-task 如: process.nextTick、new Promise().then(回调)、MutationObserver 等。

 

Browser Context 的 Event Loop 解读

  1. 一开始我们的执行栈为空,全局上下文(script标签)被推入执行栈,代码被执行。

  2. 代码执行过程中,会判断是同步任务还是异步任务,同步任务会被放入执行栈,立刻执行;异步任务会交给对应的异步处理模块去处理,异步处理完成后,会把异步任务的回调放入任务队列,当前任务执行完后出栈;

  3. 如果主线程任务全部执行完成,执行栈为空,接下来我们会检查微任务队列,如果队列不为空,会把微任务队列的回调放入主线程去执行,直到当前微任务队列被清空;

  4. 然后执行宏任务队列,宏任务队列是一次只执行一个任务;

  5. 3和4循环往复,直到两个队列都清空。


 

三、一道题彻底理清 Event loop

在 index.html 中有这么一段代码

 

当我们点击 inner 时,输出什么?

  • click
  • promise
  • mutate
  • click
  • promise
  • mutate
  • timeout
  • timeout

 

如果 JavaScript 代码中加一行 inner.click(),又会输出什么呢?
答案是:

  • click
  • click
  • promise
  • mutate
  • promise
  • timeout
  • timeout

 

为什么加了一行代码,输出结果就改变了呢?

上文提到了浏览器内核是多线程的。我们上面这段代码用到了 JavaScript 引擎线程、定时器触发线程以及事件触发线程。

 

先来分析第一段代码

JavaScript 是单线程,顺序执行代码,所以,当 script 标签里的代码执行完成后,执行栈为空:

如上图所示:执行栈 Call Stack 为空,同时浏览器内核中事件触发线程队列中放入了两个任务。

 

当我们点击 inner 时,会发生什么呢?

  1. 点击 inner 时,事件触发线程会把 inner click callback 放入任务队列;发生冒泡,事件触发线程会把outer click callback 放入任务队列;

 

 

2. 检查微任务队列, MicroTask 为空,继续下一步;

 

 

3. 从 MactoTask 取出一个宏任务,放入主线程

 

 

 

3.1 执行 onClick

遇到同步代码顺序执行,输出 `click`

 

 

执行完出栈

 

 

3.2 遇到 setTimeout 放入定时器触发线程,当 setTimeout 的 callback 触发时,会把setTimeout callback 放入 MacroTask 队列

 

3.3 Promise.then 和 MutationObserver 放入微任务队列;

 

 

3.4 当前任务处理完成,onClick 出栈

 

4. 然后继续下一轮的Event Loop(事件循环);此时检查微任务队列,微任务队列不为空
4.1 取出一个微任务,放入执行栈,执行代码,此时会输出 `promise `,执行完出栈

 

4.2 继续清空微任务队列,重复上一步步骤,此时会输出 `mutate` 。

 

 

5. 取出宏任务队列中的一个执行,步骤同3;

 

 

6. 继续下一轮事件循环,清空微任务队列,同步骤4;

 

 

7. Macro-tasks 任务队列取出一个任务,放入任务队列

  • 同步任务继续执行

 

 

  • 执行完成出栈

 

 

8. 又一轮event loop 开始,此时微任务队列为空;

 

9. 从宏任务队列取出一个任务执行,同步骤7,执行完成后出栈,此时宏任务队列为空。

 

最终输出结果如上图所示。

 

inner.click() 发生了什么

与上一段代码不同,执行 inner.click 之后,Call Stack 队列不为空:

 

1. 执行栈 inner 的click callback先被执行

  • 首先是 console.log('click'),同步代码立刻执行,输出`click`

  • 执行到 setTimeout,异步代码,交给定时器触发线程去处理,处理完成后callback放入MacroTasks

  • 执行到 Promise.then 异步代码,放入微任务队列 MicroTasks ;

  • 执行到 MutationObserver 异步代码,放入微任务队列 MicroTasks ;

 


  • inner 的click callback代码执行完成,出栈

 

 

2.主线程执行栈不为空,继续执行Call Stack的下一个方法, outer 的click callback 方法被执行,同1的步骤,outer 的callback执行中有一点需要注意,微任务队列中最多只能有一个MutationObserver,所以执行完成后结果如下:

 

 

3. 此时 inner.click 代码执行完毕,出栈

 

 

4.  检查微任务队列,微任务队列不为空

4.1 取出一个微任务,放入执行栈,执行代码,此时会输出 `promise `,执行完出栈

4.2 继续清空微任务队列,重复上一步步骤,一直到清空微任务队列,此时会依次输出 mutaion promise

 

 

5. 宏任务队列不为空,取出第一个任务放入执行栈执行,此时输出 timeout

 

 

6. 重复4和5 进入下一次事件循环,此时微任务队列为空;

7. 宏任务队列不为空,取出第一个任务放入执行栈,同5

 

 

8. 继续重复4和5 ,直到宏任务队列和微任务队列都为空。

 

三、结束语

本文旨在帮助大家理解浏览器的 Event Loop ,以图文的形式呈现给大家,所以图会比较多,如有不同见解,欢迎大家一起探讨。

 

 

Back to Blog

Related Articles

线程池原理与实践|从入门到放弃,深度解析

作者:刘振 职位:Java工程师

分布式WebSocket服务的挑战和一个简单实现

作者:董礼 职位:后端工程师

如何实现一个延时队列 ?

作者:田燕青 职位:后期工程师
Contact Icon
Message Icon
Back To Top