理解事件循环

932 阅读11分钟

掘金关于事件循环(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')
})
async1()
new Promise(function (resolve) {
    console.log('promise1')
    resolve()
}).then(function () {
    console.log('promise2')
})
console.log('script end')

这道题涉及事件循环,微任务,定时器和 async/await 等等知识点,把它吃透,所有的事件循环问题基本无碍了。如果你还没搞明白,就跟着一起看看吧。

JavaScript 线程、异步和事件循环

其实我们都知道 JavaScript 是单线程,单线程意味着同一时间只能做一件事。为什么 JavaScript 不用多线程设计?这里引用阮一峰老师博客的一段话:

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

所以 JavaScript 单线程这个核心特征是不会改变的。而单线程会带来一个问题,就是会出现代码阻塞情况。单线程下,任务是排队执行的,必须等待前一个任务执行完成才会进入到下一个任务,那么如果前一个任务耗时太久,后一个任务就得一直处于等待状态,这对用户而言,就是一种“卡死”的不好体验了。而且,这种阻塞等待并不是因为硬件性能跟不上,纯属白白浪费了时间。

为了解决这个问题,引入了异步的概念,把所有任务分为两类,一类是同步任务,另一类就是异步任务了。同步任务是指在主线程上排队的任务,只有前一个任务结束,才会执行后一个任务。异步任务则不进入主线程,而进入“事件队列”中,当主线程执行完同步任务后,会从“事件队列”中读取事件,再进入主线程执行。

那么事件循环处在什么环节呢?其实是主线程读取“事件队列”中的事件,这个过程是不断重复的,这种运行机制就被成为“事件循环(Event Loop)”。事件循环其实就是 js 的执行机制。

事件循环的过程

来看下事件循环的过程,画了张图,也是在别处看到的然后自己又画了一遍。

当任务进入执行栈时,首先判断是同步任务还是异步任务,它们会进入不同的“场所”。同步任务直接进入主线程执行,而异步任务会进入 Event Table 中并注册回调函数。常见的异步任务一般就是定时器,DOM事件监听,Ajax请求这些了,当它们满足触发条件后,回调事件会被推入到 Event Queue 中,等待主线程来读取执行。主线程在执行完所有的同步任务后,才会去读取事件队列中的事件,读取事件的过程是一直重复的,这就是为啥叫“事件循环”而不是叫“任务循环”了。

了解完事件循环的过程,用一段代码来实战下:

console.log(1)

setTimeout(()=>{
    console.log(2)
}, 0)

console.log(3)

这道题很简单对不对,大家都会做,现在可以用上面图中的过程来描述下:

console.log(1) // 是同步任务,放到主线程中
setTimeout() // 是异步任务,放到 Event Table 中,0 毫秒后回调推入 Event Queue 中
console.log(3) // 是同步任务,放到主线程中

// 根据流程,同步任务先执行,即先打印 1 ,3,当同步任务执行完毕后,去事件队列中查看是否有可执行的函数,看到有定时器的回调,执行它,打印 2

这里其实有一个关于定时器的小细节,就是定时器的时间到了后,不是马上执行回调,而是把回调推入事件队列中,如果此时同步任务还没执行完,定时器的回调也不会触发的。

又说宏任务和微任务

上面的小栗子🌰解释了事件循环的过程,但是当你拿这个套路去做开头说的那道面试题时,就会发现事件并不是那么简单。简化一下,来看这题:

console.log(1)

setTimeout(()=>{
    console.log(2)
}, 0)

new Promise((resolve, reject)=>{
    console.log('new Promise')
    resolve()
}).then(()=>{
    console.log('then')
})

console.log(3)

还是按照上面流程图的过程来分析下这题:

console.log(1) // 同步,主线程中执行
setTimeout() // 异步,放到 Event Table,0 毫秒后回调推入 Event Queue 中
new Promise // 同步,主线程直接执行 console.log('new Promise')
.then // 异步,放到 Event Table
console.log(3) // 同步,主线程执行

所以按照分析,它的结果应该是 1 => 'new Promise' => 3 => 2 => 'then'

但是当你去浏览器执行时,会发现结果是:1 => 'new Promise' => 3 => 'then' => 2

分歧就出现在异步任务的执行顺序,事件队列其实是一个“先进先出”的数据结构,排在前面的事件会优先被主线程读取,例子中 setTimeout回调事件是先进入队列中的,按理说应该先于 .then 中的执行,但是结果却偏偏相反。

其实原因大家也了解,按照同步和异步的划分方式并不是那么精确,得用大家常说的“宏任务(Macrotask)”和 “微任务(Microtask)” 来解释。

其实是我在 MDN 上没搜到 Macrotask 相关的内容,但是能搜到 Microtask 相关的。似乎宏任务应该直接叫“Tasks”,而微任务就是“microtasks”,这里有一篇 MDN 的文章可以看看:Using microtasks in JavaScript with queueMicrotask(),还有另一篇常被提起的国外一篇文章:Tasks, microtasks, queues and schedules

好啦先不管叫法问题,认识下宏任务和微任务,这里也是总结别人的,大概这样:

  • 宏任务包含:script包含的整段代码,setTimeout,setInterval,setImmediate(nodejs中的)
  • 微任务包含:Promise,process.nextTick(nodejs中的)

也用一张图表示(画得还行吧):

按照这个流程,它的执行机制是:

  • 执行一个宏任务,如果遇到微任务就将它放到微任务的事件队列中
  • 当前宏任务执行完成后,会查看微任务的事件队列,然后将里面的所有微任务依次执行完

好了,了解完宏任务和微任务,这就来在线batter上面的题:

console.log(1)
setTimeout(()=>{
    console.log(2)
}, 0)
new Promise((resolve, reject)=>{
    console.log('new Promise')
    resolve()
}).then(()=>{
    console.log('then')
})
console.log(3)

// 先运行整段代码,属于宏任务
// 遇到 console.log(1) ,直接打印 1
// 遇到定时器,属于新的宏任务,留着后面执行
// 遇到 new Promise,这个是直接执行的,打印 'new Promise'
// .then 属于微任务,放入微任务队列,后面再执行
// 遇到 console.log(3) 直接打印 3
// 好了本轮宏任务执行完毕,现在去微任务列表查看是否有微任务,发现 .then 的回调,执行它,打印 'then'
// 当一次宏任务执行完,再去执行新的宏任务,这里就剩一个定时器的宏任务了,执行它,打印 2

这里再强调的就是微任务的执行顺序,如果说是宏任务执行完才执行微任务,这并不是特别准确。应该是某一轮宏任务执行完,才执行那一轮产生的微任务。也就是说微任务列表里的任务是基于某一次宏任务执行过程中而产生的,当轮宏任务执行完就要去将微任务列表里的任务依次执行完,然后才去执行下一个宏任务,如此往复,构成事件循环的机制。(感觉像是说了一段绕口令)

现在再尝试去做开头说的那道面试题,如果没做对,那就是要再看看 async/await 部分的东西了。

async 和 await

认识一个东西可以先从名称开始,async 是 “异步” 的意思,await 则可以理解为 “async wait ”。所以可以理解 async 就是用来声明一个异步方法,而 await 是用来等待异步方法执行。

需要说明的是,await 必须在 async 声明的函数中执行,那么 async 函数怎么执行?其实 async 函数可以直接运行,跟普通函数一样调用,那么 async 到底起什么作用呢?

关于 async

将这段代码复制到浏览器控制台运行一下:

function fn1 (){
    return 'hello'
}

async function fn2 (){
    return 'hi'
}

console.log(fn1()) // hello
console.log(fn2()) // Promise {<resolved>: "hi"}

从结果可以得知 async 的作用是将函数的返回值包装成一个 Promise 对象。如果使用 return 返回的值,会被 Promise.resolve() 封装成 Promise 对象。那么如果不返回任何值呢,例如:

async function log (){
    console.log('hello')
}

聪明的你一定可以想到,结果其实是 Promise {<resolved>: undefined}

由于 async 函数返回 Promise 对象,所以也可以使用 .then() 的方式处理它:

async function test (){
    return 'hello'
}

test().then(val=>{
    console.log(val) // hello
})

接下来再看看 await。

关于 await

惯性思维让人以为 await 只能用来等待 async 函数执行完成,其实没有规定 await 后面只能跟一个 async 函数调用,测试一下:

function fn1 (){
    return 'fn1'
}

async function fn2 (){
    return 'fn2'
}
async function test (){
    const v1 = await 123
    const v2 = await fn1()
    const v3 = await fn2()
    console.log(v1) // 123
    console.log(v2) // fn1
    console.log(v3) // fn2
}

test()

可以看到 await 后面是可以直接跟普通函数调用的,甚至跟一个直接量都可以。

单单使用 await 似乎并没啥用,日常开发中,我们都是结合 async 和 await 一起用的,通常 await 用来等待 async 函数返回的 Promise 对象,得到 resolve 中的结果。那么对于 await 而言,其等待的结果无非两种,“是 Promise 对象”“不是 Promise 对象”

注意,重点来了。其实不管等到的是什么,await 都会阻塞后面的代码,这个“阻塞后面的代码”有一点歧义,还是用代码表示比较好:

async function fn1 (){
    console.log(1)
    await test()  // test函数不会被阻塞
    console.log(2) // 这里被阻塞
}

当等待的不是 Promise 对象时,await 会阻塞下面的代码,先执行 async 外面的同步代码,同步代码执行完,再回到 async 函数中,把等到的非 Promise 对象的东西,作为 await 表达式的结果。再执行之前阻塞的代码。

当等待的是 Promise 对象时,一样的会阻塞下面的代码,先执行完 async 外面的同步代码,等着 Promise 对象 fulfilled,取得 resolve 的值作为表达式的结果。同样也要执行之前阻塞的代码。

举个🌰:

async function fn1 (){
    console.log(1)
    await fn2()
    console.log(2)
}

async function fn2 (){
    console.log('fn2')
}

fn1()
console.log(3)

这段代码依次打印的结果是:1fn232。让 await 等待一个非 Promise 对象,也是一样的效果,改下代码:

async function fn1 (){
    console.log(1)
    await fn2()
    console.log(2)
}

function fn2 (){ // 这里去除 async,fn2 此时是普通函数,不会返回 Promise
    console.log('fn2')
}

fn1()
console.log(3)

// 结果: 1,fn2,3,2

道友,你悟了没呀?!😂

直面那道题

我相信看完上面的一些分析后,再做这道题就很清晰了,毕竟有理有据了。来一起看下:

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')
})
async1()
new Promise(function (resolve) {
    console.log('promise1')
    resolve()
}).then(function () {
    console.log('promise2')
})
console.log('script end')

分析过程:

  1. 执行整段代码,遇到 console.log('script start') 直接打印结果,输出 script start
  2. 遇到定时器了,它是宏任务,先放着不执行;
  3. 遇到 async1(),执行 async1 函数,先打印 async1 start,下面遇到 await 怎么办?先执行 async2,打印 async2,然后阻塞下面代码,跳出去执行同步代码;
  4. 跳到 new Promise 这里,直接执行,打印 promise1,下面遇到 .then(),它是微任务,放到微任务列表等待执行;
  5. 最后一行直接打印 script end,现在同步代码执行完了,要干嘛?别忘记了还有一部分 await 下面的代码,打印 async1 end
  6. 现在本轮宏任务结束,去执行微任务,执行 then 的回调,打印 promise2
  7. 上一个宏任务所有事都做完了,开始下一个宏任务,是谁?就是定时器啦,打印 settimeout

所以最后的结果是:script startasync1 startasync2promise1script endasync1 endpromise2settimeout

要注意的是如果在比较低版本的nodejs中运行这段代码,结果可能不一样,升级一下版本就跟上面结果一样了。

就这样了吧。⛽️⛽️⛽️

补充一个小题目,挺有意思的:

let a = 0
let b = async () => {
  a = a + await 10
  console.log('2', a)
}
b()
a++
console.log('1', a)