11
Event Loop 是 JavaScript 异步编程的核心思想,也是前端进阶必须跨越的一关。同时,它又是面试的必考点,特别是在 Promise 出现之后,各种各样的面试题层出不穷,花样百出。这篇文章从现实生活中的例子入手,让你彻底理解 Event Loop 的原理和机制,并能游刃有余的解决此类面试题。

先来一道面试题镇楼

async function async1() { 
    console.log('async1 start'); 
await async2(); 
    console.log('async1 end'); 
} 
async function async2() { 
    console.log('async2'); 
} 
console.log('script start'); 
setTimeout(function() { 
    console.log('setTimeout'); 
}, 0); 
async1(); 
new Promise(function(resolve) { 
    console.log('promise1'); 
    resolve(); 
}).then(function() { 
    console.log('promise2'); 
}); 
console.log('script end');`

你是否有见过此类面试题?接下来让我们一步一步搞懂他!

先从js说起

首先明确一点,js是一门单线程语言。也就是说同一时间只能做一件事。
JavaScript为什么是单线程?与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
但单线程容易引起阻塞,比如:

alert(1);
console.log(2);
console.log(3);
console.log(4);

alert弹框只要不点击确定那就永远不会打印出2,3,4。
为了防止主线程堵塞,javaScript有了同步和异步的概念。

接下来我们说说同步、异步

同步:同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
异步:异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。这也就是定时器并不能精确在指定时间后输出回调函数结果的原因。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

上文提到了“执行栈、任务队列”,下面我们来唠唠他俩是啥
执行栈

当我们调用一个方法的时候,JavaScript 会生成一个与这个方法对应的执行环境,又叫执行上下文(context)。这个执行环境中保存着该方法的私有作用域、上层作用域(作用域链)、方法的参数,以及这个作用域中定义的变量和 this 的指向,而当一系列方法被依次调用的时候。由于 JavaScript 是单线程的,这些方法就会按顺序被排列在一个单独的地方,这个地方就是所谓执行栈。

任务队列

"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。

所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

了解了前面的知识后,我们再来看啥是事件循环(event loop)

我们注意到,在异步代码完成后仍有可能要在一旁等待,因为此时程序可能在做其他的事情,等到程序空闲下来才有时间去看哪些异步已经完成了。所以 JavaScript 有一套机制去处理同步和异步操作,那就是事件循环 (Event Loop)。
示意图如下:

宏任务和微任务

以去银行办业务为例,当 5 号窗口柜员处理完当前客户后,开始叫号来接待下一位客户,我们将每个客户比作 宏任务接待下一位客户 的过程也就是让下一个 宏任务 进入到执行栈。

所以该窗口所有的客户都被放入了一个 任务队列 中。任务队列中的都是 已经完成的异步操作的,而不是注册一个异步任务就会被放在这个任务队列中(它会被放到 Task Table 中)。就像在银行中排号,如果叫到你的时候你不在,那么你当前的号牌就作废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来以后还需要重新取号。

在执行宏任务时,是可以穿插一些微任务进去。比如你大爷在办完业务之后,顺便问了下柜员:“最近 P2P 暴雷很严重啊,有没有其他稳妥的投资方式”。柜员暗爽:“又有傻子上钩了”,然后叽里咕噜说了一堆。

我们分析一下这个过程,虽然大爷已经办完正常的业务,但又咨询了一下理财信息,这时候柜员肯定不能说:“您再上后边取个号去,重新排队”。所以只要是柜员能够处理的,都会在响应下一个宏任务之前来做,我们可以把这些任务理解成是 微任务

大爷听罢,扬起 45 度微笑,说:“我就问问。”

柜员 OS:“艹...”

这个例子就说明了:你大爷永远是你大爷 在当前微任务没有执行完成时,是不会执行下一个宏任务的!

总结一下,异步任务分为 宏任务(macrotask)微任务 (microtask)。宏任务会进入一个队列,而微任务会进入到另一个不同的队列,且微任务要优于宏任务执行。

常见的宏任务和微任务

宏任务:script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)

微任务:Promise.then、 MutaionObserver、process.nextTick (Node.js)

来几道题试试?
setTimeout(() => {
        console.log('A');
    }, 0);
    var obj = {
    func: function() {
        setTimeout(function() {
        console.log('B');
        }, 0);
        return new Promise(function(resolve) {
        console.log('C');
        resolve();
        });
    },
    };
    obj.func().then(function() {
        console.log('D');
    });
    console.log('E');

先把打印结果呈上
image.png
再把解释呈上:

  • 第一个 setTimeout 放到宏任务队列,此时宏任务队列为 ['A']
  • 接着执行 obj 的 func 方法,将 setTimeout 放到宏任务队列,此时宏任务队列为 ['A', 'B']
  • 函数返回一个 Promise,因为这是一个同步操作,所以先打印出 'C'
  • 接着将 then 放到微任务队列,此时微任务队列为 ['D']
  • 接着执行同步任务 console.log('E');,打印出 'E'
  • 因为微任务优先执行,所以先输出 'D'
  • 最后依次输出 'A''B'

再来一个?

    let p = new Promise(resolve => {
        resolve(1);
        Promise.resolve().then(() => console.log(2));
        console.log(4);
    }).then(t => console.log(t));
    console.log(3);

打印结果:
image.png

  • 首先将 Promise.resolve() 的 then() 方法放到微任务队列,此时微任务队列为 ['2']
  • 然后打印出同步任务 4
  • 接着将 p 的 then() 方法放到微任务队列,此时微任务队列为 ['2', '1']
  • 打印出同步任务 3
  • 最后依次打印微任务 21
当 Event Loop 遇到 async/await

async/await 仅仅是生成器的语法糖,所以不要怕,只要把它转换成 Promise 的形式即可。下面这段代码是 async/await 函数的经典形式。

  async function foo() {
    // await 前面的代码
    await bar();
    // await 后面的代码
   }
    async function bar() {
    // do something...
    }
    foo();

其中 await 前面的代码 是同步的,调用此函数时会直接执行;而 await bar(); 这句可以被转换成 Promise.resolve(bar())await 后面的代码 则会被放到 Promise 的 then() 方法里。因此上面的代码可以被转换成如下形式,这样是不是就很清晰了?

   function foo() {
        // await 前面的代码
        Promise.resolve(bar()).then(() => {
            // await 后面的代码
        });
    }
    function bar() {
        // do something...
    }
    foo();

最后我们回到开篇那个题目

function async1() {
  console.log('async1 start'); // 2

  Promise.resolve(async2()).then(() => {
    console.log('async1 end'); // 6
  });
}

function async2() {
  console.log('async2'); // 3
}

console.log('script start'); // 1

setTimeout(function() {
  console.log('settimeout'); // 8
}, 0);

async1();

new Promise(function(resolve) {
  console.log('promise1'); // 4
  resolve();
}).then(function() {
  console.log('promise2'); // 7
});
console.log('script end'); // 5
  • 首先打印出 script start
  • 接着将 settimeout 添加到宏任务队列,此时宏任务队列为 ['settimeout']
  • 然后执行函数 async1,先打印出 async1 start,又因为 Promise.resolve(async2()) 是同步任务,所以打印出 async2,接着将 async1 end 添加到微任务队列,,此时微任务队列为 ['async1 end']
  • 接着打印出 promise1,将 promise2 添加到微任务队列,,此时微任务队列为 ['async1 end', promise2]
  • 打印出 script end
  • 因为微任务优先级高于宏任务,所以先依次打印出 async1 endpromise2
  • 最后打印出宏任务 settimeout
Node.js 与 浏览器环境下事件循环的区别

Node.js 在升级到 11.x 后,Event Loop 运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval 和 setImmediate) 就立刻执行微任务队列,这点就跟浏览器端一致。

案例
案例1
    const p1 = new Promise((resolve, reject) => {
        console.log('promise1');
        resolve();
    })
   .then(() => {
        console.log('then11');
        new Promise((resolve, reject) => {
            console.log('promise2');
            resolve();
        })
        .then(() => {
            console.log('then21');
        })
        .then(() => {
            console.log('then23');
        });
  })
  .then(() => {
    console.log('then12');
  });

    const p2 = new Promise((resolve, reject) => {
    console.log('promise3');
        resolve();
    }).then(() => {
        console.log('then31');
    });
  • 首先打印出 promise1
  • 接着将 then11promise2 添加到微任务队列,此时微任务队列为 ['then11', 'promise2']
  • 打印出 promise3,将 then31 添加到微任务队列,此时微任务队列为 ['then11', 'promise2', 'then31']
  • 依次打印出 then11promise2then31,此时微任务队列为空
  • then21then12 添加到微任务队列,此时微任务队列为 ['then21', 'then12'](因为then21和then12都是第二层then)
  • 依次打印出 then21then12,此时微任务队列为空
  • then23 添加到微任务队列,此时微任务队列为 ['then23']
  • 打印出 then23
案例2

这道题实际在考察 Promise 的用法,当在 then() 方法中返回一个 Promise,p1 的第二个完成处理函数就会挂在返回的这个 Promise 的 then() 方法下,因此输出顺序如下。

    const p1 = new Promise((resolve, reject) => {
        console.log('promise1'); // 1
        resolve();
    })
    .then(() => {
        console.log('then11'); // 2
        return new Promise((resolve, reject) => {
            console.log('promise2'); // 3
            resolve();
        })
        .then(() => {
            console.log('then21'); // 4
        })
        .then(() => {
            console.log('then23'); // 5
        });
    })
    .then(() => {
        console.log('then12'); //6
    });

将不断更新完善,欢迎批评指正!

参考

http://www.ruanyifeng.com/blo...
https://juejin.im/post/5cbc0a...


薇薇
298 声望24 粉丝

路漫漫其修远兮,吾将上下而求索