28.1 为什么要有异步任务
因为JS是单线程的。所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
由于程序运行时,可能会因为IO设备(输入输出设备)很慢(比如Ajax
操作从网络读取数据)而发生程序运行较慢。
为提高程序相应速度,于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
- 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
-
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
-
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
-
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
-
(4)主线程不断重复上面的第三步。
28.2 浏览器中的 Event Loop
涉及面试题:异步代码执行顺序?解释一下什么是
Event Loop
?
- 主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
上图中,主线程运行的时候,产生堆(
heap
)和栈(stack
),栈中的代码调用各种外部API
,它们在"任务队列"中加入各种事件(click
,load
,done
)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
-
JS
在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到Task
(有多种task
) 队列中。一旦执行栈为空,Event Loop
就会从Task
队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说JS
中的异步还是同步行为 -
不同的任务源会被分配到不同的
Task
队列中,任务源可以分为 微任务(microtask
) 和 宏任务(macrotask
)。在ES6
规范中,microtask
称为jobs
,macrotask
称为task
1. 微任务(jobs)
process.nextTick
(Node.js环境)promise
Object.observe
(废弃)MutationObserver
2. 宏任务(task)
script
setTimeout
setInterval
setImmediate
(Node.js环境)I/O
UI rendering
宏任务中包括了
script
,浏览器会先执行一个宏任务,接下来有异步代码的话就将回调函数放入任务队列,之后先执行微任务,最后再执行之前宏任务放入的回调函数
//一道事件循环的题
console.log('script start'); //宏任务 同步
setTimeout(function() {
console.log('setTimeout'); //宏任务 异步
}, 0);
new Promise((resolve) => {
console.log('Promise') //宏任务 同步
resolve()
}).then(function() {
console.log('promise1'); //微任务
}).then(function() {
console.log('promise2'); //微任务
});
console.log('script end'); //宏任务 同步
// script start => Promise => script end => promise1 => promise2 => setTimeout
- 以上代码虽然
setTimeout
写在Promise
之前,但是因为Promise
属于微任务而setTimeout
属于宏任务setTimeout(fn,0)
的含义是指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它的回调函数是在下一轮“事件循环”开始时执行的。- Promise.resolve()在本轮“事件循环”结束时执行
在每一次事件循环中,macrotask 只会提取一个执行(setTimeout(fn,0)是宏任务),而 microtask 会一直提取,直到 microtasks 队列清空。
28.3 一次正确的 Event loop顺序
JS是单线程的,为了防止一个函数执行时间过长阻塞后面的代码,所以会先将同步代码压入执行栈中,依次执行,将异步代码推入异步队列,异步队列又分为宏任务队列和微任务队列,因为宏任务队列的执行时间较长,所以微任务队列要优先于宏任务队列。微任务队列的代表就是,Promise.then,MutationObserver
,宏任务的话就是setImmediate setTimeout setInterval
- 执行同步代码,这属于宏任务,遇到宏任务中的异步任务则将异步任务的回调函数放入此次事件循环的最后
- 执行栈为空,查询是否有微任务需要执行,把微任务添加到任务队列中
- 立即执行任务队列中的所有微任务(依次执行),最后是
setTimeout(fn,0)
(异步回调函数执行) - 开始检查渲染,然后 GUI 线程接管渲染
- 渲染完毕,JS 线程继续接管,然后开始下一轮
Event loop
。
通过上述的
Event loop
顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作DOM
的话,为了更快的响应界面响应,我们可以把操作DOM
放入微任务中
//一道事件循环的题
new Promise(resolve => {
resolve(1);
Promise.resolve().then(() => {
// t2
console.log(2)
});
console.log(4)
}).then(t => {
// t1
console.log(t)
});
console.log(3);
这段代码的流程大致如下:
script
任务先运行。首先遇到 Promise 实例,构造函数首先执行,所以首先输出了 4。此时 microtask 的任务有 t2 和 t1script
任务继续运行,输出 3。至此,第一个宏任务执行完成。- 执行所有的微任务,先后取出 t2 和 t1,分别输出 2 和 1
- 代码执行完毕
综上,上述代码的输出是:4321
为什么 t2 会先执行呢?理由如下:
- 根据 Promises/A+规范:
实践中要确保onFulfilled
和onRejected
方法异步执行,且应该在then
方法被调用的那一轮事件循环之后的新执行栈中执行Promise.resolve
方法允许调用时不带参数,直接返回一个resolved
状态的 Promise 对象。立即resolved
的Promise
对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
补充:setTimeout 倒计时为什么会出现误差
上面讲了定时器是属于 宏任务(
macrotask
) 。如果当前 执行栈 所花费的时间大于定时器时间,那么定时器的回调在宏任务(macrotask
) 里,来不及去调用,所有这个时间会有误差。
根据以下代码进行讨论
setTimeout(function () {
console.log('biubiu');
}, 1000);
//某个执行时间很长的函数();
setInterval(function(){
let j = 0
while(j++ < 100000000)
}, 0)
如果定时器下面的函数执行要
5
秒钟,那么定时器里的log
则需要5
秒之后再执行,函数占用了当前 执行栈 ,等执行栈执行完毕后需要再去读取 微任务(microtask
),等微任务(microtask
) 完成,这个时候才会去读取 宏任务(macrotask
) 里面的setTimeout
回调函数执行。setInterval
同理,例如每3秒放入宏任务,也要等到执行栈的完成。
如何解决倒计时误差问题
- 一般的解决方法是前端定时向服务器发送请求获取最新的时间差来校准倒计时时间,主动(程序里设置定时请求)或被动的(F5 已被用户按坏)区别而已。这个方法简单但也有点粗暴,
下面提供一种方法,能够一定程度上不依赖服务端实现倒计时的纠偏。
const interval = 1000
let ms = 50000, // 从服务器和活动开始时间计算出的时间差,这里测试用 50000 ms
let count = 0
const startTime = new Date().getTime()
let timeCounter
if( ms >= 0) {
timeCounter = setTimeout(countDownStart, interval)
}
function countDownStart () {
count++
const offset = new Date().getTime() - (startTime + count * interval) // A
let nextTime = interval - offset
if (nextTime < 0) {
nextTime = 0
}
ms -= interval
console.log(`误差:${offset} ms,下一次执行:${nextTime} ms 后,离活动开始还有:${ms} ms`)
if (ms < 0) {
clearTimeout(timeCounter)
} else {
timeCounter = setTimeout(countDownStart, nextTime)
}
}
通过递归调用
setTimeout
进行倒计时操作的执行。而每次执行函数时会维护一个count
变量,用以记录已经执行过的倒计时次数,使用代码 A 处的公式可计算出当前执行倒计时的时间与实际应执行时间的偏差,进而可以计算出下次执行倒计时的时间。