一直对谁js里面谁先执行、谁后执行、微任务、宏任务是一团浆糊,这两天看看了掘金上大神(ZavierTang)的文章做个笔记,加深一下印象
js是单线程、异步、非阻塞、解释型脚本语言
我在想既然是单线程事件,定时器、异步又是怎么回事?带着问题我们来往下走。
js是单线程的,处理事情也是一件一件地去处理,用过Js的都知道执行的顺序是自上而下
console.log(1)
console.log(2)
console.log(3)
// 1
// 2
// 3
上面的代码会依次打印1、2、3
这样的话那如果第一个任务非常耗时 (或者是等待)很久的话,如:网络请求、定时器、等待鼠标点击等,后面的任务也就会被阻塞掉。就是说会阻塞所有的用户交互(按钮、滚动条等),会带来极不友好的体验
但是:
console.log(1)
setTimeout(() => {
console.log(2)
},1000)
console.log(3)
// 1
// 3
// 2
’2’ 在 ‘3’ 后面再打印的,也就是说计时器并没有阻塞后面的代码。那,发生了什么?
其实,JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:
如果大家想详细的地去了解浏览器的渲染进程的话可以去自己百度看看,这里不作详细的解说了
当遇到计时器、DOM事件监听或者是网络请求的任务时,JS引擎会将它们直接交给 webapi,也就是浏览器提供的相应线程(如定时器线程为setTimeout计时、异步http请求线程处理网络请求)去处理,而JS引擎线程继续后面的其他任务,这样便实现了 异步非阻塞。
定时器触发线程也只是为 setTimeout(…, 1000) 定时而已,时间一到,还会把它对应的回调函数(callback)交给 消息队列 去维护,JS引擎线程会在适当的时候去消息队列取出消息并执行,后面再去谈
同步、异步
上面提到的异步,我们都知道JS里面是有同步异步(请求、定时器)代码的,
同步就是刚刚说的,代码自上而下依次执行。
异步一般情况是:
上面写的setTimeout(() => { console.log(2) },1000) 便是发起了一个异步,() => {…} 便是异步的回调函数
事件循环与消息队列
其实 事件循环 机制和 消息队列(消息队列是类似队列的数据结构,遵循先入先出(FIFO)的规则。) 的维护是由事件触发线程控制的。
JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等…),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由 事件触发线程 将异步对应的 回调函数 加入到消息队列中,消息队列中的回调函数等待被执行。
同时,JS引擎线程会维护一个 执行栈,同步代码会依次加入执行栈然后执行,结束会退出执行栈。如果执行栈里的任务执行完成,即执行栈为空的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。执行完了后,执行栈再次为空,事件触发线程会重复上一步操作,再取出一个消息队列中的任务,这种机制就被称为事件循环(event loop)机制。
console.log(1)
setTimeout(() => {
console.log(2)
}, 1000)
// 点击页面
console.log(3)
console.log(4)
// 1
// 3
// 4
// 2
执行过程:
1.主代码块(script)依次加入执行栈,依次执行,主代码块为:
2 console.log() 为同步代码,JS引擎线程处理,打印 “1”,出栈;
3. 遇到异步函数 setTimeout,交给定时器触发线程(异步触发函数为:setTimeout,回调函数为:() => { … }),JS引擎线程继续,出栈;
4. console.log() 为同步代码,JS引擎线程处理,打印 “3”,出栈;
5. console.log() 为同步代码,JS引擎线程处理,打印 “4”,出栈;
6. 执行栈为空,也就是JS引擎线程空闲,这时从消息队列中取出(如果有的话)一条任务(callback)加入 执行栈,并执行;
7. 重复第6步
8. (此步的位置不确定)某个时刻(1000ms后),定时器触发线程通知事件触发线程,事件触发线程将回调函数 () => { … } 加入消息队列队尾,等待JS引擎线程执行。
可以看出,setTimeout异步函数对应的回调函数( () => {} )会在执行栈为空,主代码块执行完了后才会执行。
零延时时:
console.log('script start')
setTimeout(() => {
console.log('timer 1 over')
}, 1000)
setTimeout(() => {
console.log('timer 2 over')
}, 0)
console.log('script end')
// script start
// script end
// timer 2 over
// timer 1 over
这里会先打印 “timer 2 over”,然后打印 “timer 1 over”,尽管 timer 1 先被定时器触发线程处理,但是 timer 2 的callback会先加入消息队列。
上面,timer 2 的延时为 0ms,HTML5标准规定 setTimeout 第二个参数不得小于4(不同浏览器最小值会不一样),不足会自动增加,所以 “timer 2 over” 还是会在 “script end” 之后。
就算延时为 0ms,只是 timer 2 的回调函数会立即加入消息队列而已,回调的执行还是得等执行栈为空(JS引擎线程空闲)时执行。
其实 setTimeout 的第二个参数并不能代表回调执行的准确的延时事件,它只能表示回调执行的最小延时时间,因为回调函数进入消息队列后需要等待执行栈中的同步任务执行完成,执行栈为空时才会被执行
宏任务与微任务
以上机制在ES5的情况下够用了,但是ES6会有一些问题。
Promise同样是用来处理异步的:
console.log('script start')
setTimeout(function() {
console.log('timer over')
}, 0)
Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})
console.log('script end')
// script start
// script end
// promise1
// promise2
// timer over
WTF?? “promise 1” “promise 2” 在 “timer over” 之前打印了?
这里有一个新概念:macrotask(宏任务) 和 microtask(微任务)。
所有任务分为 macrotask 和 microtask:
JS引擎线程首先执行主代码块。
每次执行栈执行的代码就是一个宏任务,包括任务队列(宏任务队列)中的,因为执行栈中的宏任务执行完会去取任务队列(宏任务队列)中的任务加入执行栈中,即同样是事件循环的机制。
在执行宏任务时遇到Promise等,会创建微任务(.then()里面的回调),并加入到微任务队列队尾。
microtask必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task >> 渲染 >> 下一个task(从任务队列中取一个))。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。
也就是说,在某一个macrotask执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
这样就可以解释 “promise 1” “promise 2” 在 “timer over” 之前打印了。“promise 1” “promise 2” 做为微任务加入到微任务队列中,而 “timer over” 做为宏任务加入到宏任务队列中,它们同时在等待被执行,但是微任务队列中的所有微任务都会在开始下一个宏任务之前都被执行完。
在node环境下,process.nextTick的优先级高于Promise,也就是说:在宏任务结束后会先执行微任务队列中的nextTickQueue,然后才会执行微任务中的Promise。
执行机制:
一口气看完相信小伙伴们对JavaScript异步、事件循环与消息队列、微任务与宏任务都有一定的了解了
如果还没看过瘾可以去原文章看看点击这里