头图

js的事件循环机制

LeapFE

一、js 的定义和特性?

众所周知,js 是一门单线程的非阻塞的脚本语言。

单线程:只有一个调用栈,同一时刻只能干一件事,代码是一段一段执行的。

调用栈:是一个数据结构,记录我们程序运行到哪一个阶段了,如果调用了函数就进栈,如果函数返回结果,就出栈(进栈出栈)。

非阻塞:代码需要进行一项异步任务的时候,主线程会挂起这个任务,然后在异步任务返回结果的时候,再根据一段的规则去执行相应的回调。

为什么是单线程的?
这是因为 js 创立之初的目的就在于与浏览器交互,而浏览器要大量操作 dom,试想一下,如果同时对某个 dom 节点进行修改和删除的操作,那会发生什么呢?所以决定了 js 只能是单线程的。

为什么非阻塞呢?
我们在页面中通常会发大量的请求,获取后端的数据去渲染页面。因为浏览器是单线程的,试想一下,当我们发出异步请求的时候,阻塞了,后面的代码都不执行了,那页面可能出现长时间白屏,极度影响用户体验。

二、浏览器环境下?

这里,我们只谈论 Google 的 js 引擎---V8 引擎(nodeJS 也是 v8 引擎)。

1.浏览器环境下 js 是怎样工作的?

1.1js 的引擎简图

主要是由两部分组成:

  • 1.emory Heap(内存堆) —  内存分配地址的地方
  • 2.Call Stack(调用堆栈) — 代码执行的地方
1.2.js 运行过程简图

  • 1.js 引擎(js 代码执行在调用栈)
  • 2.webapis(浏览器提供给我们的,不是 js 引擎提供的,例如:Dom,ajax,setTimeout)
  • 3.回调队列(callback queue 包括宏任务,微任务)
  • 4.事件循环(event loop)
1.3.js 运行过程:
  • 1.当 js 运行时,碰到同步任务,就在stack里执行
  • 2.一旦碰到异步任务,主线程会挂起这个任务,把异步回调结果放在callback queue里。
  • 3.等待当前stack中的所有任务都执行完毕,主线程处于闲置状态时,主线程会去查找callback queue是否有任务。如果有,那么主线程会从中取出回调(此处区分宏任务与微任务)放入stack中,然后执行其中的同步代码...,如此反复。
    由于第三步,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因

2.浏览器环境下 js 的事件循环机制?

2.1 宏任务和微任务(callback queue回调队列里面2条平行的队列,宏任务队列和微任务队列,宏任务队列里面放宏任务的回调,微任务队列里面放微任务的回调)
  • 宏任务:script(整体代码),setInterval(),setTimeout(),setImmediate(Nodejs), I/O, UI rendering
  • 微任务:process.nextTick(Nodejs),Promises,Object.observe, MutationObserver
2.2 js 事件循环代码
console.log(1);
setTimeout(function a() {
  console.log(2);
}, 0);
new Promise(function (resolve, reject) {
  console.log(5);
  resolve();
}).then(function () {
  console.log(6);
});
new Promise(function (resolve, reject) {
  resolve();
}).then(function () {
  console.log(7);
});
console.log(3);

结果:1,5,3,6,2

分析:代码从上往下执行,先打印同步任务,1。碰到 setTimeout,把回调函数 a()放到 callback queue 的宏任务里去。然后碰到 Promise,打印 new Promise 的同步任务 5,接着把 then 回调(console.log(6)),放入 callback queue 的微任务里去,然后打印同步任务 3。此时 call stack 为空,去查找 callback queue,微任务比宏任务先,且当前循环会处理当前所有微任务队列中的事件。所以,先打印 6,再打印 7,在打印 2.
总结:先执行同步任务,再执行微任务,最后执行宏任务
2.3.多次 js 事件循环
let promiseGlobal = new Promise(function (resolve) {
  console.log(1);
  resolve("2");
});
console.log(3);

promiseGlobal.then(function (data) {
  console.log(data);
  let setTimeoutInner = setTimeout(function (_) {
    console.log(4);
  }, 1000);
  let promiseInner = new Promise(function (resolve) {
    console.log(5);
    resolve(6);
  }).then(function (data) {
    console.log(data);
  });
});
let setTimeoutGlobal = setTimeout(function (_) {
  console.log(7);
  let promiseInGlobalTimeout = new Promise(function (resolve) {
    console.log(8);
    resolve(9);
  }).then(function (data) {
    console.log(data);
  });
}, 1000);

执行顺序是 1,3,2,5,6,间隔一秒,7,8,9,4

解答如下:

  • 1.打印完 1,3
    本轮执行栈执行完毕
  • 2.打印完 1,3,2,5,6
    微任务队列清空,eventloop 完成,下一次 eventloop 开始
  • 3.打印完 1,3,2,5,6,7
    本轮执行栈执行完毕
  • 4.打印完 1,3,2,5,6,7,8,9
    微任务队列清空,eventloop 完成,下一次 eventloop 开始
  • 5.打印完 1,3,2,5,6,7,8,9,4
    eventloop 完成
 ⚠️易错点:
 之所以把这道题拿出来讲,是因为这道题涉及到多次事件循环,很多同学容易搞混的点。

3.总结

  • 当前执行栈执行完毕,会立即处理所有微任务队列中的事件,再去宏任务队列中取出一个事件
  • 在一次事件循环中,微任务永远在宏任务之前执行

二、宏任务、微任务、Dom 渲染的顺序

1.浏览器包含多个进程

  • 1.主进程

    • 协调控制其他子进程(创建、销毁)
  • 2.第三方插件进程

    • 每种类型的插件对应一个进程,仅当使用该插件时才创建
  • 3.GPU 进程

    • 用于 3D 绘制等
  • 4.渲染进程,就是我们说的浏览器内核(最重要

    • 负责页面渲染,脚本执行,事件处理等
    • 每个 tab 页一个渲染进程

2.渲染进程包含了多个线程:

  • 1.JS 引擎线程

    • 负责处理解析和执行 javascript 脚本程序
    • 只有一个 JS 引擎线程(单线程)
    • 与 GUI 渲染线程互斥,防止渲染结果不可预期
  • 2.GUI 渲染线程

    • 负责渲染页面,布局和绘制
    • 页面需要重绘和回流时,该线程就会执行
    • 与 js 引擎线程互斥,防止渲染结果不可预期
  • 3.http 请求线程

    • 浏览器有一个单独的线程用于处理 AJAX 请求
  • 4.事件处理线程(鼠标点击、ajax 等)

    • 用来控制事件循环(鼠标点击、setTimeout、ajax 等)
  • 5.定时器触发线程

    • setInterval 与 setTimeout 所在的线程

3. 为什么 JS 引擎线程和 GUI 渲染线程是互斥的?

JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面,那么渲染线程前后获得的元素数据就可能不一致了。因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。

4. 为什么 JS 会阻塞页面加载?

从上面的互斥关系可以推导出,JS 如果执行时间过长就会阻塞页面。譬如,假设 JS 引擎正在进行巨量的计算,此时就算 GUI 有更新,也会被保存到队列中,等待 JS 引擎空闲后执行。然后,由于巨量计算,所以 JS 引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。所以,要尽量避免 JS 执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

5. JS 引擎线程和 GUI 渲染线程是互斥的,那先后顺序呢?

把下面三段代码放到浏览器的控制台执行:
document.body.style = "background:black";
document.body.style = "background:red";
document.body.style = "background:blue";
document.body.style = "background:grey";

结果:背景直接变成灰色
分析:Call Stack 清空的时候,执行,执行到了 document.body.style = 'background:grey';这时,前面的代码都被覆盖了,此时 dom 渲染,背景色是灰色

document.body.style = "background:blue";
console.log(1);
Promise.resolve().then(function () {
  console.log(2);
  document.body.style = "background:black";
});
console.log(3);

结果:背景直接变成黑色
分析:document.body.style = 'background:blue'是同步代码,document.body.style = 'background:black'是微任务,此时微任务执行完,才会进行 dom 渲染,所以背景色是黑色

document.body.style = "background:blue";
setTimeout(function () {
  document.body.style = "background:black";
}, 0);

结果:背景先一闪而过蓝色,然后变成黑色
分析:document.body.style = 'background:blue';是同步代码,document.body.style = 'background:black'是宏任务,所以 dom 在同步代码执行完,宏任务执行之前会渲染一次。然后宏任务执行完又会渲染一次。2 次渲染,所以才会呈现背景先一闪而过蓝色,然后变成黑色,这种效果。

总结:
1.先把Call Stack清空
2.然后执行当前的微任务
3.接下来DOM渲染
微任务在dom渲染`之前`执行,宏任务在dom渲染`之后`执行。

三、nodeJs 环境下的 js 事件循环机制

⚠️ 注意:以下内容 node 的版本大于等于 11.0.0

1.NodeJs 的架构图


解释:

  • 1.Node Standard Library:Node.js 标准库,这部分是由 Javascript 编写的。使用过程中直接能调用的 API。例如模块 http、buffer、fs、stream 等
  • 2.Node bindings:这里就是 JavaScript 与 C/C++ 连接的桥梁,前者通过 bindings 调用后者,相互交换数据。
  • 3.最下面一层是支撑 Node.js 运行的关键,由 C/C++ 实现(比如:V8:Google 开源的高性能 JavaScript 引擎,使用 C++ 开发)

2.libuv 引擎

libuv 专注于异步 I/O.是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一 API,Node.js的Event Loop 是基于libuv实现的

3.node 环境下 js 是怎样运行的?

  • (1)V8 引擎解析 JavaScript 脚本。
  • (2)解析后的代码,调用 Node API。
  • (3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
  • (4)V8 引擎再将结果返回给用户

4.js 事件循环的阶段

  • 1.timer:这个阶段执行 timer(setTimeout、setInterval)的回调
  • 2.I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调(tcp 错误)
  • 3.idle,prepare 阶段:仅供 node 内部使用,忽略
  • 4.poll 阶段:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 5.check:setImmediate() 回调函数在这里执行
  • 6.close callbacks:一些关闭的回调函数,如:socket.on('close', ...)

5.process.nextTick 和 microtask 的特别

都是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

6.js 事件循环的顺序

  • 宏任务:script(整体代码),setInterval(),setTimeout(),setImmediate(Nodejs), I/O, UI rendering
  • 微任务:process.nextTick(Nodejs),Promises,Object.observe, MutationObserver
setTimeout(funciton(){console.log(1)});
setImmediate(function(){console.log(2)});
process.nextTick(function(){console.log(3)});
Promise.resolve().then(function(){console.log(4)});
(function() {console.log(5)})();

打印结果:5,3,4,1,2

总结:先执行同步任务,接下来执行 process.nextTick,再接下来 Promise 的微任务,最后是 js 事件循环的 6 个阶段,从上到下顺序执行。

⚠️ 注意:每个阶段都有一个先进先出的回调函数队列。只有一个阶段的回调函数队列清空了,该执行的回调函数都执行了,事件循环才会进入下一个阶段。

四、浏览器和nodeJs 环境下的 js 事件循环机制对比

1. 浏览器下打印:time1,promise1,time2,promise2

因为执行完2个定时器,回调都进入宏任务队列了。然后开始事件循环,因为宏任务是一个个执行的,所以先把第一个定时器的回调放入调用栈中,执行完time1,把微任务放入微任务队列中。
这是调用栈清空,又开始事件循环,这时候有微任务promise1,和第二个宏任务。因为微任务在宏任务之前执行,所以先执行promise1,
这是调用栈又清空,又开始事件循环。执行第二个宏任务,打印,time2,promise2

2.node环境下打印:time1,timer2,promise1,promise2

因为已经在timer阶段了,所以。先执行完time阶段,time1,time2,然后看到微任务,执行微任务。

参考文章

1.从多线程到 Event Loop 全面梳理

阅读 277

好未来-励步前端团队,期待您的加入,简历发送至 arthas.zheng@tal.com

898 声望
1.9k 粉丝
0 条评论
你知道吗?

好未来-励步前端团队,期待您的加入,简历发送至 arthas.zheng@tal.com

898 声望
1.9k 粉丝
宣传栏