本文详细解析 JavaScript 单线程模型下的事件循环机制。通过对比宏任务与微任务的执行队列,结合 Promise、setTimeout 及 async/await 示例代码,阐明代码输出的具体顺序。同时探讨 requestAnimationFrame 的执行时机及定时器延迟的不确定性,帮助开发者掌握异步编程的核心逻辑。
前言
前言
JavaScript 采用单线程执行模型,那么异步代码是如何被处理的呢?以下面的代码为例,我们来分析其输出结果。
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
new Promise(function(resolve) {
console.log(3);
resolve(Date.now());
}).then(function() {
console.log(4);
});
console.log(5);
setTimeout(function() {
new Promise(function(resolve) {
console.log(6);
resolve(Date.now());
}).then(function() {
console.log(7);
});
}, 0);
在运行代码之前,可以先尝试预测最终的输出顺序,随后我们将展开具体的分析过程。
宏任务与微任务
1. 宏任务与微任务
基于编写 Ajax 的经验,JavaScript 通常按语句顺序执行。当遇到异步操作时,会先发起请求并继续执行后续代码,待异步结果返回后再执行回调。但内部是如何管理这些任务的执行顺序呢?
在 JavaScript 中,任务被划分为宏任务(macrotask)和微任务(microtask)。这两类任务分别维护独立的队列,并遵循先进先出(FIFO)的策略执行。同步任务在宏任务队列中执行。
常见的宏任务包括:script(整体代码)、setTimeout、setInterval、I/O 操作、UI 交互事件、postMessage、MessageChannel 以及 Node.js 环境下的 setImmediate。
常见的微任务包括:Promise.then、MutationObserver 以及 Node.js 环境下的 process.nextTick。
事件循环的具体执行步骤如下:
- 从宏任务队列头部取出一个任务执行;
- 在执行过程中,若遇到微任务,则将其加入微任务队列;
- 当前宏任务执行完毕后,检查微任务队列。若存在微任务,则依次执行直到队列为空;
- 进行 GUI 渲染;
- 返回步骤 1,继续处理下一个宏任务,直到宏任务队列执行完毕;
上述四个步骤构成了事件循环检测机制,即 Event Loop。
代码执行顺序分析
回到前文提到的代码示例:
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
new Promise(function(resolve) {
console.log(3);
resolve(Date.now());
}).then(function() {
console.log(4);
});
console.log(5);
setTimeout(function() {
new Promise(function(resolve) {
console.log(6);
resolve(Date.now());
}).then(function() {
console.log(7);
});
}, 0);
其执行步骤如下:
- 执行 console.log(1),输出 1;
- 遇到 setTimeout,将回调函数 console.log(2) 加入宏任务队列等待执行;
- 执行 console.log(3),将 Promise.then 中的 console.log(4) 加入微任务队列;
- 执行 console.log(5),输出 5;
- 再次遇到 setTimeout,将回调函数中的 console.log(6) 和 console.log(7) 相关逻辑加入宏任务队列;
- 当前宏任务执行完毕,检查微任务队列,发现存在一个微任务 console.log(4)(在步骤 3 中添加),执行并输出 4;
- 从宏任务队列取出下一个任务 console.log(2) 执行,输出 2;
- 当前宏任务执行完毕,检查微任务队列,此时为空;
- 取出下一个宏任务执行,执行 console.log(6),并将 Promise.then 中的 console.log(7) 加入微任务队列;
- 当前宏任务执行完毕,检查微任务队列,存在一个微任务 console.log(7)(在步骤 9 中添加),执行并输出 7;
因此,最终的输出顺序为:1, 3, 5, 4, 2, 6, 7。
耗时操作对 Event Loop 的影响
我们在 Promise.then 中实现一个耗时操作,使执行过程更加直观:
console.log(1);
var start = Date.now();
setTimeout(function() {
console.log(2);
}, 0);
setTimeout(function() {
console.log(4, Date.now() - start);
}, 400);
Promise.resolve().then(function() {
var sum = function(a, b) {
return Number(a) + Number(b);
}
var res = [];
for(var i=0; i<5000000; i++) {
var a = Math.floor(Math.random()*100);
var b = Math.floor(Math.random()*200);
res.push(sum(a, b));
}
res = res.sort();
console.log(3);
})
在 Promise.then 中,先生成一个包含 500 万个随机数的数组,然后对该数组进行排序。运行这段代码可以发现:立即输出 1,稍等片刻后输出 3,最后输出 2。无论等待多久,输出 3 和 2 的顺序固定,3 总是在 2 之前。这验证了 Event Loop 的第 3 步:必须等待所有微任务执行完毕后,才开始执行下一个宏任务。
此外,这段代码的输出结果颇具意味:
setTimeout(function() {
console.log(4, Date.now() - start); // 4, 1380 电脑状态的不同,输出的时间差也不一样
}, 400);
虽然设定了 400ms 后输出,但由于前序任务耗时严重,导致后续任务只能顺延。这也说明 setTimeout 和 setInterval 的延时并不精确,它们仅能将任务安排在大约 400ms 后的宏任务队列中,具体执行时间取决于线程是否空闲。若前一个任务包含耗时操作,或有无限微任务加入,则会阻塞后续任务的执行。
async/await 的执行机制
2. async/await
从上述代码可以看出,Promise.then 中的代码属于微任务。那么 async/await 的代码如何执行?以下面代码为例:
function A() {
return Promise.resolve(Date.now());
}
async function B() {
console.log(Math.random());
let now = await A();
console.log(now);
}
console.log(1);
B();
console.log(2);
实际上,async/await 只是 Promise 与 Generator 的语法糖。将上述代码改写为如下形式,可以看得更清晰:
function B() {
console.log(Math.random());
A().then(function(now) {
console.log(now);
})
}
console.log(1);
B();
console.log(2);
这样我们就能明确输出的先后顺序:1, 0.4793526730678652(随机数), 2, 1557830834679(时间戳)。
requestAnimationFrame 的执行时机
3. requestAnimationFrame
requestAnimationFrame 属于异步执行方法,但它既不属于宏任务,也不属于微任务。根据 MDN 的定义:
window.requestAnimationFrame() 告知浏览器希望执行一个动画,并要求浏览器在下次重绘之前调用指定的回调函数来更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
requestAnimationFrame 在 GUI 渲染之前执行,但在微任务之后。不过,它不保证在当前帧必须执行,具体在哪一帧执行由浏览器根据当前策略自行决定。
总结
4. 总结
我们需要记住最重要的两点:JavaScript 是单线程的,且存在 Event Loop 循环机制。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对本站的支持。