了解 javascript 中的事件队列和调用堆栈
Posted
技术标签:
【中文标题】了解 javascript 中的事件队列和调用堆栈【英文标题】:Understanding Event Queue and Call stack in javascript 【发布时间】:2017-01-20 10:01:51 【问题描述】:我对理解“事件队列”和“调用堆栈”概念的好奇心开始于我解决这个问题时:
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 不是 null,超时函数(nextListItem)被推送到事件队列 并且函数退出,从而使调用堆栈清除。当。。。的时候 事件队列运行它的超时事件,下一个项目被处理并且一个 计时器设置为再次调用 nextListItem。因此,该方法是 从头到尾处理,无需直接递归调用,因此 无论迭代次数如何,调用堆栈都保持清晰。
现在我的问题是:
Q1) “事件队列”和“调用堆栈”有什么区别
Q2)我不明白答案。谁能详细解释一下?
Q3) 当我在 javascript 中执行函数或调用变量或对象时。流程是如何进行的? 调用堆栈中有什么?(假设我做了 setTimeout.. 它是进入调用堆栈还是事件队列?)
这些概念非常不清楚。我用谷歌搜索,但大多数结果都不是我所期望的。
请帮忙!
【问题讨论】:
在调用setTimeout( nextListItem, 0);
中,setTimeout
进入调用栈,将带有nextListItem
的定时器添加到事件队列中,然后返回,即从调用中弹出setTimeout
堆栈。
调用栈是当前正在执行的函数及其状态的栈。您可以将事件队列想象成一个函数队列,一旦调用堆栈为空(并且经过了足够的时间),它们将运行。因此,每当调用放置在事件队列中的函数时,调用堆栈都是空的。如果您递归调用函数而不将这些调用放在事件队列中,则调用堆栈将继续增长。
@MikeC 这是一个有趣的答案。有点澄清我的概念。但是当您说“您可以将事件队列视为一个函数队列,一旦调用堆栈为空就会运行”时,我仍然不明白为什么有人将已经在堆栈中的东西放入队列中?那么你是在告诉我,如果我执行一个函数,那么它会进入堆栈,然后弹出堆栈然后放入队列中? (为了 UI 渲染?).. 如果我错了,请纠正我
@TechnoCorner “为什么有人将已经在堆栈中的东西放入队列中?”这个想法是将某些东西放入队列中,以便它不会进入堆栈。通常这样做是因为您希望稍后发生某些事情(想象一个每秒更新一次的时钟),或者您可以避免填满调用堆栈。请记住:从事件队列运行的任何函数都将从一个空的调用堆栈开始。
setTimeout(nextListItem)
就足够了,如果值低于~10,则无需指定持续时间。
【参考方案1】:
回答 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();
调用函数时会发生以下情况:
bar
被执行 - 在堆栈上为其分配内存。
bar
打印“开始”
foo
被执行 - 在堆栈上为其分配内存。 注意! bar
仍在运行,它的内存还在。
foo
打印“开始”
foo
打印“结束”
foo
完成执行并从堆栈中清除其内存。
bar
打印“结束”
bar
完成执行,其内存从堆栈中清除。
所以,执行顺序是 bar
-> foo
但分辨率是后进先出顺序 (LIFO) foo
完成 -> bar
完成。
这就是使它成为“堆栈”的原因。
这里要注意的重要一点是,函数使用的资源只有在执行完毕后才会被释放。当它内部的所有函数和它们内部的函数完成执行时,它就完成了执行。所以你可以有一个非常深的调用堆栈,比如a
-> b
-> c
-> d
-> e
并且如果a
中有任何大型资源,你将需要b
到e
在它们发布之前完成。
在递归中,一个函数调用自己,它仍然在堆栈上创建条目。所以,如果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();
这是如何发挥作用的。
baz
被执行。在堆栈上分配的内存。
foo
被调度为“下一个”运行而被延迟。
bar
被调度为“下一个”运行而被延迟。
baz
完成。堆栈已清除。
事件循环选择队列中的下一项 - 这是foo
。
foo
被执行。在堆栈上分配的内存。
foo
完成。堆栈已清除。
事件循环选择队列中的下一项 - 这是bar
。
bar
被执行。在堆栈上分配的内存。
bar
完成。堆栈已清除。
如您所见,堆栈仍在发挥作用。您调用的任何函数都将始终生成一个堆栈条目。事件队列是一个单独的机制。
通过这种方式,您可以获得更少的内存开销,因为您不必等待任何其他函数来释放分配的资源。另一方面,您不能依赖任何功能的完整。
我希望这部分也能回答你的 Q3。
回答 2
推迟到队列有什么帮助?
我希望上面的解释能让它更清楚,但需要确保解释是有道理的:
堆栈的深度是有限制的。如果您考虑一下,这应该很明显 - 可能只有这么多内存可以用于临时存储。一旦达到最大调用深度,JavaScript 将抛出 RangeError: Maximum call stack size exceeded
错误。
如果您查看我上面给出的recursiveCountDown
示例,很容易导致错误 - 如果您调用recursiveCountDown(100000)
,您将得到RangeError
。
通过将所有其他执行放入队列中,您可以避免填满堆栈,从而避免RangeError
。所以让我们重新编写函数
// 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);
【讨论】:
太棒了,太棒了!这是我见过的关于这个问题的最惊人的解释。太感谢了!你的时间!这是 SO 如此神奇和令人上瘾的最好原因之一! 一个问题。为什么我们假设会溢出?例如,如果它超过数组的大小,则在数组中。在堆栈的情况下。我怎么知道堆栈的大小?只是好奇! 好吧,调用堆栈的大小是有限的,但没有通用的限制。请参阅here 尽管我认为这些数字已经过时,但它们传达的想法仍然存在——这取决于浏览器。通常,您只需要担心递归函数的堆栈大小深度——“正常”函数极不可能遇到该限制。但是,您应该注意分配的资源 - 保存昂贵数据的函数可能会减慢您的应用程序(如果它们等待内部调用结束)。 这是对事件队列的一个很好的解释。我已经知道事件队列,但是将它与调用堆栈并排放置,就像您在此处所做的那样,无疑使我的理解提高了一个档次。干得好。为这个清晰的解释 +1。【参考方案2】:使用call stack
的主要原因是为了知道当前函数结束后去哪里。
但大多数语言的大小限制为call stack
,因此如果您重复调用函数直到函数未完成,call stack
的大小就会溢出。
setTimeout
的大多数工具都有 queue
用于保存作业。
并在空闲时间执行它们。
首先nextListItem
在自身未完成之前调用self。
所以call stack
会很长,直到项目列表结束。
第二个nextListItem
是在完成自身后调用self,call stack
也很清楚。
所以在空闲时间从setTimeout
调用nextListItem
时,call stack
将从空开始。
call stack
用于函数调用历史记录。 event queue
用于保存 setTimeout
工作。
见上面的解释。
javascript 只是不断地执行你的语句。但将保存在调用此函数的位置以在函数完成后返回该函数。 call stack
用于保存调用函数的历史记录。
【讨论】:
以上是关于了解 javascript 中的事件队列和调用堆栈的主要内容,如果未能解决你的问题,请参考以下文章
JavaScript是如何工作的01:引擎,运行时和调用堆栈的概述!