JavaScript - 如何在执行繁重任务时避免阻塞浏览器?

29

我在我的JS脚本中有这样一个函数:

function heavyWork(){
   for (i=0; i<300; i++){
        doSomethingHeavy(i);
   }
}
也许单独使用"doSomethingHeavy"没有问题,但重复300次会导致浏览器窗口卡住一段时间。在Chrome中,这不是太大的问题,因为只有一个选项卡受到影响;但对于Firefox来说,这完全是一场灾难。有没有办法告诉浏览器/JS“慢慢来”,不要在调用“doSomethingHeavy”之间阻塞所有东西?

你可以尝试在调用 doSomethingHeavy 之间休眠一下。此外,如果这个函数涉及与某个服务器的通信,AJAX 可能是正确的选择。 - Eran Zimmerman Gonen
@EranZimmerman:我认为在JavaScript中你无法“睡眠”。你能做的最好的事情就是为每个对doSomethingHeavy的调用设置一个setTimeout - gen_Eric
9个回答

26

你可以将调用嵌套在一个 setTimeout 调用内:

for(...) {
    setTimeout(function(i) {
        return function() { doSomethingHeavy(i); }
    }(i), 0);
}

这会将对doSomethingHeavy的调用排队以立即执行,但其他JavaScript操作可以在它们之间被挤入。

一个更好的解决方案是让浏览器通过Web Workers生成一个新的非阻塞进程,但这是HTML5特定的。

编辑:

使用setTimeout(fn, 0)实际上需要比零毫秒更长的时间--例如,Firefox 强制执行最小4毫秒的等待时间。更好的方法可能是使用setZeroTimeout,它优先选择postMessage进行即时、可中断的函数调用,但对于较旧的浏览器则使用setTimeout作为备选项。


1
应该是 setTimeout((function(i){ - gen_Eric
这两种技术在我的情况下都很有用(针对不同类型的重型工作)。谢谢! - Gadi A
3
干得好。我认为值得注意的是,在 doSomethingHeavy 的执行期间,浏览器将被冻结,因此如果有长时间操作,仍然会出现挂起,但时间将缩短为原来的1/300。 - vol7ron
为什么使用setZeroTimeout可能是更好的方法?这有点不公平,对吧?在这里使用setTimeout的目的并不是为了延迟,而是为了给其他代码运行的机会。 - Tobbe Brolin
@TobbeBrolin 这里 setTimeout(fn, 0) 的问题是(过去了吗?这现在已经是六年前的事了),setTimeout 具有最小等待时间为4毫秒。如果需要运行1000次 fn,则浪费时间的上限为额外的4秒钟。通过等待“零”毫秒,您仍然可以为其他进程插入迭代(确实使用 setZeroTimeout 排队等待任务),但是如果没有其他等待任务,您不会浪费4毫秒。 - apsillers

12

您可以尝试将每个函数调用都包装在一个0毫秒的setTimeout中。这样做将把这些调用推到堆栈的底部,并使浏览器能够在它们之间得到休息。

function heavyWork(){
   for (i=0; i<300; i++){
        setTimeout(function(){
            doSomethingHeavy(i);
        }, 0);
   }
}

编辑:我刚意识到这样行不通。 i 的值将在每次循环迭代中保持不变,您需要创建一个闭包。

function heavyWork(){
   for (i=0; i<300; i++){
        setTimeout((function(x){
            return function(){
                doSomethingHeavy(x);
            };
        })(i), 0);
   }
}

1
0 不再是一个可接受的延迟值,您需要至少使用 4 - Teemu
1
@Teemu:需要引用。编辑:找到了,https://developer.mozilla.org/en/DOM/window.setTimeout#Minimum_delay_and_timeout_nesting。 - gen_Eric
3
根据规范:如果当前正在运行的任务是由setTimeout()方法创建的,并且超时时间小于4,则会自动将超时时间增加到4。 - gen_Eric
1
@rocket:请查看https://dev59.com/PnI-5IYBdhLWcg3wlpiW。 - dyoo
@Rocket 我明白了,这个链接(whatwg.org)似乎非常有用,谢谢。 - Teemu
显示剩余7条评论

10

3

您可以做很多事情:

  1. 优化循环 - 如果重量级工作涉及DOM访问,请参见此答案
  • 如果函数使用某种原始数据类型,请使用类型化数组MSDN MDN
  1. 使用setTimeout()方法被称为迭代,非常有用。

  2. 对于非函数编程语言,该函数似乎非常直观。 JavaScript利用回调函数的优势SO问题

  3. 一个新特性是Web WorkersMDNMSDNwikipedia

  4. 最后一件事情(也许)是将所有方法结合起来-使用传统方式,函数仅使用一个线程。如果您可以使用Web Workers,则可以在多个线程之间分配工作。这应该可以缩短完成任务所需的时间。


3

为了避免独占浏览器的注意力,我们需要定期将控制权交给浏览器。

一种释放控制权的方法是使用setTimeout,它可以安排一个“回调函数”在一段时间后被调用。例如:

var f1 = function() {
    document.body.appendChild(document.createTextNode("Hello"));
    setTimeout(f2, 1000);
};

var f2 = function() {
    document.body.appendChild(document.createTextNode("World"));
};

在此处调用 f1 将向您的文档添加单词 hello,安排一个挂起的计算任务,然后释放控制权给浏览器。最终,将调用 f2

请注意,仅仅 indiscriminately 使用 setTimeout 并不能像神奇的精灵尘一样轻松实现功能:您真正需要做的是将剩余的计算过程封装在回调函数中。通常,setTimeout 将成为函数中的最后一件事,而其他计算内容则被塞进回调函数中。

对于您的特定情况,代码需要被小心地转换为类似以下的形式:

var heavyWork = function(i, onSuccess) {
   if (i < 300) {
       var restOfComputation = function() {
           return heavyWork(i+1, onSuccess);
       }
       return doSomethingHeavy(i, restOfComputation);          
   } else {
       onSuccess();
   }
};

var restOfComputation = function(i, callback) {
   // ... do some work, followed by:
   setTimeout(callback, 0);
};

在每个restOfComputation上,它会将控制权释放给浏览器。

另一个具体的例子是: 如何排队播放一系列HTML5 <audio>声音片段?

高级JavaScript程序员需要知道如何进行这种程序转换,否则他们会遇到您遇到的问题。如果使用此技术,您会发现自己必须以奇怪的样式编写程序,其中每个可以释放控制的函数都接受回调函数。这种样式的技术术语称为“传递风格”或“异步风格”。


2

我看到有两种方法:

a) 如果允许使用Html5特性,则可以考虑使用工作线程。

b) 将任务分割成一次只执行一个调用的消息队列,并在还有任务需要处理时进行迭代。


1

有一个人写了一个特定的 backgroundtask JavaScript 库来处理这样的繁重工作... 你可以在这里的问题中查看它:

JavaScript 中执行后台任务

我自己没有使用过它,只是使用了同样提到的线程用法。


1
function doSomethingHeavy(param){
   if (param && param%100==0) 
     alert(param);
}

(function heavyWork(){
    for (var i=0; i<=300; i++){
       window.setTimeout(
           (function(i){ return function(){doSomethingHeavy(i)}; })(i)
       ,0);
    }
}())

以上代码是无效的。setTimeout不能神奇地保存周围的计算上下文,也不能将控制权返回给浏览器。它只是一个调度机制。以上解释是错误的。 - dyoo
@dyoo:我认为那不对,也许正确的方式是0,但我很确定它会跳到堆栈中的下一个,同时等待。 - vol7ron
不。作为证明,请尝试:var test = function() { var f = function() {alert('hi!');}; setTimeout(f, 0); while(true) {} }; test()。在您的演示中,这将显示“hi!”。请注意,它并没有这样做。我想指出的是,仅添加setTimeout()是不够的。调用setTimeout不会将控制权交还给浏览器:它只是一个调度机制。 - dyoo
我明白你的意思,你是对的,这确实涉及到调度(但浏览器控制也是如此)。问题在于它必须发生在doSomethingHeavy之外,除非你创建自己的队列堆栈并从doSomethingHeavy中检查。自从我最初发布这篇文章以来,似乎有很多答案,所以我会在给你一些时间看到这个评论后将其删除。 - vol7ron

0

有一个名为requestIdleCallback的功能(最近被大多数较大的平台采用),您可以运行一个函数,只有在没有其他函数占用事件循环时才会执行该函数。这意味着对于不太重要但耗费时间的工作,您可以安全地执行它,而无需影响主线程(前提是任务需要少于16ms完成,即一帧时间。否则必须分批处理)

我编写了一个函数来执行一系列操作,而不会影响主线程。您也可以传递一个shouldCancel回调函数,在任何时候取消工作流。它将退回到setTimeout:

export const idleWork = async (
  actions: (() => void)[],
  shouldCancel: () => boolean
): Promise<boolean> => {
  const actionsCopied = [...actions];
  const isRequestIdleCallbackAvailable = "requestIdleCallback" in window;

  const promise = new Promise<boolean>((resolve) => {
    if (isRequestIdleCallbackAvailable) {
      const doWork: IdleRequestCallback = (deadline) => {
        while (deadline.timeRemaining() > 0 && actionsCopied.length > 0) {
          actionsCopied.shift()?.();
        }

        if (shouldCancel()) {
          resolve(false);
        }

        if (actionsCopied.length > 0) {
          window.requestIdleCallback(doWork, { timeout: 150 });
        } else {
          resolve(true);
        }
      };
      window.requestIdleCallback(doWork, { timeout: 200 });
    } else {
      const doWork = () => {
        actionsCopied.shift()?.();
        if (shouldCancel()) {
          resolve(false);
        }

        if (actionsCopied.length !== 0) {
          setTimeout(doWork);
        } else {
          resolve(true);
        }
      };
      setTimeout(doWork);
    }
  });

  const isSuccessful = await promise;
  return isSuccessful;
};

以上代码将执行一个函数列表。该列表可能非常长且昂贵,但只要每个单独的任务在16毫秒内完成,它就不会影响主线程。警告因为并非所有浏览器都支持此功能,但Webkit支持。


requestIdleCallback 并不是新的,它存在已经很多年了。也许对你来说是新的 ;) 你应该编辑你的问题并删除“2022答案”的标题,因为显然你是在2022年写的(见你的答案下面,你的用户名上面)。在巨大的字体中写下答案的日期并不重要,而且在当前的答案中也会产生误导。 - vsync
@vsync相对其他已有多年应用的功能来说是新的,这才是最重要的因素,对吧?所以是的,它相对而言是新的。 - Gabriel Petersson
抱歉,无论你怎么解释,这都不是新的。另外,你最后的编辑可能需要重新编辑(试着读一下你的回答)。 - vsync

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