详细描述 `JS Call Stack` 和 `Event Queue` 的内部运作,以及 `Microtask Queue` 和 `Macrotask Queue` 的调度优先级。

各位靓仔靓女,大家好!我是你们的老朋友,今天咱们聊聊 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");

这段代码的执行过程就像这样:

  1. 一开始,调用栈是空的。
  2. 执行 sayHello("Alice")sayHello 的“剧本”被放到桌子上(压栈)。
  3. sayHello 里面,调用了 greet("Alice")greet 的“剧本”又被放到桌子上(压栈)。
  4. greet("Alice") 执行完毕,返回 "Hello, Alice!",greet 的“剧本”从桌子上拿走(出栈)。
  5. sayHello 接着执行 console.log("Hello, Alice!")console.log的“剧本”被放到桌子上(压栈),然后执行完毕,console.log的“剧本”被从桌子上拿走(出栈)。
  6. sayHello 执行完毕,sayHello 的“剧本”也从桌子上拿走(出栈)。
  7. 调用栈恢复为空。

是不是像叠盘子?先放上去的后拿走,后放上去的先拿走,这就是典型的“后进先出”(LIFO)原则。

如果调用栈里堆满了函数,还没来得及执行完,就会发生著名的“栈溢出”(Stack Overflow)错误。就像桌子上的剧本叠得太高,摇摇欲坠。

来个 Stack Overflow 的例子:

function infiniteRecursion() {
  infiniteRecursion();
}

infiniteRecursion(); // 报错:Maximum call stack size exceeded

这个函数会无限递归调用自己,导致调用栈不断增长,最终超出最大限制,浏览器就会毫不留情地报错。

第二站:排队的观众——事件队列(Event Queue)

JavaScript 是单线程的,这意味着它一次只能做一件事情。但是,现实生活中有很多事情不能立刻完成,比如:

  • 等待用户点击按钮
  • 等待网络请求返回数据
  • 等待定时器到期

这些“等待”的过程,JavaScript 引擎不能傻傻地卡在那里,否则整个页面就卡死了。所以,它采用了“异步”(Asynchronous)的方式来处理这些事情。

异步操作不会立刻返回结果,而是先把任务提交给 Web API(浏览器提供的 API,比如 setTimeoutaddEventListener 等),然后继续执行后面的代码。当 Web API 完成任务后,会将结果放入事件队列(Event Queue)。

你可以把事件队列想象成电影院门口排队等待入场的观众,每个人都拿着一张票(事件),按照先来后到的顺序排队。

第三站:幕后 Boss——事件循环(Event Loop)

现在,问题来了:JavaScript 引擎怎么知道事件队列里有没有新的任务呢?这就轮到我们的幕后 Boss——事件循环(Event Loop)出场了。

事件循环就像一个保安,它会不断地检查调用栈是否为空。如果调用栈为空,就从事件队列里取出第一个任务,放到调用栈里执行。如果调用栈不为空,就继续等待。

这个过程就像一个循环:

  1. 检查调用栈是否为空?
  2. 如果为空,从事件队列取出第一个任务,放到调用栈执行。
  3. 如果不为空,回到第 1 步。

用伪代码表示:

while (true) {
  if (callStack.isEmpty()) {
    let task = eventQueue.dequeue(); // 取出队列中的第一个任务
    if (task) {
      callStack.push(task);        // 将任务放入调用栈执行
    }
  }
}

第四站:任务的优先级——微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)

现在,事情变得稍微复杂了一点。事件队列实际上分为两种:微任务队列(Microtask Queue)和宏任务队列(Macrotask Queue)。

  • 宏任务(Macrotask): 比如 setTimeoutsetIntervalsetImmediate(Node.js)、I/O 操作(比如文件读取)、UI 渲染等。
  • 微任务(Microtask): 比如 Promise.thenMutationObserverprocess.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");

这段代码的执行顺序是:

  1. script start
  2. script end
  3. promise1
  4. promise2
  5. setTimeout

为什么不是 script start -> setTimeout -> promise1 -> promise2 -> script end 呢?

因为:

  1. console.log("script start")console.log("script end") 是同步代码,会依次执行。
  2. setTimeout 是一个宏任务,会被放入宏任务队列。
  3. Promise.resolve().then() 是一个微任务,会被放入微任务队列。

当执行完 console.log("script end") 后,调用栈为空,事件循环开始工作。

  1. 首先,它从宏任务队列中取出一个任务(setTimeout),放到调用栈执行。
  2. 在执行 setTimeout 之前,它会先检查微任务队列,发现里面有两个微任务(promise1promise2),于是依次执行这两个微任务。
  3. 执行完所有微任务后,才会执行 setTimeout 里面的 console.log("setTimeout")

用表格总结一下:

阶段 任务 队列类型
1. 同步代码 console.log("script start") (无,直接执行)
1. 同步代码 console.log("script end") (无,直接执行)
2. 异步任务 setTimeout(..., 0) 宏任务
2. 异步任务 Promise.resolve().then(...) 微任务

事件循环的流程:

  1. 执行全局 Script 代码,直到调用栈为空。
  2. 执行微任务队列中的所有任务。
  3. 取出宏任务队列中的第一个任务,放到调用栈执行。
  4. 重复步骤 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');

执行顺序:

  1. start
  2. end
  3. promise 2
  4. setTimeout 1
  5. promise 4
  6. setTimeout 2

解释:

  1. startend 先执行,没啥好说的。
  2. 第一个 setTimeout 被放入宏任务队列。
  3. 第一个 Promise.resolve().then() 被放入微任务队列。
  4. 执行完同步代码后,事件循环开始工作。
  5. 先执行微任务队列中的 promise 2,在 promise 2 里面又有一个 setTimeout,这个 setTimeout 被放入宏任务队列。
  6. 微任务队列为空,开始执行宏任务队列中的第一个任务(setTimeout 1),打印 setTimeout 1,并且将 promise 4 放入微任务队列。
  7. 执行微任务队列中的 promise 4
  8. 宏任务队列中还剩下 setTimeout 2,执行它,打印 setTimeout 2

总结:

  • JavaScript 是单线程的,通过事件循环机制来实现异步编程。
  • 调用栈用于执行同步代码,事件队列用于存放异步任务的回调函数。
  • 事件循环不断地检查调用栈和事件队列,将事件队列中的任务放入调用栈执行。
  • 事件队列分为微任务队列和宏任务队列,微任务的优先级高于宏任务。
  • 每次执行完一个宏任务,都要把所有微任务执行完,才能开始下一个宏任务。

一些常见的坑:

  • 阻塞主线程: 如果在同步代码中执行耗时操作,会导致主线程阻塞,页面卡死。应该尽量将耗时操作放在异步任务中执行。
  • 过度使用 setTimeout(..., 0) 虽然 setTimeout(..., 0) 可以将任务放入宏任务队列,但过度使用会导致页面响应变慢,因为宏任务的优先级较低。
  • 忘记处理 Promise 的 rejected 状态: Promise 可能会 rejected,如果没有捕获 rejected 状态,会导致程序出错。应该使用 .catch() 或者 try...catch 来处理 rejected 状态。

一些使用技巧:

  • 使用 Promise 代替回调函数: Promise 可以更清晰地表达异步操作的依赖关系,避免回调地狱。
  • 使用 async/await async/awaitPromise 的语法糖,可以使异步代码看起来更像同步代码,提高代码的可读性。
  • 合理使用 setTimeoutsetInterval setTimeout 用于执行一次性任务,setInterval 用于执行周期性任务。注意 setInterval 可能会导致任务堆积,应该谨慎使用。
  • 使用 requestAnimationFrame requestAnimationFrame 用于执行动画相关的任务,它的执行时机与浏览器的渲染周期同步,可以获得更好的性能。

好了,今天的讲座就到这里。希望大家对 JavaScript 的调用栈、事件队列、微任务队列和宏任务队列有了更深入的理解。记住,理解这些概念,才能更好地编写高效、稳定的 JavaScript 代码。

下次见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注