理解JavaScript中的事件队列和调用栈

8

当我解决这个问题时,我对于理解“事件队列”和“调用栈”的概念产生了兴趣:

var list = readHugeList();

var nextListItem = function() {
    var item = list.pop();

    if (item) {
        // process the list item...
        nextListItem();
    }
};

如果数组列表过大,下面的递归代码将导致堆栈溢出。如何解决此问题并仍保留递归模式?

提出的解决方案是:

var list = readHugeList();

var nextListItem = function() {
    var item = list.pop();

    if (item) {
        // process the list item...
        setTimeout( nextListItem, 0);
    }
};

解决方案:

由于事件循环处理递归,而不是调用栈,因此消除了堆栈溢出。当nextListItem运行时,如果item不为空,则会将超时函数(nextListItem)推送到事件队列中,并退出函数,从而保留调用栈的清晰状态。当事件队列运行其定时事件时,处理下一个项目并设置计时器以再次调用nextListItem。因此,该方法在没有直接递归调用的情况下从头到尾处理,因此无论迭代次数如何,调用堆栈始终保持清晰。

现在我的问题:

Q1)“事件队列”和“调用栈”之间有什么区别?

Q2)我不理解答案。 有人可以详细解释一下吗?

Q3)当我在JavaScript中执行函数或调用变量或对象时,流程如何进行?调用堆栈中发生了什么?(假设我执行setTimeout..它会进入callstack还是event queue?)

这些概念非常不清楚。 我谷歌了一下,但大多数结果都不是我想了解的。

请帮帮我!


在调用setTimeout(nextListItem, 0)时,setTimeout会进入调用栈,它会将一个带有nextListItem的定时器添加到事件队列中,然后返回,也就是从调用栈中弹出setTimeout - Bergi
1
调用堆栈是当前正在执行的函数及其状态的堆栈。你可以把事件队列看作是一系列函数,它们将在调用堆栈为空(并且经过足够的时间)时运行。因此,每当调用放入事件队列的函数被调用时,调用堆栈就为空。如果你递归地调用一个函数而没有将这些调用放入事件队列中,调用堆栈将继续增长。 - Mike Cluck
@MikeC 非常有趣的回答,让我对概念有了更清晰的认识。但是当你说“您可以将事件队列视为一组函数队列,这些函数将在调用堆栈为空时运行”时,我仍然不明白为什么有人会将某些东西放入队列中,而它已经在堆栈中了?所以你是在告诉我,如果我执行一个函数,那么它就进入堆栈,然后弹出堆栈,然后放入队列中吗?(为了UI渲染?)...如果我理解有误,请纠正我。 - TechnoCorner
1
@TechnoCorner “为什么有人会把已经在栈中的东西放入队列?” 这个想法是将某些东西放入队列中,以便它不会进入栈中。通常这样做是因为您希望稍后发生某些事情(想象一下每秒更新一次的时钟),或者避免填满调用堆栈。请记住:从事件队列运行的任何函数都将以空调用堆栈开始。 - Mike Cluck
如果值小于约10,则setTimeout(nextListItem)就足够了,不需要指定持续时间。 - vsync
2个回答

31

答案1&3

事件队列和调用栈之间存在非常大的区别。实际上,它们几乎没有任何共同点。

调用栈(简单概述):

当您执行一个函数时,它使用的所有内容都被视为进入调用栈中,这就是您所提到的调用栈。简而言之,它是用于功能执行的临时内存。或者换句话说

function foo() {
  console.log("-> start [foo]");
  console.log("<- end   [foo]");
}

foo();

当被调用时,它将在堆栈上得到一个小沙盒来玩耍。当函数结束时,临时内存将被清除并可以供其他事物使用。因此,使用的资源(除非被分配给系统的某个地方)只会持续函数持续的时间。

现在,如果你有嵌套函数

function foo() {
  console.log("-> start [foo]");
  console.log("<- end   [foo]");
}

function bar() {
  console.log("-> start [bar]");
  foo()
  console.log("<- end   [bar]");
}

bar();

当你调用该函数时,发生了以下情况:

  1. 执行bar - 在堆栈上为其分配内存。
  2. bar打印“start”。
  3. 执行foo - 在堆栈上为其分配内存。注意! bar仍在运行,它的内存也在那里。
  4. foo打印“start”。
  5. foo打印“end”。
  6. foo完成执行,并从堆栈中清除其内存。
  7. bar打印“end”。
  8. bar完成执行,并从堆栈中清除其内存。

因此,执行顺序是bar -> foo,但解析顺序是后进先出(LIFO)foo完成 -> bar完成。

这就是它成为“堆栈”的原因。

这里需要注意的重要事项是,只有当函数执行完成时,它所使用的资源才会被释放。并且它执行结束的时间是在其内部所有函数和这些函数内部的所有函数都执行完毕之后。因此,你可能会有一个非常深的调用堆栈,例如a -> b -> c -> d -> e,如果a中保存有大量资源,则需要等待be都执行完毕后这些资源才能被释放。

在递归中,函数会调用自身,这仍然会在堆栈上创建新的条目。因此,如果a不断地调用自身,那么就会得到一个调用堆栈,例如a -> a -> a -> a等等。

以下是一个非常简要的示例:

// a very naive recursive count down function
function recursiveCountDown(count) {
  //show that we started
  console.log("-> start recursiveCountDown [" + count + "]");
  
  if (count !== 0) {//exit condition
    //take one off the count and recursively call again
    recursiveCountDown(count -1);
    console.log("<- end recursiveCountDown [" + count + "]"); // show where we stopped. This will terminate this stack but only after the line above finished executing;
  } else {
    console.log("<<<- it's the final recursiveCountDown! [" + count + "]"); // show where we stopped
  }
}

console.log("--shallow call stack--")
recursiveCountDown(2);

console.log("--deep call stack--")
recursiveCountDown(10);

这是一个非常简单但存在缺陷的递归函数,但它仅用于演示在那种情况下会发生什么。

事件队列

JavaScript 在事件队列(或“事件循环”)中运行,简单来说,它等待“活动”(事件),处理它们然后再次等待。

如果有多个事件,则按顺序进行处理 - 先进先出(FIFO),因此形成了队列。 因此,如果我们重新编写上面的函数:

function foo() {
  console.log("-> start [foo]");
  console.log("<- end   [foo]");
}

function bar() {
  console.log("-> start [bar]");
  console.log("<- end   [bar]");
}


function baz() {
  console.log("-> start [baz]");
  
  setTimeout(foo, 0);
  setTimeout(bar, 0);
  
  console.log("<- end   [baz]");
}

baz();

这是其运作方式。
1. 执行 `baz`,在堆栈上分配内存。 2. 通过将其调度为“下一个”运行,延迟执行 `foo`。 3. 通过将其调度为“下一个”运行,延迟执行 `bar`。 4. `baz` 完成。堆栈被清除。 5. 事件循环选择队列中的下一项——这是 `foo`。 6. 执行 `foo`,在堆栈上分配内存。 7. `foo` 完成。堆栈被清除。 8. 事件循环选择队列中的下一项——这是 `bar`。 9. 执行 `bar`,在堆栈上分配内存。 10. `bar` 完成。堆栈被清除。
正如您所看到的,堆栈仍然起着作用。您调用的任何函数都将始终生成堆栈条目。事件队列是单独的机制。
采用这种方式,您将获得更少的内存开销,因为您不必等待其他任何函数释放已分配的资源。另一方面,您不能依赖任何函数完成。
希望本节也回答了您的第三个问题。

// still naive but a bit improved recursive count down function
function betterRecursiveCountDown(count) {
  console.log("-> start recursiveCountDown [" + count + "]");
  
  if (count !== 0) {
    //setTimeout takes more than two parameters - anything after the second one will be passed to the function when it gets executed
    setTimeout(betterRecursiveCountDown, 0, count - 1);
    console.log("<- end recursiveCountDown [" + count + "]");
  } else {
    console.log("<<<- it's the final recursiveCountDown! [" + count + "]"); // show where we stopped
  }
}

betterRecursiveCountDown(10);


5
太棒了!这是我见过的对这个问题最好的解释。非常感谢您的时间!这也是为什么 Stack Overflow 如此惊人和让人上瘾的最好原因之一! - TechnoCorner
一个问题。为什么我们会假设出现溢出?比如在数组中,如果超过了数组的大小。在堆栈的情况下,我怎么知道堆栈的大小?只是好奇! - TechnoCorner
调用堆栈大小是有限的,但没有通用限制。请参见此处(https://dev59.com/0Wsz5IYBdhLWcg3wiYY0),尽管我想这些数字已经过时了,它们传达的思想仍然是正确的——它取决于浏览器。一般来说,您只需要担心递归函数的堆栈深度——“普通”的函数极不可能遇到此限制。但是,您应该注意分配的资源——持有昂贵数据的函数可能会减慢应用程序速度(如果它们等待内部调用结束)。 - VLAZ
这是一个关于事件队列的很好的解释。我已经知道了事件队列,但是像你在这里所做的那样将它与调用栈并排放置,肯定提高了我的理解水平。干得好。对于这个清晰的解释点赞。 - NaijaProgrammer

1
主要使用 调用栈 的原因是为了知道在当前函数结束后要去哪里。 但大多数语言都有 调用栈 的大小限制,所以如果您重复调用一个函数直到函数未完成,则 调用栈 的大小会溢出。
大多数 setTimeout 的实现都有一个 队列 来保存任务,并在空闲时间执行它们。
第一个 nextListItem 在自身未完成之前就会调用自己。 因此,调用栈 将在项目列表结束时变得很长。
第二个 nextListItem 在完成自身后才调用自身,而且 调用栈 也已清除。 因此,当从空闲时间的 setTimeout 中调用 nextListItem 时,调用栈 将从空开始。
  1. 调用栈 用于保存函数调用历史记录,而 事件队列 用于保存 setTimeout 的任务。

  2. 参见上面的解释。

  3. JavaScript 只是连续执行您的语句,但会保存该函数被调用的位置以便在函数完成后返回。 调用栈 用于保存调用函数的历史记录。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接