大家好,我是前端dog君,一名95后前端小兵。2019年毕业于北京化工大学,天津人,不知道有校友和老乡嘛?对前端的热爱,让我们在此相聚,希望这篇文章,能帮助到您,也同时希望能交到志同道合的小伙伴,共同发展,一起进步。我的微信号dm120225,备注简书,期待您的光临。
今天在公司遇到一个因javascript 事件循环引发的bug,趁着这个机会将js事件循环机制进行梳理。
js是单线程的
我们大家都知道,javascript语言的一大特点就是单线程,也就是说,同一时间只能做一件事,那么为什么javascript不能有多个线程呢?多线程可以提高响应速度,提高效率呀。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
单线程的js如何执行异步代码?
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
js引擎执行异步代码而不用等待,是因有为有 消息队列和事件循环。
消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。
实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。
js事件循环
浏览器中的事件循环和node环境下事件循环大同小异,但是也会有细微的不同,这里我们暂时只介绍浏览器事件循环。
首先我们先介绍几个概念:
执行栈:同步代码的执行,按照顺序添加到执行栈中
事件队列:异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。
宏任务和微任务
JS 引擎把所有任务分成两类,一类叫宏任务(macroTask),一类叫微任务(microTask)。
为什么要引入微任务,只有一种类型的任务不行么?
页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。
我们来看下什么情况下回触发宏任务和微任务:
宏任务:
- script(整体代码)
- setTimeout/setInterval
- I/O
- UI 渲染
- postMessage
- MessageChannel
- requestAnimationFrame
- setImmediate(Node.js 环境)
微任务:
- new Promise().then()
- MutaionObserver
- process.nextTick(Node.js 环境)
运行机制
异步任务的返回结果会被放到一个任务队列中,根据异步事件的类型,这个事件实际上会被放到对应的宏任务和微任务队列中去。
在当前执行栈为空时,主线程会查看微任务队列是否有事件存在。
- 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。
- 如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;
当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
简单总结一下执行的顺序:执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。
面试题解析
我们找到几道关于javascript事件循环的经典面试题,一起来剖析一下
下面代码输出什么
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');
先执行宏任务(当前代码块也算是宏任务),然后执行当前宏任务产生的微任务,然后接着执行宏任务
- 从上往下执行代码,先执行同步代码,输出 script start
- 遇到setTimeout,现把 setTimeout 的代码放到宏任务队列中
- 执行 async1(),输出 async1 start, 然后执行 async2(), 输出
- async2,把 async2() 后面的代码 console.log('async1 end')放到微任务队列中
- 接着往下执行,输出promise1,把 .then()放到微任务队列中;注意Promise本身是同步的立即执行函数,.then是异步执行函数
- 接着往下执行, 输出 script end。同步代码(同时也是宏任务)执行完成,接下来开始执行刚才放到微任务中的代码
- 依次执行微任务中的代码,依次输出 async1 end、promise2, 微任务中的代码执行完成后,开始执行宏任务中的代码,输出setTimeout
最后的执行结果如下
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
下面代码输出什么
console.log('start');
setTimeout(() => {
console.log('children2');
Promise.resolve().then(() => {
console.log('children3');
})
}, 0);
new Promise(function(resolve, reject) {
console.log('children4');
setTimeout(function() {
console.log('children5');
resolve('children6')
}, 0)
}).then((res) => {
console.log('children7');
setTimeout(() => {
console.log(res);
}, 0)
})
这道题跟上面题目不同之处在于,执行代码会产生很多个宏任务,每个宏任务中又会产生微任务
- 从上往下执行代码,先执行同步代码,输出start
- 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列①中
- 接着往下执行,输出children4, 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列②中,此时.then并不会被放到微任务队列中,因为 resolve是放到 setTimeout中执行的
- 代码执行完成之后,会查找微任务队列中的事件,发现并没有,于是开始执行宏任务①,即第一个 setTimeout, 输出children2,此时,会把 Promise.resolve().then放到微任务队列中。
- 宏任务①中的代码执行完成后,会查找微任务队列,于是输出children3;然后开始执行宏任务②,即第二个 setTimeout,输出children5,此时将.then放到微任务队列中。
- 宏任务②中的代码执行完成后,会查找微任务队列,于是输出children7,遇到 setTimeout,放到宏任务队列中。此时微任务执行完成,开始执行宏任务,输出children6;
最后的执行结果如下
start
children4
children2
children3
children5
children7
children6
下面代码输出什么
const p = function() {
return new Promise((resolve, reject) => {
const p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 0)
resolve(2)
})
p1.then((res) => {
console.log(res);
})
console.log(3);
resolve(4);
})
}
p().then((res) => {
console.log(res);
})
console.log('end');
- 执行代码,Promise本身是同步的立即执行函数,.then是异步执行函数。遇到setTimeout,先把其放入宏任务队列中,遇到p1.then会先放到微任务队列中,接着往下执行,输出 3
- 遇到 p().then 会先放到微任务队列中,接着往下执行,输出end
- 同步代码块执行完成后,开始执行微任务队列中的任务,首先执行 p1.then,输出2, 接着执行p().then, 输出 4
- 微任务执行完成后,开始执行宏任务,setTimeout, resolve(1),但是此时 p1.then已经执行完成,此时 1不会输出。
最后的执行结果如下
3
end
2
4
总结
js是一门单线程的语言,对异步代码的执行采取的是事件循环机制。我们无论在日常工作中还是面试中,可能会遇到一些奇奇怪怪的问题,这多半是由于javascript的事件循环机制引发的。我们可以回忆一下js事件循环过程,宏任务放入执行栈,微任务,宏任务,渲染,下一个宏任务放入执行栈,微任务,宏任务,渲染......相信大家了解了js的事件循环机制,在以后的工作中处理奇奇怪怪的bug,能够游刃有余。祝大家学习进步,天天开心,💪!
参考链接:
JS是单线程,你了解其运行机制吗?
JavaScript事件循环机制解析
深入理解 JavaScript 之事件循环(Event Loop)
我是前端dog君,一名95后前端小兵。对前端的热爱,让我们在此相聚,希望这篇文章,能帮助到您,也同时希望能交到志同道合的小伙伴,共同发展,一起进步。我的微信号dm120225,备注简书,期待您的光临。