事件循环

JavaScript 具有基于 **事件循环** 的运行时模型,它负责执行代码、收集和处理事件以及执行排队的子任务。该模型与 C 和 Java 等其他语言中的模型截然不同。

运行时概念

以下部分解释了一个理论模型。现代 JavaScript 引擎实现了并对描述的语义进行了大量优化。

视觉表示

A diagram showing how stacks are comprised of frames, heaps are comprised of objects, and queues are comprised of messages.

堆栈

函数调用形成一个 **帧** 堆栈。

js
function foo(b) {
  const a = 10;
  return a + b + 11;
}

function bar(x) {
  const y = 3;
  return foo(x * y);
}

const baz = bar(7); // assigns 42 to baz

操作顺序

  1. 调用 bar 时,将创建一个包含对 bar 的参数和局部变量的引用的第一个帧。
  2. bar 调用 foo 时,将创建一个第二个帧并将其推送到第一个帧的顶部,其中包含对 foo 的参数和局部变量的引用。
  3. foo 返回时,将从堆栈中弹出顶部的帧元素(只剩下 bar 的调用帧)。
  4. bar 返回时,堆栈为空。

请注意,参数和局部变量可能仍然存在,因为它们存储在堆栈之外 - 因此它们可以被任何 嵌套函数 在其外部函数返回后很长时间内访问。

对象在堆中分配,堆只是一个表示大块(大部分非结构化)内存区域的名称。

队列

JavaScript 运行时使用消息队列,消息队列是待处理消息的列表。每个消息都关联着一个用于处理该消息的函数。

在 **事件循环** 过程中的某个时刻,运行时开始处理队列中的消息,从最旧的消息开始。为此,将消息从队列中删除,并使用该消息作为输入参数调用其对应的函数。与往常一样,调用函数会为该函数使用创建一个新的堆栈帧。

函数处理持续进行,直到堆栈再次为空。然后,事件循环将处理队列中的下一条消息(如果有)。

事件循环

**事件循环** 由于其实现方式通常类似于以下内容而得名

js
while (queue.waitForMessage()) {
  queue.processNextMessage();
}

queue.waitForMessage() 异步等待消息到达(如果消息尚未到达且正在等待处理)。

"运行至完成"

每个消息在任何其他消息处理之前都完全处理。

这在推理程序时提供了一些很好的属性,包括以下事实:只要函数运行,它就不会被抢占,并且将在任何其他代码运行之前完全运行(并且可以修改函数操作的数据)。这与 C 不同,例如,如果函数在某个线程中运行,它可能在运行时系统运行另一个线程中的某些代码时被停止。

此模型的一个缺点是,如果消息处理时间过长,则 Web 应用程序将无法处理用户交互,如点击或滚动。浏览器通过 "脚本运行时间过长" 对话框来缓解这个问题。一个要遵循的良好做法是使消息处理简短,如果可能的话,将一条消息分成多条消息。

添加消息

在 Web 浏览器中,消息通常在发生事件并且事件监听器附加到该事件时添加。如果没有监听器,则事件将丢失。因此,点击具有点击事件处理程序的元素将添加一条消息 - 同样适用于任何其他事件。但是,有些事件会同步发生而无需消息 - 例如,通过 click 方法模拟点击。

函数 setTimeout 的前两个参数是待添加到队列的消息和时间值(可选;默认为 0)。时间值 表示将消息推送到队列的(最小)延迟。如果没有其他消息在队列中,并且堆栈为空,则消息将在延迟后立即处理。但是,如果存在消息,则 setTimeout 消息将不得不等待其他消息被处理。因此,第二个参数表示最小时间 - 而不是保证时间。

以下示例演示了此概念(setTimeout 不会在计时器过期后立即运行)

js
const seconds = new Date().getTime() / 1000;

setTimeout(() => {
  // prints out "2", meaning that the callback is not called immediately after 500 milliseconds.
  console.log(`Ran after ${new Date().getTime() / 1000 - seconds} seconds`);
}, 500);

while (true) {
  if (new Date().getTime() / 1000 - seconds >= 2) {
    console.log("Good, looped for 2 seconds");
    break;
  }
}

零延迟

零延迟并不意味着回调将在零毫秒后触发。使用 0(零)毫秒的延迟调用 setTimeout 不会在给定间隔后执行回调函数。

执行取决于队列中等待的任务数量。在下面的示例中,消息 "this is just a message" 将在回调中的消息被处理之前写入控制台,因为延迟是运行时处理请求所需的最小时间(而不是保证时间)。

setTimeout 需要等待所有排队消息的代码完成,即使你为 setTimeout 指定了特定的时间限制。

js
(() => {
  console.log("this is the start");

  setTimeout(() => {
    console.log("Callback 1: this is a msg from call back");
  }); // has a default time value of 0

  console.log("this is just a message");

  setTimeout(() => {
    console.log("Callback 2: this is a msg from call back");
  }, 0);

  console.log("this is the end");
})();

// "this is the start"
// "this is just a message"
// "this is the end"
// "Callback 1: this is a msg from call back"
// "Callback 2: this is a msg from call back"

多个运行时相互通信

Web 工作线程或跨域 iframe 具有自己的堆栈、堆和消息队列。两个不同的运行时只能通过使用 postMessage 方法发送消息来通信。该方法会将消息添加到另一个运行时,如果后者监听 message 事件。

永不阻塞

事件循环模型的一个非常有趣的属性是,与许多其他语言不同,JavaScript 从不阻塞。I/O 处理通常通过事件和回调来执行,因此,当应用程序等待 IndexedDB 查询返回或 fetch() 请求返回时,它仍然可以处理其他事情,如用户输入。

存在像 alert 或同步 XHR 这样的旧异常,但建议避免使用它们。注意:异常中存在异常(但通常是实现错误,而不是其他原因)。

另请参阅