为什么setTimeout(fn,0)有时会有用?

问题描述 投票:783回答:17

我最近遇到了一个相当讨厌的错误,其中代码是通过JavaScript动态加载<select>。这个动态加载的<select>有一个预先选择的值。在IE6中,我们已经有了修复所选<option>的代码,因为有时候<select>selectedIndex值与选中的<option>index属性不同步,如下所示:

field.selectedIndex = element.index;

但是,此代码无效。即使字段的selectedIndex设置正确,错误的索引最终会被选中。但是,如果我在正确的时间插入了alert()语句,则会选择正确的选项。考虑到这可能是某种时间问题,我尝试了一些随机的东西,我之前在代码中看到过:

var wrapFn = (function() {
    var myField = field;
    var myElement = element;

    return function() {
        myField.selectedIndex = myElement.index;
    }
})();
setTimeout(wrapFn, 0);

这有效!

我已经找到了解决问题的方法,但是我很不安,因为我不知道为什么这会解决我的问题。有人有官方解释吗?通过使用setTimeout()“稍后”调用我的函数,我可以避免哪些浏览器问题?

javascript dom event-loop
17个回答
751
投票

这是有效的,因为你正在进行合作多任务。

浏览器必须同时执行许多操作,其中只有一个是执行JavaScript。但JavaScript经常用于的一件事是要求浏览器构建一个显示元素。这通常被认为是同步完成的(特别是当JavaScript不是并行执行时),但不能保证是这种情况,并且JavaScript没有明确定义的等待机制。

解决方案是“暂停”JavaScript执行以让渲染线程赶上来。这就是setTimeout()超时为0的效果。它就像C中的一个线程/进程产量。虽然它似乎说“立即运行”但它实际上让浏览器有机会完成一些非JavaScript事情,这些事情一直在等待完成这一新的JavaScript之前完成。

(实际上,setTimeout()在执行队列的末尾重新排队新的JavaScript。请参阅注释以获取更长解释的链接。)

IE6恰好更容易出现此错误,但我已经看到它出现在旧版本的Mozilla和Firefox中。


请参阅Philip Roberts谈论"What the heck is the event loop?"以获得更详尽的解释。


6
投票

这两个评价最高的答案都是错误的。 Check out the MDN description on the concurrency model and the event loop,它应该变得清楚发生了什么(MDN资源是真正的宝石)。简单地使用setTimeout除了“解决”这个小问题外,还可以在代码中添加意想不到的问题。

这里实际发生的事情并不是“浏览器可能还没有准备就绪,因为并发性”,或基于“每一行都是一个被添加到队列后面的事件”的东西。

DVK提供的jsfiddle确实说明了一个问题,但他对此的解释并不正确。

他的代码中发生的事情是他首先在click按钮上将事件处理程序附加到#do事件。

然后,当您实际单击该按钮时,将创建引用事件处理函数的message,该函数将添加到message queue。当event loop到达此消息时,它会在堆栈上创建一个frame,函数调用jsfiddle中的click事件处理程序。

这就是它变得有趣的地方。我们习惯于将Javascript视为异步,我们很容易忽略这个微小的事实:任何帧都必须在下一帧执行之前完整执行。没有并发,人。

这是什么意思?这意味着无论何时从消息队列调用函数,它都会阻塞队列,直到它生成的堆栈被清空为止。或者,更一般地说,它会阻塞,直到函数返回。它阻止了一切,包括DOM渲染操作,滚动和诸如此类的东西。如果你想要确认,只是尝试增加小提琴中长时间运行操作的持续时间(例如,多次运行外循环10),你会注意到它在运行时,你无法滚动页面。如果运行时间足够长,您的浏览器会询问您是否要终止该进程,因为它会使页面无响应。正在执行该帧,并且事件循环和消息队列将一直停留直到完成。

那么为什么这个文本的副作用没有更新呢?因为虽然你已经改变了DOM中元素的值 - 你可以在更改它之后立即console.log()它的值并看到它已被更改(这说明为什么DVK的解释不正确) - 浏览器正在等待堆栈到耗尽(要返回的on处理函数)以及因此要完成的消息,以便它最终能够执行运行时添加的消息作为对我们的变异操作的反应,并且为了反映该变异用户界面。

这是因为我们实际上在等待代码完成运行。我们还没有说“有人拿这个,然后用结果调用这个函数,谢谢,现在我已经完成了imma return,现在做任何事情,”就像我们通常使用基于事件的异步Javascript一样。我们输入一个click事件处理函数,我们更新一个DOM元素,我们调用另一个函数,其他函数工作很长时间然后返回,然后我们更新相同的DOM元素,然后我们从初始函数返回,有效地清空堆栈。然后浏览器可以到达队列中的下一条消息,这可能是我们通过触发一些内部“on-DOM-mutation”类型事件而生成的消息。

浏览器UI不能(或选择不)更新UI,直到当前正在执行的帧完成(函数已返回)。就个人而言,我认为这是设计而非限制。

为什么setTimeout的东西呢?它这样做,因为它有效地从其自己的帧中删除对长时间运行的函数的调用,将其安排在稍后的window上下文中执行,以便它本身可以立即返回并允许消息队列处理其他消息。我们的想法是,在更改DOM中的文本时,我们在Javascript中触发的UI“on update”消息现在位于排队等待长时间运行的函数的消息之前,因此UI更新在我们阻止之前发生需很长时间。

请注意a)长时间运行的函数在运行时仍会阻塞所有内容,并且b)您无法保证UI更新实际上在消息队列中位于其前面。在我的2018年6月的Chrome浏览器中,0的值并没有“修复”小提示演示的问题--10。我实际上对此有点嗤之以鼻,因为我认为UI更新消息应该在它之前排队,因为它的触发器是在调度长时间运行的函数“稍后”运行之前执行的。但也许在V8引擎中有一些可能会干扰的优化,或者我的理解可能只是缺乏。

那么,使用setTimeout有什么问题,对于这个特殊情况,什么是更好的解决方案?

首先,在这样的任何事件处理程序上使用setTimeout的问题,试图缓解另一个问题,很容易弄乱其他代码。这是我工作中的一个真实例子:

一位同事在对事件循环的错误理解中,试图通过让一些模板渲染代码使用setTimeout 0进行渲染来“线程化”Javascript。他不再在这里问,但我可以假设也许他插入了定时器来衡量渲染速度(这将是函数的返回即时性),并发现使用这种方法会使该函数的响应速度非常快。

第一个问题很明显;你不能使用javascript,所以你在添加混淆时没有赢得任何东西。其次,您现在已经有效地从可能的事件侦听器堆栈中分离了模板的呈现,这些事件侦听器可能期望已经渲染了非常模板,而它可能很少没有。该函数的实际行为现在是非确定性的,因为 - 在不知情的情况下 - 任何运行它或依赖它的函数。您可以进行有根据的猜测,但无法正确编码其行为。

编写依赖于其逻辑的新事件处理程序时的“修复”也是使用setTimeout 0。但是,这不是一个修复,很难理解,调试由这样的代码引起的错误并不好玩。有时候没有任何问题,有时它会一直失败,然后有时它会偶尔起作用和破坏,这取决于平台当前的性能以及当时正在发生的其他事情。这就是为什么我个人会建议不要使用这个黑客(它是一个黑客,我们都应该知道它是),除非你真的知道你在做什么以及后果是什么。

但我们能做些什么呢?好吧,正如引用的MDN文章建议的那样,要么将工作分成多个消息(如果可以的话),以便排队的其他消息可以与您的工作交错并在其运行时执行,或者使用可以运行的Web工作者与您的页面串联并在完成计算后返回结果。

哦,如果你在想,“好吧,我不能只在长时间运行的函数中调用它来使它异步吗?”然后没有。回调不会使它异步,它仍然必须在显式调用回调之前运行长时间运行的代码。


3
投票

另一件事是将函数调用推送到堆栈的底部,如果递归调用函数,则防止堆栈溢出。这具有while循环的效果,但允许JavaScript引擎触发其他异步计时器。


2
投票

关于执行循环和在其他代码完成之前呈现DOM的答案是正确的。 JavaScript中的零秒超时有助于使代码伪多线程,即使它不是。

我想补充一点,JavaScript中跨浏览器/跨平台零秒超时的最佳值实际上是大约20毫秒而不是0(零),因为由于时钟限制,许多移动浏览器无法注册小于20毫秒的超时在AMD芯片上。

此外,不应该将涉及DOM操作的长时间运行的进程发送给Web Workers,因为它们提供了真正的多线程JavaScript执行。


1
投票

通过调用setTimeout,您可以给页面时间以响应用户正在执行的操作。这对于页面加载期间运行的函数特别有用。


1
投票

setTimeout有用的其他一些情况:

您希望将长时间运行的循环或计算分解为较小的组件,以便浏览器看起来不会“冻结”或说“页面上的脚本正忙”。

您希望在单击时禁用表单提交按钮,但如果禁用onClick处理程序中的按钮,则不会提交表单。时间为零的setTimeout可以解决问题,允许事件结束,表单开始提交,然后您的按钮可以被禁用。


1
投票

setTimeout为0在设置延迟保证的模式中也非常有用,您希望立即返回:

myObject.prototype.myMethodDeferred = function() {
    var deferredObject = $.Deferred();
    var that = this;  // Because setTimeout won't work right with this
    setTimeout(function() { 
        return myMethodActualWork.call(that, deferredObject);
    }, 0);
    return deferredObject.promise();
}

0
投票

问题是你试图对非现有元素执行Javascript操作。该元素尚未加载,setTimeout()为以下列方式加载元素提供了更多时间:

  1. setTimeout()导致事件是异步的,因此在所有同步代码之后执行,从而为您的元素提供更多的加载时间。像setTimeout()中的回调这样的异步回调被放置在事件队列中,并在同步代码堆栈为空后由事件循环放入堆栈。
  2. ms作为函数setTimeout()中的第二个参数的值0通常略高(4-10ms,具体取决于浏览器)。执行setTimeout()回调所需的稍高时间是由事件循环的“滴答”(如果堆栈为空,滴答符在堆栈上回调)。由于性能和电池寿命的原因,事件循环中的滴答数量限制在每秒少于1000次的一定量。

-1
投票

Javascript是单线程应用程序,因此不允许同时运行函数,因此实现此事件循环是有用的。所以setTimeout(fn,0)正是这样做的,它被推入任务任务,当你的调用堆栈为空时执行。我知道这个解释很无聊,所以我建议你仔细阅读这个视频,这将有助于你在浏览器中如何工作。看看这个视频: - https://www.youtube.com/watch?time_continue=392&v=8aGhZQkoFbQ


613
投票

前言:

重要提示:虽然它最受欢迎和接受,但@staticsan接受的答案实际上并不正确! - 请参阅David Mulder对于解释原因的评论。

其他一些答案是正确的,但实际上没有说明要解决的问题是什么,所以我创建了这个答案,以提供详细的说明。

因此,我将详细介绍浏览器的功能以及如何使用setTimeout()提供帮助。它看起来很长,但实际上非常简单明了 - 我只是非常详细。

更新:我已经制作了一个JSFiddle来演示以下解释:http://jsfiddle.net/C2YBE/31/。非常感谢@ThangChung帮助启动它。

更新2:为了防止JSFiddle网站死亡或删除代码,我在最后添加了代码到这个答案。


细节:

想象一下带有“做某事”按钮和结果div的网络应用程序。

“do something”按钮的onClick处理程序调用函数“LongCalc()”,它执行两项操作:

  1. 做了很长的计算(比如需要3分钟)
  2. 将计算结果打印到结果div中。

现在,你的用户开始测试这个,点击“做某事”按钮,页面就在那里做3分钟看似没事,他们变得焦躁不安,再次点击按钮,等待1分钟,没有任何反应,再次点击按钮......

问题很明显 - 你想要一个“状态”DIV,它显示了正在发生的事情。让我们看看它是如何工作的。


所以你添加一个“Status”DIV(最初为空),并修改onclick处理程序(函数LongCalc())来做4件事:

  1. 将状态“计算...可能需要约3分钟”填充到状态DIV中
  2. 做了很长的计算(比如需要3分钟)
  3. 将计算结果打印到结果div中。
  4. 将“已完成计算”状态填充到状态DIV中

并且,您乐意将应用程序提供给用户重新测试。

他们回到你身边看起来很生气。并解释当他们点击按钮时,状态DIV永远不会更新“计算...”状态!


你挠头,在StackOverflow(或阅读文档或谷歌)上四处询问,并意识到问题:

浏览器将事件产生的所有“TODO”任务(UI任务和JavaScript命令)放入单个队列中。不幸的是,使用新的“Calculating ...”值重新绘制“Status”DIV是一个单独的TODO,它会一直到队列的末尾!

以下是用户测试期间事件的细分,每个事件后队列的内容:

  • 队列:[Empty]
  • 事件:单击按钮。事件后排队:[Execute OnClick handler(lines 1-4)]
  • 事件:在OnClick处理程序中执行第一行(例如,更改Status DIV值)。事件后排队:[Execute OnClick handler(lines 2-4), re-draw Status DIV with new "Calculating" value]。请注意,当DOM更改瞬间发生时,要重新绘制相应的DOM元素,您需要一个由DOM更改触发的新事件,该事件在队列末尾。
  • 问题!!!问题!!!细节说明如下。
  • 事件:在处理程序(计算)中执行第二行。队列之后:[Execute OnClick handler(lines 3-4), re-draw Status DIV with "Calculating" value]
  • 事件:在处理程序中执行第3行(填充结果DIV)。队列之后:[Execute OnClick handler(line 4), re-draw Status DIV with "Calculating" value, re-draw result DIV with result]
  • 事件:在处理程序中执行第4行(使用“DONE”填充状态DIV)。队列:[Execute OnClick handler, re-draw Status DIV with "Calculating" value, re-draw result DIV with result; re-draw Status DIV with "DONE" value]
  • 事件:从return处理程序sub执行隐含的onclick。我们从队列中取出“Execute OnClick handler”并开始执行队列中的下一个项目。
  • 注意:由于我们已经完成了计算,因此用户已经过了3分钟。重新抽奖活动还没有发生!
  • 事件:使用“计算”​​值重新绘制状态DIV。我们重新绘制并将其从队列中取出。
  • 事件:使用结果值重新绘制结果DIV。我们重新绘制并将其从队列中取出。
  • 事件:使用“完成”值重新绘制状态DIV。我们重新绘制并将其从队列中取出。眼尖的观众甚至可能会注意到“状态DIV与”计算“值闪烁一分之一微秒 - 计算完成后

因此,潜在的问题是“状态”DIV的重新绘制事件在结束时被放置在队列中,在“执行第2行”事件之后需要3分钟,因此实际的重新绘制直到计算完成后。


救援来了setTimeout()。它有什么用?因为通过setTimeout调用长执行代码,实际上创建了2个事件:setTimeout执行本身,和(由于0超时),正在执行的代码的单独队列条目。

因此,为了解决您的问题,您将onClick处理程序修改为TWO语句(在新函数中或仅在onClick中的块中):

  1. 将状态“计算...可能需要约3分钟”填充到状态DIV中
  2. 执行setTimeout(),超时0并调用LongCalc()函数。 LongCalc()函数与上次几乎相同,但显然没有“计算...”状态DIV更新为第一步;而是立即开始计算。

那么,事件序列和队列现在看起来像什么?

  • 队列:[Empty]
  • 事件:单击按钮。事件后排队:[Execute OnClick handler(status update, setTimeout() call)]
  • 事件:在OnClick处理程序中执行第一行(例如,更改Status DIV值)。事件后排队:[Execute OnClick handler(which is a setTimeout call), re-draw Status DIV with new "Calculating" value]
  • 事件:在处理程序中执行第二行(setTimeout调用)。队列之后:[re-draw Status DIV with "Calculating" value]。队列中没有任何新内容,持续0秒。
  • 事件:超时报警在0秒后关闭。队列之后:[re-draw Status DIV with "Calculating" value, execute LongCalc (lines 1-3)]
  • 事件:使用“计算”​​值重新绘制状态DIV。队列之后:[execute LongCalc (lines 1-3)]。请注意,此重新绘制事件可能实际发生在闹钟响起之前,这也适用。
  • ...

万岁!在计算开始之前,状态DIV刚刚更新为“计算...”!



下面是来自JSFiddle的示例代码,说明了这些示例:http://jsfiddle.net/C2YBE/31/

HTML代码:

<table border=1>
    <tr><td><button id='do'>Do long calc - bad status!</button></td>
        <td><div id='status'>Not Calculating yet.</div></td>
    </tr>
    <tr><td><button id='do_ok'>Do long calc - good status!</button></td>
        <td><div id='status_ok'>Not Calculating yet.</div></td>
    </tr>
</table>

JavaScript代码:(在onDomReady上执行,可能需要jQuery 1.9)

function long_running(status_div) {

    var result = 0;
    // Use 1000/700/300 limits in Chrome, 
    //    300/100/100 in IE8, 
    //    1000/500/200 in FireFox
    // I have no idea why identical runtimes fail on diff browsers.
    for (var i = 0; i < 1000; i++) {
        for (var j = 0; j < 700; j++) {
            for (var k = 0; k < 300; k++) {
                result = result + i + j + k;
            }
        }
    }
    $(status_div).text('calculation done');
}

// Assign events to buttons
$('#do').on('click', function () {
    $('#status').text('calculating....');
    long_running('#status');
});

$('#do_ok').on('click', function () {
    $('#status_ok').text('calculating....');
    // This works on IE8. Works in Chrome
    // Does NOT work in FireFox 25 with timeout =0 or =1
    // DOES work in FF if you change timeout from 0 to 500
    window.setTimeout(function (){ long_running('#status_ok') }, 0);
});

87
投票

看看John Resig关于How JavaScript Timers Work的文章。设置超时时,它实际上会将异步代码排队,直到引擎执行当前调用堆栈。


20
投票

setTimeout()会花一些时间来加载DOM元素,即使设置为0也是如此。

看看这个:setTimeout


20
投票

大多数浏览器都有一个名为main thread的进程,它负责执行一些JavaScript任务,UI更新,例如:绘画,重绘或重排等。

一些JavaScript执行和UI更新任务排队到浏览器消息队列,然后被分派到浏览器主线程以执行。

在主线程忙时生成UI更新时,任务将添加到消息队列中。

setTimeout(fn, 0);将此fn添加到要执行的队列的末尾。它会在给定的时间后安排在消息队列中添加的任务。


18
投票

这里有相互矛盾的赞成答案,没有证据就没有办法知道相信谁。这是证明@DVK是正确的并且@SalvadorDali是错误的。后者声称:

“这就是为什么:setTimeout不可能有0毫秒的时间延迟。最小值由浏览器确定,它不是0毫秒。历史上,浏览器将此最小值设置为10毫秒,但HTML5规范和现代浏览器设置为4毫秒。“

4ms的最小超时与发生的事情无关。真正发生的是setTimeout将回调函数推送到执行队列的末尾。如果在setTimeout(回调,0)之后你有阻塞代码需要几秒钟才能运行,那么回调将不会执行几秒钟,直到阻塞代码完成。试试这段代码:

function testSettimeout0 () {
    var startTime = new Date().getTime()
    console.log('setting timeout 0 callback at ' +sinceStart())
    setTimeout(function(){
        console.log('in timeout callback at ' +sinceStart())
    }, 0)
    console.log('starting blocking loop at ' +sinceStart())
    while (sinceStart() < 3000) {
        continue
    }
    console.log('blocking loop ended at ' +sinceStart())
    return // functions below
    function sinceStart () {
        return new Date().getTime() - startTime
    } // sinceStart
} // testSettimeout0

输出是:

setting timeout 0 callback at 0
starting blocking loop at 5
blocking loop ended at 3000
in timeout callback at 3033

13
投票

这样做的一个原因是将代码的执行推迟到单独的后续事件循环。当响应某种浏览器事件(例如,鼠标单击)时,有时只有在处理当前事件后才需要执行操作。 setTimeout()设施是最简单的方法。

现在编辑它是2015年我应该注意到还有requestAnimationFrame(),它不完全相同,但它足够接近setTimeout(fn, 0),值得一提。


9
投票

这是旧答案的旧问题。我想对此问题添加一个新的外观,并回答为什么会发生这种情况而不是为什么这有用。

所以你有两个功能:

var f1 = function () {    
   setTimeout(function(){
      console.log("f1", "First function call...");
   }, 0);
};

var f2 = function () {
    console.log("f2", "Second call...");
};

然后按照以下顺序调用它们f1(); f2();,看看第一个执行的第一个。

这就是为什么:不可能让setTimeout的时间延迟为0毫秒。最小值由浏览器确定,不是0毫秒。 Historically浏览器将此最小值设置为10毫秒,但HTML5 specs和现代浏览器将其设置为4毫秒。

如果嵌套级别大于5,并且超时小于4,则将超时增加到4。

也来自mozilla:

要在现代浏览器中实现0 ms超时,可以使用here所述的window.postMessage()。

附:阅读以下article后获取信息。


8
投票

由于它传递的是0的持续时间,我想它是为了从执行流程中删除传递给setTimeout的代码。因此,如果它是一个可能需要一段时间的函数,它将不会阻止后续代码执行。

© www.soinside.com 2019 - 2024. All rights reserved.