笔者最近在复习事件循环这个老生常谈的话题,看到有的文章提到“异步任务分为宏任务和微任务”,即宏任务属于异步任务。这和我理解的不太一样,于是决定重新梳理一遍事件循环。
先说我得出的结论:宏任务跟同步异步无关,可以是同步,也可以是异步,而微任务则全是异步。
下面开始重头讲浏览器的事件循环,希望对各位看官老爷有帮助。
关于事件循环有一个很不错的例子是早餐店。餐馆开门之后,来了一群顾客排队买早餐。甲点了个已经蒸好的馒头,付了钱拿了就走。乙点了碗需要现煮的米粉,于是拿了个号码先去座位上等。后面的客人继续点餐,而在乙的米粉煮好之后,乙便直接去拿走米线。
在这个简单的生活场景中有 2 个重要的点:
在浏览器中,也有一套类似的机制来安排各个事件的执行顺序和时机,让“点包子”和“点米粉”能非阻塞式地执行,这套机制就是事件循环。
JavaScript 本质上是一门单线程语言。对于在它被设计出来的那个年代来说,这样的设计是一个很好的选择。那个时候很少有多核计算机,而且当时预期由 JavaScript 处理的代码量也相对较少。
但是很快计算机就发展成为强大的多核系统,而 JavaScript 也肩负着更多更复杂的任务。再后来,Web API 增加了定时器(setTimeout()
和 setInterval()
)。JavaScript 的运行环境便逐渐发展到包含任务调度、多线程应用开发等强大的特性。事件循环便是 JavaScript 运行时安排和运行代码背后的机制,它相当于是主线程这条繁忙公路的交通指挥员。
事件循环的概念在操作系统中由来已久,并非 JavaScript 首创。除了操作系统,其他语言如 Python 中也存在事件循环。即便是在 JavaScript 中,也存在浏览器和 Node 两种不同的事件循环机制。可见,事件循环是一个概念,不同技术对它的实现细节不尽相同。
实际上,事件循环驱动着浏览器中发生的一切。不过本文重点介绍它如何负责调度和执行在其线程中运行的每一段代码。
在 JavaScript 中使用了一个叫调用栈(Call Stack,也叫执行栈)的机制来管理函数的调用顺序。用一个简单示例来理解它:
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
在调用 bar
时,bar
的执行上下文被创建并压入栈中。这个上下文包含了 bar
的变量环境、作用域链和 this
,在一些地方也管它叫帧(Frame),那是更专业的术语。
函数 bar
内部调用了函数 foo
,于是 foo
的上下文也被创建并压入栈中,并且位于 bar
之上(栈的特性)。当 foo
执行完毕、返回时,foo
的上下文就被弹出栈。同理,当 bar
执行完毕时,bar
的上下文也被弹出栈。至此,栈就被清空了。
以上就是一个简单的调用栈从开始到清空的过程。
一个 JavaScript 运行时包含了一个用于存储异步任务的任务队列(Task Queue),也称消息队列(Message Queue)。在 JavaScript 开始运行的时候,所有同步代码会按书写顺序在调用栈中依次执行,而异步任务的回调函数则会被放入任务队列,等待执行。
就像开头早餐店的例子中,乙点了米粉之后,乙就去“任务队列”上等着,后面的人可以继续点餐。再用一个简单示例来理解它:
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 0);
console.log("End");
执行顺序:
console.log("Start")
是同步任务,因此调用栈立即执行它。setTimeout
是异步任务,因此它的回调函数被放入任务队列中,等待执行。即使它设置的延迟是 0 毫秒,也不会立即执行。console.log("End")
立即执行。setTimeout
回调函数取出、并推入调用栈,即执行 console.log("Timeout callback")
。在上一节中,我们提到了同步任务和异步任务。而在事件循环机制中,JavaScript 提供了另一种任务分类:宏任务和微任务。
宏任务指的是计划由标准机制来执行的任何 JavaScript 代码,例如一段同步代码、一个用户事件、一个定时器的回调函数或一次 I/O 操作。在一些地方,“任务”指的就是宏任务。
在下面 3 个时机,宏任务会被添加到任务队列:
<script>
元素中运行代码。setTimeout()
或 setInterval()
创建的 timeout
或 interval
,相应的回调函数被添加到任务队列时。从定义可以看出,宏任务跟同步、异步无关。最开始执行的同步代码就是第一个宏任务。一个 <script>
元素中的代码可以是同步的,而 setTimeout
是异步的,但是它们都是宏任务。
微任务是在当前宏任务执行完成后,立即执行的任务。微任务的执行是为了确保代码的顺序性和一致性,在进入下一个宏任务之前,先把本轮循环中的所有微任务执行完毕。
在开头的例子中,乙去拿他煮好的米粉就相当于执行一个微任务的回调。微任务的回调可以插队,插在下一个宏任务前面,而不需要重头开始排队。
常见的微任务来源于:
.then()
和 .catch()
回调。MutationObserver
(DOM 变化观察者)。process.nextTick()
。设计微任务的目的就是解决异步任务完成后,其回调函数可以插队执行,因此说微任务都是异步任务是没问题的。
关于 Promise 容易混淆:Promise 创建的是异步任务,new Promise(...)
括号内是同步代码,.then()
和 .catch()
回调是微任务。
微任务的优先级高于宏任务,具体逻辑请看下面介绍。
了解完前面的概念之后,我们终于可以来看事件循环驱动的执行顺序了,这是事件循环的核心。当浏览器拿到一段 JavaScript 代码时,会按以下顺序处理:
await
和 new
。碰到宏任务,则放入任务队列,碰到微任务,则放入微任务队列,等待执行。这也是事件循环代码题的解题思路。
下面来看一些例子,解题要点是:
setTimeout
的回调代表了宏任务,new Promise()
是同步任务,Promise 的 .then()
代表了微任务。await
时,后面的代码会整体被安排进一个新的微任务,此后的函数体变为异步执行。在下面的解析中,我们常用“第 n 次迭代”来帮助理解事件循环,这是因为提到“循环”我们容易联想到“迭代”。但在实际开发中,并不会太关心第几次迭代,而是关心事件的执行顺序。
console.log('Start');
// Timeout 1
setTimeout(() => {
console.log('Timeout 1');
}, 0);
Promise.resolve().then(() => {
// Timeout 2
setTimeout(() => {
console.log('Timeout 2');
}, 0);
console.log('Promise 1');
// Timeout 3
setTimeout(() => {
console.log('Timeout 3');
}, 0);
}).then(() => {
console.log('Promise 2');
});
console.log('End');
第 1 次迭代:
console.log('Start')
立即执行;Timeout 1
放入任务队列、等待执行;.then()
放入微任务队列、等待执行;console.log('End')
立即执行;.then()
:宏任务 Timeout 2
放入任务队列,同步代码 console.log('Promise 1')
立即执行,宏任务 Timeout 3
放入任务队列。.then()
的同步代码 console.log('Promise 2')
。微任务队列清空,本次迭代结束。第 2-4 次迭代:依次执行任务队列中的宏任务。
结果为:Start -> End -> Promise 1 -> Promise 2 -> Timeout 1 -> Timeout 2 -> Timeout 3。
// Promise 1
Promise.resolve().then(() => {
console.log('Promise 1')
// Timeout 2
setTimeout(() => {
console.log('Timeout 2')
}, 0)
})
// Timeout 1
setTimeout(() => {
console.log('Timeout 1')
// Promise 2
Promise.resolve().then(() => {
console.log('Promise 2')
})
}, 0)
第 1 次迭代:
.then()
放入微任务队列等待执行。Promise 1
、把 Timeout 2 放入任务队列。微任务队列清空,本次迭代结束。第 2 次迭代 Timeout 1:
Timeout 1
。.then()
放入微任务队列。Promise 2
。微任务队列清空,本次迭代结束。第 3 次迭代 Timeout 2:打印 Timeout 2
。
结果为:Promise 1 -> Timeout 1 -> Promise 2 -> Timeout 2。
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
console.log(3);
new Promise((resolve) => {
console.log(4);
resolve();
console.log(5);
}).then(() => {
console.log(6);
});
console.log(7);
第 1 次迭代:
setTimeout
放入任务队列;new Promise()
打印 4、5;.then()
放入微任务队列;第 2 次迭代:执行宏任务 setTimeout
的回调:打印 2。
结果为:1 -> 3 -> 4 -> 5 -> 7 -> 6 -> 2。
执行以下代码,页面先变红还是先打印 End
?
console.log("Start");
document.body.style.backgroundColor = "red";
console.log("End");
答案:先打印 End
再变红。
这 3 句代码都是同步任务。但是,浏览器会在当前宏任务、微任务队列执行完毕后,再重绘页面,因此是先打印 End 再变红。
一些地方说“修改页面样式是一个宏任务”,这是错误的。可以用下面代码验证:
setTimeout(() => {
console.log("Timeout");
}, 0);
document.body.style.backgroundColor = "red";
const bgColor = getComputedStyle(document.body).backgroundColor;
console.log(bgColor);
输出:rgb(255, 0, 0) -> Timeout。
如果修改样式是宏任务,那么就会被排在 setTimeout
之后,那样打印 bgColor
就不会是红色,而是修改前的颜色。这与事实不符,可见修改页面样式不是宏任务,而是同步代码,只是因为浏览器会在本次迭代的最后来渲染页面,所以修改效果会在所有同步代码结束之后。
await
async function foo(name) {
console.log(name, "1");
await console.log(name, "2");
console.log(name, "3");
}
foo("甲");
foo("乙");
解析:在函数 foo
中,await
后的内容会被作为微任务放入微任务队列等待执行。函数 foo
等价于:
function foo(name) {
return new Promise((resolve) => {
console.log(name, "1");
resolve(console.log(name, "2"));
}).then(() => {
console.log(name, "3");
});
}
结果:甲 1 -> 甲 2 -> 乙 1 -> 乙 2 -> 甲 3 -> 乙 3。
再看一个例子:
async function foo() {
await console.log(8);
console.log(9);
}
console.log(1);
foo();
setTimeout(() => {
console.log(2);
}, 0);
new Promise((resolve) => {
console.log(4);
resolve();
console.log(5);
}).then(() => {
console.log(6);
});
console.log(7);
第 1 次迭代:
foo
:await
同步打印 8,await
后的 9 放入微任务队列;setTimeout
回调放入任务队列;new Promise
打印 4、5,回调 .then()
放入微任务队列;第 2 次迭代:setTimeout
回调打印 2。
结果:1 -> 8 -> 4 -> 5 -> 7 -> 9 -> 6 -> 2。
JavaScript 的事件循环是一种机制,用于处理异步任务,通过不断循环执行任务队列中的事件,确保非阻塞的单线程代码执行顺序。
事件循环是计算机的一种运行机制,不同技术在具体实现和调度机制上有所不同。浏览器与 Node 的事件循环差异有:
在一个宏任务队列全部执行完毕后,去清空一次微任务队列,然后到下一个等级的宏任务队列,以此往复。六个等级的宏任务全部执行完成,才是一轮循环。
另外 Node 不同版本的事件循环机制也有差别,在讨论时应先指定版本。随着 Node 的更新,其事件循环大体上有与浏览器靠拢的趋势。
在递归函数的每一次递归调用时,都会生成新的栈帧并压入调用栈。这意味着每一次递归,调用栈都会增加一个新帧。
随着递归结束,栈帧会依次弹出,函数的结果逐步传递回前面的调用栈帧,直到递归完全结束,调用栈恢复到最初状态。
当递归函数调用次数过多,超过调用栈的最大容量时,就会发生堆栈溢出(Stack Overflow)。这是因为每次函数调用都会创建一个新的执行上下文,并推入调用栈,而栈的容量是有限的。
这会导致浏览器或运行环境抛出 "Maximum call stack size exceeded"
错误。
setTimeout(fn(),1000)
中 fn()
不一定是延迟 1 秒执行?setTimeout
的第 2 个参数指的是回调函数被加入任务队列的延迟时间。如果任务队列中没有其他任务,并且调用栈当前为空,回调函数的延迟执行时间才会是第 2 个参数设定的时间。但是,如果任务队列或调用栈不为空,则需要等待队列前面的任务执行完或调用栈清空,才轮到 setTimeout
的回调函数。
浏览器有如下 3 种事件循环:
Window 事件循环驱动所有共享同源的窗口。这里的“窗口”指的是“用于运行网页内容的浏览器级容器”,包括实际的浏览器窗口、标签页或者一个 frame
。不过,同源窗口之间共享事件循环是有条件的,各个浏览器可能并不一样。
Worker 事件循环驱动 worker 的事件循环,包括所有形式的 worker,如基本的 web worker、shared worker 和 service worker。
Worklet 事件循环驱动运行 worklet 的代理。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。