站点图标

浅谈浏览器Event Loop [更新]

2020-06-24折腾记录浅谈 / JavaScript / 前端
本文最后更新于 410 天前,文中所描述的信息可能已发生改变

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

Jake Archibald: 在循环 - JSConf.Asia

什么是 Event Loop?

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

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


_16
import java.util.Date;
_16
_16
public class Main {
_16
public static void main(String[] args) {
_16
// 打印该句执行的时间
_16
System.out.println("begin: " + new Date().getTime());
_16
try {
_16
// 利用sleep模拟请求时的状态
_16
Thread.sleep(5000);
_16
} catch (InterruptedException e) {
_16
e.printStackTrace();
_16
}
_16
// 打印该句执行的时间
_16
System.out.println("end: " + new Date().getTime());
_16
}
_16
}

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

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

Task

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

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

Promise

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


_28
var re = "foo";
_28
_28
var promise1 = new Promise(function (resolve, reject) {
_28
setTimeout(function () {
_28
resolve(re);
_28
}, 300);
_28
});
_28
_28
promise1.then(function (value) {
_28
console.log("one call value: " + value);
_28
console.log("one vall re prev: " + re);
_28
re = "foo2";
_28
console.log("one call re: " + re);
_28
});
_28
_28
promise1.then(function (value) {
_28
console.log("two call value: " + value);
_28
console.log("two call re: " + re);
_28
});
_28
_28
console.log(promise1);
_28
_28
// > [object Promise]
_28
// > "one call value: foo"
_28
// > "one vall re prev: foo"
_28
// > "one call re: foo2"
_28
// > "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


_10
var fun = async function foo() {
_10
return "hello";
_10
};
_10
_10
var re = fun();
_10
console.log(re);
_10
re.then((value) => console.log(value));
_10
_10
// > [object Promise]
_10
// > "hello"

运行重置输入

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

一个有用的例子


_29
console.log("sync1");
_29
_29
setTimeout(function () {
_29
//1
_29
console.log("sync_timeout1");
_29
}, 0);
_29
_29
var promise = new Promise(function (resolve, reject) {
_29
setTimeout(function () {
_29
//2
_29
console.log("pro_new_timeout");
_29
}, 0);
_29
console.log("pro_new");
_29
resolve();
_29
});
_29
_29
promise.then(() => {
_29
console.log("pro_then");
_29
setTimeout(() => {
_29
//3
_29
console.log("pro_timeout");
_29
}, 0);
_29
});
_29
_29
setTimeout(function () {
_29
//4
_29
console.log("sync_timeout2");
_29
}, 0);
_29
console.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 的末尾执行完毕,其实这并不准确。我们来看看以下代码:


_8
button.addEventListener("click", () => {
_8
Promise.resolve().then(() => console.log("Microtask 1"));
_8
console.log("Listener 1");
_8
});
_8
button.addEventListener("click", () => {
_8
Promise.resolve().then(() => console.log("Microtask 2"));
_8
console.log("Listener 2");
_8
});

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

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

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


_9
button.addEventListener("click", () => {
_9
Promise.resolve().then(() => console.log("Microtask 1"));
_9
console.log("Listener 1");
_9
});
_9
button.addEventListener("click", () => {
_9
Promise.resolve().then(() => console.log("Microtask 2"));
_9
console.log("Listener 2");
_9
});
_9
button.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▽, ̄)╭

浅谈浏览器Event Loop [更新]

https://blog.ixk.me/post/talking-about-browser-event-loop
  • 许可协议

    BY-NC-SA

  • 发布于

    2020-06-24

  • 本文作者

    Otstar Lin

转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!

WSL2 踩坑记录从零实现一个 PHP 微框架 - Bootstrap 启动加载