开篇
我们都知道JavaScript是单线程的语言,它不像大多数语言可以开启多线程,当处理一些阻塞并且很慢的操作时,它可以通过多线程使操作变成异步(例如读取文件等IO操作)。其实JavaScript也有同步异步的区分。首先来看一下同步与异步的区别
// 同步alert('阻塞中...')console.log('待执行')// 异步setTimeout(() => { alert('阻塞中...')}, 1000)console.log('待执行')复制代码
从上面俩张图可以看到区别,alert()
同步会阻塞应用,导致下面代码只好等待其完成,而setTimeout
异步则不需要等待,log
可以先执行。那刚才说JavaScript没有多线程,它又是如何实现异步操作的呢,这里就要说到事件循环啦。
正题
事件循环(the Event Loop)
事件循环会有一个事件的执行栈,当我们调用一个函数,它的地址、参数、局部变量都会压入这个栈中,当运行完之后,他们又会依次的出栈。我们拿一个例子来展示下这个过程。
// 平方function square(num) { return num * num}// 平方求和function sumSqrt(a, b) { return square(a) + square(b)}function log(a, b) { console.log(sumSqrt(a, b))}log(2, 3) // 13复制代码
上面的例子是传入俩个参数打印出他们的平方和,当运行log
函数时,该函数入栈,发现需要求平方和时,sumSqrt
函数入栈,之后,发现需要先计算平方,square
函数入栈。当square
执行完返回结果后,出栈,以此类推。整个事件循环就是依次入栈,再依次出栈。具体流程图如下:
通过上图应该很容易理解JavaScript的执行栈是如何运作的,那要实现异步该如何做呢,第一步就是不要停留在执行栈中,因为执行栈是入栈出栈操作,停留久了就会造成阻塞。所以此时就要说到第二个概念任务队列,
当浏览器遇到需要异步操作时,会把它放入任务队列去执行,执行成功后,会将回调函数返回给主线程。
那是不是任务队列中的异步操作执行完成后,会立即将回调函数入栈从而执行呢,答案当然是否定的,如果是那样,整个程序运行的岂不是很乱套。之所以叫事件循环是因为它是有一个周期的概念,每次循环代表一个周期的。
在这个周期内,主线程执行栈正常工作,当主线程任务清空时,会从任务队列中提取到已完成的异步回调函数入栈,然后执行栈又开始工作,进入下一次事件循环。具体什么意思呢,我们还是用代码配合图来演示整个事件循环周期:
这里要注意的是:每次事件循环只会从任务队列中获取一个回调函数,无论回调队列中有多少个函数,都只会有一个推到主线程。其他的函数需要等到下一次事件循环(主线程任务又清空时)。举个例子:
console.log('script start')setTimeout(() => { console.log('异步操作1')}, 1000)setTimeout(() => { console.log('异步操作2')}, 1000)console.log('script end')/* 执行结果 * script start * script end * 异步操作1 * 异步操作2 */复制代码
放到chrome控制台执行,虽然结果上看似同时打印异步操作1、异步操作2,但其实打印“异步操作1”的回调函数先被推入主线程,当log执行完后,又进入下一次事件循环,把打印的“异步操作2”的函数推入主线程。所以这里延伸了一个问题:
setTimeout
不一定是在规定时间内后立即执行。如上述例子,1000ms只代表多长时间后进入回调队列,但什么时候去执行它,要看主线程的任务什么时候结束。
在这里推荐大家一个事件循环可视化网址 ,在这里可以自己写函数做测试,加深对事件循环机制的印象。弹窗的视频也是非常的赞,推荐大家跟着视频实践一下。
宏任务(macrotask)、微任务(microtask)
其实上面所讲的任务队列存放的任务都叫宏任务,宏任务是指时间耗时比较长的。
宏任务(macrotask): setTimeout
、 setInterval
、setImmediate
,I/O
、UI rendering
微任务(microtask):process.nextTick
、Promises
、Object.observe
、MutationObserver
俩者的区别在于在事件循环中,每次只会执行一个macrotask,而所有microtask都会依次执行直到为空。并且每次主线程任务被清空时,先执行所有microtask,再去执行一个macrotask。
console.log('script start');setTimeout(function() { console.log('setTimeout1'); setTimeout(function() { console.log('setTimeout2'); })}, 0);Promise.resolve().then(function() { console.log('promise1');}).then(function() { console.log('promise2');});console.log('script end');/** * script start * script end * promise1 * promise2 * setTimeout1 * setTimeout2*/复制代码
上面事件循环的整体流程是:
Cycle 1
- 打印'script start'
setTimout(fn, 0)
被调度到任务队列去执行Promise.resolve()
被列为microtask- 打印'script end'
- stack 清空 microtasks 执行
- 打印'promise1'
- .then()被列为microtask,继续执行microtasks
- 打印'promise2'
Cycle 2
- microtasks 队列清空 setTimout 的回调可以执行
- 打印'setTimeout1'
- 回调函数内
setTimout(fn, 0)
被调度到任务队列去执行
Cycle3
- setTimout 的回调可以执行
- 打印'setTimeout2'
我们用图片在演示一遍:
总结
- JavaScript是单线程语言,通过事件循环和任务队列来实现异步操作。
- 异步操作都会放到任务队列中去执行,执行完毕后,回调函数等待被调度
- 主线程任务清空时,首先会执行所有的microTask,然后执行一个macroTask
- setTimeout不会按照给定时间后执行(主线程任务阻塞时,会一直等待)
- microTask异步也可能造成程序阻塞。(因为每次事件循环会执行所有microTask,死循环)