4

浅谈浏览器Event Loop [更新]

 2 years ago
source link: https://blog.ixk.me/post/talking-about-browser-event-loop
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
本文最后更新于 268 天前,文中所描述的信息可能已发生改变

更新此篇文章的原因是看到一个 JSConf 关于事件循环的演讲,建议有能力的(能上 YouTube)看看这个演讲。

Jake Archibald: 在循环 - JSConf.Asia

什么是 Event Loop?

若你了解过 JavaScript,你一定知道 JavaScript 是一种单线程语言,为什么 JavaScript 是单线程呢?为什么不使用多线程呢?JavaScript 作为浏览器脚本语言(虽然现在也在后端挺流行的),JavaScript 的主要用途是与用户互动以及操作 DOM。若使用多线程就会导致一些问题,比如更新丢失等问题,当一个线程要删除 DOM 的时候另一个线程要更改它,那浏览器该如何操作呢。所以 JavaScript 为了避免多线程带来的一系列问题采用了单线程的运行机制。

而若只是单纯的同步单线程的执行便会导致 JS 运行到某个需要等待的位置时就会造成假死状态,比如当 JS 要从网络中获取一张巨大的图片,发起了 HTTP 请求,在等待 HTTP 请求中若是不采用某种机制来处理的话就会导致卡住的假死状态,我们可以用 Java 来模拟一下:

1import java.util.Date;
2
3public class Main {
4   public static void main(String[] args) {
5      // 打印该句执行的时间
6      System.out.println("begin: " + new Date().getTime());
7      try {
8         // 利用sleep模拟请求时的状态
9         Thread.sleep(5000);
10      } catch (InterruptedException e) {
11         e.printStackTrace();
12      }
13      // 打印该句执行的时间
14      System.out.println("end: " + new Date().getTime());
15   }
16}

2893c71b 90d9 4dfd a494 1ce351916c9f

从上面的运行结果可以看到,同步运行的时候 Java 在 begin 和 end 中间隔了非常久的时间,程序也在那时候被阻塞住了,而 UI 是不能被阻塞的否则会严重影响用户体验,所以 JS 采用异步来防止这种情况发生。

当 JS 线程执行到需要异步的操作的时候就会把该任务发到任务队列,然后继续向下执行,当所有的同步代码都执行完毕的时候,JS 线程就会从任务队列读取任务并执行,若遇到异步操作就继续入队。。。如此往复,这就是Event Loop(事件循环)

308e6df0 cd8a 414d a1eb 39abd35fb132

在浏览器中存在着一个任务队列,如上图,左侧绿色的部分,任务队列中存放着将要执行的任务,当任务队列中没有任务的时候,事件循环会进入空转的状态,但这并不代表浏览器是休眠的,从上图我们可以看到,除了任务队列外,事件循环还要处理渲染相关的任务。一旦有任务进入任务队列了,浏览器会在可以执行任务的时机从任务队列中取出任务并执行,执行完一个任务后就进入下一个循环,而不是逐个取出任务执行直到任务队列为空。

为了容易理解 Task,我们通常把 Task 分成 MacroTask (宏任务)MicroTask (微任务),宏任务很好理解,就是一些异步的任务,如 setTimeout 等等,而微任务比较常见的就是 Promise。微任务会在宏任务执行完后,并且 JS 调用栈为空的时候执行。具体的过程下面会分析。

Promise

Promise 是 ES6 新增的一种异步解决方案,它的运行方式是当需要进行 I/O,等待等异步操作的时候,不返回结果而是返回一个 Promise(“承诺”),当这个承诺完成的时候,即状态变为 fulfilled 或者 rejected ,这个 promise 就定格了,也可以认为返回值是一样的了,你可以在任何位置任何时间利用 then 得到这个结果(返回值),或许这有点难以理解,那就举个例子吧:

1var re = "foo";
2
3var promise1 = new Promise(function (resolve, reject) {
4  setTimeout(function () {
5    resolve(re);
6  }, 300);
7});
8
9promise1.then(function (value) {
10  console.log("one call value: " + value);
11  console.log("one vall re prev: " + re);
12  re = "foo2";
13  console.log("one call re: " + re);
14});
15
16promise1.then(function (value) {
17  console.log("two call value: " + value);
18  console.log("two call re: " + re);
19});
20
21console.log(promise1);
22
23// > [object Promise]
24// > "one call value: foo"
25// > "one vall re prev: foo"
26// > "one call re: foo2"
27// > "two call value: foo"
28// > "two call re: foo2"

运行重置输入

从上面的运行结果可以看到 re 确实在第一次调用 promise1 的时候被修改为 foo2,但是当第二次调用 promise 时的 value 并没有跟着改变,也就是说 promise 不会再调用第二次,而是直接返回结果。

async/await

ES7 中添加了 asyncawait 的关键字,async 返回的必定是 Promise,可以理解为就是异步函数,await 是等待 Promise。

async 可以使一个函数成为异步函数,返回 Promise,我们可以把 async 认为使new Promise 的语法糖,所以当函数 return 值的时候 return 的不是值而是 Promise,若要得到 async 中 return 的值就需要使用 then

1var fun = async function foo() {
2  return "hello";
3};
4
5var re = fun();
6console.log(re);
7re.then((value) => console.log(value));
8
9// > [object Promise]
10// > "hello"

运行重置输入

await 是等待后面东西,可以是 Promise,可以是值,可以是表达式,当可以直接得到值的时候 await 会立即返回值,但若是 Promise,await 就会将 JS 阻塞住,直到 Promise 兑现,有了 await,我们可以把异步的 JS 写成同步的 JS,可以有效的解决回调地狱。

一个有用的例子

1console.log("sync1");
2
3setTimeout(function () {
4  //1
5  console.log("sync_timeout1");
6}, 0);
7
8var promise = new Promise(function (resolve, reject) {
9  setTimeout(function () {
10    //2
11    console.log("pro_new_timeout");
12  }, 0);
13  console.log("pro_new");
14  resolve();
15});
16
17promise.then(() => {
18  console.log("pro_then");
19  setTimeout(() => {
20    //3
21    console.log("pro_timeout");
22  }, 0);
23});
24
25setTimeout(function () {
26  //4
27  console.log("sync_timeout2");
28}, 0);
29console.log("sync2");

运行重置输入

看到这个代码,是不是很晕,先不要放到 JS 中运行,让我们一起来看看这个的输出,你可以先不看以下的步骤自己思考一下,或许能得到不小收获。

首先,sync1 的 console.log 肯定是第一个输出的,调用栈先进了 console,然后出栈,控制台输出sync1

然后程序来到了第一个 setTimeout,这是一个异步宏任务,所以放到宏任务队列中,然后跳过该 setTimeout。MacroTask Queue:[setTimeout-1]

接下来程序来到了 new promise,new promise 是同步的,所以会进入到 function 中,遇到第二个 setTimeout,此时将这个 setTimeout 放到宏任务队列中,然后跳过 setTimeout,执行到 console.log,输出pro_new,接着遇到 resolve,是微任务将其放到微任务队列中,然后退出 function。MacroTask Queue:[setTimeout-1,setTimeout-2],MicroTask Queue:[resolve]

接着遇到了 promise 的 then,这是属于 resolve 的回调,当 resolve 状态改变的时候才执行,所以跳过该部分。MacroTask Queue:[setTimeout-1,setTimeout-2],MicroTask Queue:[resolve]

然后又遇到了一个 setTimeout,同样将其放到宏任务队列中。MacroTask Queue:[setTimeout-1,setTimeout-2,setTimeout-4],MicroTask Queue:[resolve]

接着遇到了最后一个 console.log,输出sync2,此时同步代码已经执行完毕。

微任务列表不为空,所以需要在这个 tick 中执行,不能先取宏任务,调用 resolve 的 then,输出pro_then,同时将 setTimeout 放入宏任务队列。MacroTask Queue:[setTimeout-1,setTimeout-2,setTimeout-4,setTimeout-3],MicroTask Queue:[]

微任务队列空了,从宏任务中取出队首的任务,即 setTimeout-1,执行后输出sync_timeout1

取出队首任务,setTimeout-2,输出pro_new_timeout

取出队首任务,setTimeout-4,输出sync_timeout2

取出队首任务,setTimeout-3,输出pro_timeout,此时宏任务列表和微任务队列为空,js 引擎进入等待状态。

  • sync1
  • pro_new
  • sync2
  • pro_then
  • sync_timeout1
  • pro_new_timeout
  • sync_timeout2
  • pro_timeout

你们猜对了吗?反正我第一次是没猜对(逃

一些值得了解的地方

很多文章会把 Task 分成 MacroTask 和 MicroTask,并说明 MicroTask 是在 MacroTask 的末尾执行完毕,其实这并不准确。我们来看看以下代码:

1button.addEventListener("click", () => {
2  Promise.resolve().then(() => console.log("Microtask 1"));
3  console.log("Listener 1");
4});
5button.addEventListener("click", () => {
6  Promise.resolve().then(() => console.log("Microtask 2"));
7  console.log("Listener 2");
8});

代码很简单,为一个按钮注册两个点击事件,当用户点击后,控制台会输出一些日志:

  • Listener 1
  • Microtask 1
  • Listener 2
  • Microtask 2

按照微任务在宏任务完成后执行的流程,对照这输出,似乎并没有错误,但是,如果我们手动触发点击事件会如何呢?

1button.addEventListener("click", () => {
2  Promise.resolve().then(() => console.log("Microtask 1"));
3  console.log("Listener 1");
4});
5button.addEventListener("click", () => {
6  Promise.resolve().then(() => console.log("Microtask 2"));
7  console.log("Listener 2");
8});
9button.click();

此时的输出就变成了:

  • Listener 1
  • Listener 2
  • Microtask 1
  • Microtask 2

从以上输出可以看到,微任务一定会在该宏任务下执行完毕的说法是错误的,那么,是什么原因造成二者的差异呢?其实,这是就是我在 Task 章节里说到的,微任务会在宏任务执行完后并且 JS 调用栈为空的时候执行。在第二段代码中,执行完 Listener 1 宏任务的时候,JS 调用栈还存在着 button.click() 这个函数的栈帧,所以 Microtask 1 微任务就无法被执行,直到 Listener 2 完成的时候,button.click() 函数调用帧才会出栈,此时 JS 调用栈才为空,微任务才可开始执行,所以就有了以上的输出。

学了好久的前端,之前对异步只处于会用的状态,前几天刚了解了一下 JS 的异步,刚好好久没写过文章了,便自己整理了写成一篇文章,输出才是最好的学习,其实是为了水文章(逃。( ̄ y▽, ̄)╭


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK