前端中的 eventloop 事件循环机制
众所周知 JavaScript 是单线程的,也就是同一时间内只能做一件事情。就比如车站过安检一样,都会一个个通过,这就是单线程。那么这样问题就来了,假如程序中有一个很慢的 http 请求,用户必须要等到请求响应后才可以继续后续的操作,这时单线程就不妥了。JavaScript 解决这个问题使用的是非阻塞模型,也就是说没有同步返回请求,会被挂起,等待请求得到响应后,再去执行请求对应的回调函数。
所以借这一篇文章好好深入理解一下 JavaScript 中的时间循环机制。
事件循环 eventloop
在 JavaScript 中,我们把任务分为同步任务和异步任务。
首先我们看一段代码的执行顺序:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
以上代码的执行结果为:
// script start
// script end
// promise1
// promise2
// setTimeout
为什么会是这样的结果,因为任务进入执行栈之后会判断一下是否是同步任务,若是同步任务就会进入主线程执行;异步任务就会到事件表里面注册回调函数到事件队列。
- 同步和异步任务分别进入不同的执行环境,同步的进入主线程,异步的进入 Event Table 并注册函数;
- 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue;
- 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。
所有的任务可以分为同步任务和异步任务,同步任务,顾名思义,就是立即执行的任务,同步任务一般会直接进入到主线程中执行;而异步任务,就是异步执行的任务,比如ajax网络请求,setTimeout 定时函数等都属于异步任务,异步任务会通过任务队列的机制(先进先出的机制)来进行协调。
上述过程会不断重复,也就是常说的 Event Loop (事件循环)。
宏任务和微任务
JavaScript 中所有的任务都可以分为 宏任务(macrotask) 和 微任务(microtask)两大类,这两类任务分别维护一个队列,均采用先进先出的策略进行执行。
**宏任务 **主要包含:
- script( 整体代码);
- setTimeout、setInterval;
- I/O、UI 交互事件、setImmediate(Node.js 环境)。
微任务 主要包含:
- Promise;
- MutaionObserver;
- process.nextTick(Node.js 环境)。
setTimeout/Promise 等 API 便是任务源,而进入任务队列的是由他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中 setTimeout 与 setInterval 是同源的。
同步执行的任务都在宏任务上执行。具体的操作步骤如下:
- 从宏任务的头部取出一个任务执行;
- 执行过程中若遇到微任务则将其添加到微任务的队列中;
- 宏任务执行完毕后,微任务的队列中是否存在任务,若存在,则挨个儿出去执行,直到执行完毕;
- GUI 渲染;
- 回到步骤 1,直到宏任务执行完毕;
再看上面的那个例子:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
上面的代码执行顺序为:
- 整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出 script start;
- 遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中;
- 遇到 Promise,其 then函数被分到到微任务 Event Queue 中,记为 then1,之后又遇到了 then 函数,将其分到微任务 Event Queue 中,记为 then2;
- 遇到 console.log,输出 script end。
所以最终输出结果为:
// script start
// script end
// promise1
// promise2
// setTimeout
setTimeout 与 setInterval
上面的过程理解后,可以看下面的一个例子,输出结果应该发生在大约 3 秒后还是 5 秒后?
setTimeout(() => {
console.log("world");
},3000);
// 假设此处 doSomeTask 为同步任务,且执行需要5秒
doSomeTask();
console.log("hello");
我们来分析一下,因为 setTimeout 和 setInterval 是同源的宏任务,而整个 script 代码是在宏任务中,所以先执行 setTimeout ,将回调函数放在宏任务队列中暂存,然后继续执行当前的宏任务。由于 doSomeTask() 函数的执行需要消耗 5 秒钟,这段时间主进程一直保持执行当前宏任务,所以不会去执行 setTimeout 的回调。当当前宏任务中的最后一行代码 console.log 执行完成后,主线任务结束。
主线宏任务结束后,检查是否有等待的微任务,由于这里没有微任务,所以开始从宏任务队列中取出回调函数执行,由于 timeout 已经超时,所以立刻执行。
最后,这段代码的执行结果为:
// 程序启动执行大约 5 秒后输出
hello
world
async 与 await
从上面的代码中也能看到 Promise.then 中的代码是属于微服务,那么 async-await 的代码怎么执行呢?比如下面的代码:
function taskA() {
return Promise.resolve(Date.now());
}
async function taskB() {
console.log(Math.random());
let now = await taskA();
console.log(now);
}
console.log(1);
taskB();
console.log(2);
其实,async-await 只是 Promise+generator 的一种语法糖而已。上面的代码我们改写为这样,可以更加清晰一点:
function taskA() {
return Promise.resolve(Date.now());
}
function taskB() {
console.log(Math.random());
taskA().then(function(now) {
console.log(now);
})
}
console.log(1);
taskB();
console.log(2);
// 输出结果:
// 1
// 0.5184300792539205(随机数)
// 2
// 1594416282155(时间戳)
引申:setTimeout 与 setInterval 延时准确吗
从上面的分析可以看出来,使用 setTimeout 与 setInterval 的延时是非常不准确的。执行进程只能保证超时后尽快执行,但是却不能保证超时则立刻执行,主要原因还是要看是否有同步任务阻塞进程或微任务等待执行,假如当前同步执行的宏任务比较耗时,且又有微任务等待的话,那么 setTimeout 与 setInterval 将非常不准确。
另外一种使用延时的方式是通过 requestAnimationFrame 来实现,但依然不准确,在前端开发中,并没有能准确异步延时的方法。