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

Jake Archibald: 在循环 – JSConf.Asia

什么是 Event Loop?

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

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

import java.util.Date;

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

从上面的运行结果可以看到,同步运行的时候 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 得到这个结果(返回值),或许这有点难以理解,那就举个例子吧:

var re = "foo"

var promise1 = new Promise(function(resolve, reject) {
	setTimeout(function() {
    	resolve(re);
	}, 300);
});

promise1.then(function(value) {
	console.log("one call value: " + value);
	console.log("one vall re prev: " + re);
	re="foo2";
	console.log("one call re: " + re);
});

promise1.then(function(value) {
	console.log("two call value: " + value);
  	console.log("two call re: " + re)
})

console.log(promise1);

// > [object Promise]
// > "one call value: foo"
// > "one vall re prev: foo"
// > "one call re: foo2"
// > "two call value: foo"
// > "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

var fun = async function foo(){
  return "hello";
}

var re = fun();
console.log(re);
re.then(value => console.log(value));

// > [object Promise]
// > "hello"

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

一个有用的例子

console.log('sync1');

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

var promise = new Promise(function (resolve, reject) {
    setTimeout(function () { //2
        console.log('pro_new_timeout')
    }, 0);
    console.log('pro_new');
    resolve();
});


promise.then(() => {
    console.log('pro_then');
    setTimeout(() => { //3
        console.log('pro_timeout');
    }, 0)
})

setTimeout(function () { //4
    console.log('sync_timeout2')
}, 0);
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 的末尾执行完毕,其实这并不准确。我们来看看以下代码:

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

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

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

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

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

说点什么
本博客评论规则(评论规则什么的都是浮云,小声
支持Markdown语法
在"浅谈浏览器Event Loop [更新]"已有4条评论
Loading...