JavaScript事件循环乱序执行

JavaScript Event Loop out of order execution

我正在尝试书中的一个示例来确认 JavaScript 事件循环是如何工作的,这是代码

const baz = () => console.log("baz");
const bar = () => console.log("bar");

const foo = () => {
    console.log("foo");
    setTimeout(bar, 0);
    baz();
}
foo();

setTimeout 在这里的工作方式很简单(乱序执行)输出是

foo
baz
bar 

不明白的是加一行时的顺序

const baz = () => console.log("baz");
const bar = () => console.log("bar");

const foo = () => {
    console.log("foo");
    setTimeout(bar, 0);
    baz();
}
setTimeout(baz, 0); // this somehow runs before foo() is finished
foo();

输出是

foo
baz
baz 
bar

怎么会在 foo() 完成之前执行第二个 setTimeout?

如果将异步操作与其余代码分开,您可以清楚地看到它:

const baz = () => console.log("baz");
const bar = () => console.log("bar");

const foo = () => {
    console.log("foo");
    setTimeout(bar, 0);
    baz();
}
setTimeout(baz, 0); // this somehow runs before foo() is finished
foo();

如果我们提取 setTimeout 个函数,我们将得到:

const baz = () => console.log("baz");
const bar = () => console.log("bar");

const foo = () => {
    console.log("foo");
    // setTimeout(bar, 0);
    baz();
}
// setTimeout(baz, 0); // this somehow runs before foo() is finished
foo();

记录前两行:

foo
baz

在第一个循环中,setTimeout中定义的异步函数已添加到下一个循环中,因此我们必须执行它们:

setTimeout(baz, 0);
setTimeout(bar, 0);

导致接下来两行日志:

baz 
bar

您需要记住,所有同步代码将首先 运行,然后是异步代码。 (setTimeout 将始终排队异步操作,即使超时设置为 0。)

因此,考虑到这一点,事件的顺序如下:

  • 注册对 baz()
  • 的异步调用
  • 同步输出foo
  • 注册对 bar()
  • 的异步调用
  • 同步调用baz(),输出baz

首先了解同步内容 运行,我们首先得到 foo 然后 baz

然后我们的异步事件运行,依次输出bazbar

你调用setTimeout(baz, 0)函数,它会进入调用栈,然后进入事件循环中的计时器阶段,并在那里等待。

调用 foo() 函数后,将 console.log("foo")setTimeout(bar, 0)baz() 插入调用堆栈。 由于 console.log("foo") 是一个同步操作,它会立即执行,您会在输出中看到 "foo"setTimeout(bar, 0) 转到事件循环中的计时器阶段并等待。 然后执行 baz() 函数,该函数依次启动 console.log("baz"),您会在输出中看到 "baz"。 执行同步操作后,调用栈为空,定时器等待结束,开始执行setTimeouts.

回调

setTimeout(baz, 0) -> baz -> console.log("baz") = "baz" 在 otput

之后

setTimeout(bar, 0) -> bar -> console.log("bar") = "bar" 在 otput

这里从事件循环的角度进行解释。

您可以可视化调用堆栈,它用于跟踪我们在给定时间点在程序中的位置。当您调用一个函数时,我们将其压入堆栈,而当我们 return/complete 一个函数时,我们将其从堆栈顶部弹出。最初,堆栈是空的。

当您第一次 运行 您的代码时,您的主要“脚本”将被压入堆栈,并在脚本执行完毕后从堆栈弹出:

Stack:
------
- Main()   // <-- indicates that we're in the main script

然后我们定义了几个函数 bazbarfoo,并最终到达我们的第一个函数调用 setTimeout(baz, 0),因此,我们将其推送到堆栈:

Stack:
------
- setTimeout(baz, 0)
- Main()

setTimeout() 启动 web-api,在 0 毫秒后将您的 baz 回调排入 任务队列 .在 setTimeout 将它的工作传递给 web-api 之后,它的工作就完成了,因此它也完成了它的工作并且可以从堆栈中弹出:

Stack:
------
- Main()

Task Queue: (Front <--- Back)
baz

事件循环的工作是从任务队列中取出任务,并在堆栈时将它们压入堆栈。目前,堆栈不为空,因为我们仍在主脚本中,所以 baz() 尚未执行。我们遇到的下一个函数调用是 foo(),因此我们将其压入堆栈:

Stack:
------
- foo()
- Main()

Task Queue: (Front <--- Back)
baz

Foo 然后调用控制台对象的 log() 方法,该方法也被压入堆栈:

Stack:
------
- log("foo")
- foo()
- Main()

Task Queue: (Front <--- Back)
baz

这会记录 "foo",并且 log() 在完成工作后从堆栈中弹出。然后我们继续单步执行函数 foo。我们现在遇到了对 setTimeout(bar, 0); 的函数调用。这与第一个函数调用非常相似,将 setTimeout(bar, 0) 压入堆栈。这会衍生出一个 web-api,它将 bar 添加到任务队列中。 setTimeout(bar, 0) 在将其工作移交给 web-api 后也已完成,因此它也会从堆栈中弹出(有关这些步骤,请参见第二个和第三个 ascii-diagrams),留给我们的是:

Stack:
------
- foo()
- Main()

Task Queue: (Front <--- Back)
baz, bar

最后,我们到达调用 baz() 的函数 foo 的最后一行。这会将 baz() 推送到调用堆栈,然后将 log("baz") 推送到调用堆栈的顶部, 记录“baz”。到目前为止,我们已经记录了“foo”,然后是“baz”。在记录 baz 后,log() 被弹出堆栈,baz() 也被弹出。

一旦 foo() 中的最后一行完成,我们隐含地 return,将 foo() 弹出堆栈,留下 Main()。一旦我们从 foo returned,我们的 control/execution 在 foo() 被调用后被 returned 回到主脚本。由于我们的脚本中没有更多的函数可以调用,我们从堆栈中弹出 Main(),留下:

Stack:
------
<EMPTY>

Task Queue: (Front <--- Back)
baz, bar 

现在堆栈为空,事件循环可以进入并处理任务队列中的 bazbar。首先是从队列中取出 baz 并将其压入堆栈,然后调用 log("baz"),将 log 压入堆栈,然后 记录“baz”。日志完成后,logbaz 从堆栈中弹出,再次留空:

Stack:
------
<EMPTY>

Task Queue: (Front <--- Back)
bar 

现在堆栈再次为空,事件循环从队列中取出第一个任务(即:bar)并压入堆栈。 bar 然后调用 log("bar"),它将 log("bar") 添加到堆栈,以及 logs "bar" 到控制台。记录完成后,log()bar() 都从堆栈中弹出。

因此,您的日志输出按以下顺序打印(请参阅上面的粗体日志):

"foo"
"baz"
"baz"
"bar"

可以找到一些关于事件循环和调用堆栈的好资源here, here and here