各位靓仔靓女,大家好!我是你们的老朋友,今天咱们聊聊 JavaScript 的大心脏——调用栈(Call Stack)、事件队列(Event Queue)以及这两位好兄弟背后的两个小弟:微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)。
准备好了吗?系好安全带,咱们要开车了!
第一站:JS 的“剧本”——调用栈(Call Stack)
你可以把 JavaScript 引擎想象成一个尽职尽责的演员,它拿到一段代码,就像拿到了一份剧本,需要一行一行地执行。而调用栈,就是这个演员的“排练厅”,或者更形象点说,是叠放剧本的“桌子”。
每当演员要执行一个函数,就把这个函数对应的“剧本”放到桌子的最上面。执行完这个函数,就从桌子上拿走“剧本”。
举个例子:
function greet(name) {
return "Hello, " + name + "!";
}
function sayHello(name) {
let message = greet(name);
console.log(message);
}
sayHello("Alice");
这段代码的执行过程就像这样:
- 一开始,调用栈是空的。
- 执行
sayHello("Alice")
,sayHello
的“剧本”被放到桌子上(压栈)。 - 在
sayHello
里面,调用了greet("Alice")
,greet
的“剧本”又被放到桌子上(压栈)。 greet("Alice")
执行完毕,返回 "Hello, Alice!",greet
的“剧本”从桌子上拿走(出栈)。sayHello
接着执行console.log("Hello, Alice!")
,console.log
的“剧本”被放到桌子上(压栈),然后执行完毕,console.log
的“剧本”被从桌子上拿走(出栈)。sayHello
执行完毕,sayHello
的“剧本”也从桌子上拿走(出栈)。- 调用栈恢复为空。
是不是像叠盘子?先放上去的后拿走,后放上去的先拿走,这就是典型的“后进先出”(LIFO)原则。
如果调用栈里堆满了函数,还没来得及执行完,就会发生著名的“栈溢出”(Stack Overflow)错误。就像桌子上的剧本叠得太高,摇摇欲坠。
来个 Stack Overflow 的例子:
function infiniteRecursion() {
infiniteRecursion();
}
infiniteRecursion(); // 报错:Maximum call stack size exceeded
这个函数会无限递归调用自己,导致调用栈不断增长,最终超出最大限制,浏览器就会毫不留情地报错。
第二站:排队的观众——事件队列(Event Queue)
JavaScript 是单线程的,这意味着它一次只能做一件事情。但是,现实生活中有很多事情不能立刻完成,比如:
- 等待用户点击按钮
- 等待网络请求返回数据
- 等待定时器到期
这些“等待”的过程,JavaScript 引擎不能傻傻地卡在那里,否则整个页面就卡死了。所以,它采用了“异步”(Asynchronous)的方式来处理这些事情。
异步操作不会立刻返回结果,而是先把任务提交给 Web API(浏览器提供的 API,比如 setTimeout
、addEventListener
等),然后继续执行后面的代码。当 Web API 完成任务后,会将结果放入事件队列(Event Queue)。
你可以把事件队列想象成电影院门口排队等待入场的观众,每个人都拿着一张票(事件),按照先来后到的顺序排队。
第三站:幕后 Boss——事件循环(Event Loop)
现在,问题来了:JavaScript 引擎怎么知道事件队列里有没有新的任务呢?这就轮到我们的幕后 Boss——事件循环(Event Loop)出场了。
事件循环就像一个保安,它会不断地检查调用栈是否为空。如果调用栈为空,就从事件队列里取出第一个任务,放到调用栈里执行。如果调用栈不为空,就继续等待。
这个过程就像一个循环:
- 检查调用栈是否为空?
- 如果为空,从事件队列取出第一个任务,放到调用栈执行。
- 如果不为空,回到第 1 步。
用伪代码表示:
while (true) {
if (callStack.isEmpty()) {
let task = eventQueue.dequeue(); // 取出队列中的第一个任务
if (task) {
callStack.push(task); // 将任务放入调用栈执行
}
}
}
第四站:任务的优先级——微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)
现在,事情变得稍微复杂了一点。事件队列实际上分为两种:微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)。
- 宏任务(Macrotask): 比如
setTimeout
、setInterval
、setImmediate
(Node.js)、I/O 操作(比如文件读取)、UI 渲染等。 - 微任务(Microtask): 比如
Promise.then
、MutationObserver
、process.nextTick
(Node.js)等。
它们的区别在于:
- 宏任务: 每次事件循环只会从宏任务队列中取出一个任务执行。
- 微任务: 每次执行完一个宏任务后,会检查微任务队列,并执行队列中所有微任务。
你可以把宏任务队列想象成“正餐”,微任务队列想象成“甜点”。每次吃完一道正餐,都要把所有的甜点吃完,才能开始下一道正餐。
调度优先级:微任务 > 宏任务
这意味着,微任务的优先级高于宏任务。当事件循环从事件队列中取出一个任务执行时,会先执行宏任务,然后检查微任务队列,执行所有微任务,直到微任务队列为空,才会开始执行下一个宏任务。
来看个例子:
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
为什么不是 script start
-> setTimeout
-> promise1
-> promise2
-> script end
呢?
因为:
console.log("script start")
和console.log("script end")
是同步代码,会依次执行。setTimeout
是一个宏任务,会被放入宏任务队列。Promise.resolve().then()
是一个微任务,会被放入微任务队列。
当执行完 console.log("script end")
后,调用栈为空,事件循环开始工作。
- 首先,它从宏任务队列中取出一个任务(
setTimeout
),放到调用栈执行。 - 在执行
setTimeout
之前,它会先检查微任务队列,发现里面有两个微任务(promise1
和promise2
),于是依次执行这两个微任务。 - 执行完所有微任务后,才会执行
setTimeout
里面的console.log("setTimeout")
。
用表格总结一下:
阶段 | 任务 | 队列类型 |
---|---|---|
1. 同步代码 | console.log("script start") |
(无,直接执行) |
1. 同步代码 | console.log("script end") |
(无,直接执行) |
2. 异步任务 | setTimeout(..., 0) |
宏任务 |
2. 异步任务 | Promise.resolve().then(...) |
微任务 |
事件循环的流程:
- 执行全局 Script 代码,直到调用栈为空。
- 执行微任务队列中的所有任务。
- 取出宏任务队列中的第一个任务,放到调用栈执行。
- 重复步骤 2 和 3,直到宏任务队列和微任务队列都为空。
再来一个更复杂的例子:
console.log('start');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => console.log('promise 4'));
}, 0);
Promise.resolve().then(() => {
console.log('promise 2');
setTimeout(() => console.log('setTimeout 2'), 0);
});
console.log('end');
执行顺序:
start
end
promise 2
setTimeout 1
promise 4
setTimeout 2
解释:
start
和end
先执行,没啥好说的。- 第一个
setTimeout
被放入宏任务队列。 - 第一个
Promise.resolve().then()
被放入微任务队列。 - 执行完同步代码后,事件循环开始工作。
- 先执行微任务队列中的
promise 2
,在promise 2
里面又有一个setTimeout
,这个setTimeout
被放入宏任务队列。 - 微任务队列为空,开始执行宏任务队列中的第一个任务(
setTimeout 1
),打印setTimeout 1
,并且将promise 4
放入微任务队列。 - 执行微任务队列中的
promise 4
。 - 宏任务队列中还剩下
setTimeout 2
,执行它,打印setTimeout 2
。
总结:
- JavaScript 是单线程的,通过事件循环机制来实现异步编程。
- 调用栈用于执行同步代码,事件队列用于存放异步任务的回调函数。
- 事件循环不断地检查调用栈和事件队列,将事件队列中的任务放入调用栈执行。
- 事件队列分为微任务队列和宏任务队列,微任务的优先级高于宏任务。
- 每次执行完一个宏任务,都要把所有微任务执行完,才能开始下一个宏任务。
一些常见的坑:
- 阻塞主线程: 如果在同步代码中执行耗时操作,会导致主线程阻塞,页面卡死。应该尽量将耗时操作放在异步任务中执行。
- 过度使用
setTimeout(..., 0)
: 虽然setTimeout(..., 0)
可以将任务放入宏任务队列,但过度使用会导致页面响应变慢,因为宏任务的优先级较低。 - 忘记处理 Promise 的 rejected 状态: Promise 可能会 rejected,如果没有捕获 rejected 状态,会导致程序出错。应该使用
.catch()
或者try...catch
来处理 rejected 状态。
一些使用技巧:
- 使用
Promise
代替回调函数:Promise
可以更清晰地表达异步操作的依赖关系,避免回调地狱。 - 使用
async/await
:async/await
是Promise
的语法糖,可以使异步代码看起来更像同步代码,提高代码的可读性。 - 合理使用
setTimeout
和setInterval
:setTimeout
用于执行一次性任务,setInterval
用于执行周期性任务。注意setInterval
可能会导致任务堆积,应该谨慎使用。 - 使用
requestAnimationFrame
:requestAnimationFrame
用于执行动画相关的任务,它的执行时机与浏览器的渲染周期同步,可以获得更好的性能。
好了,今天的讲座就到这里。希望大家对 JavaScript 的调用栈、事件队列、微任务队列和宏任务队列有了更深入的理解。记住,理解这些概念,才能更好地编写高效、稳定的 JavaScript 代码。
下次见!