不阻塞用户界面的最佳数组迭代方式

81

我需要遍历一些大型数组并从 API 调用中将它们存储到 Backbone 集合中。在不使循环导致界面失去响应的情况下,最好的方法是什么?

由于返回的数据量很大,ajax 请求的返回也会阻塞。我想,我可以将其分割并使用 setTimeout 以较小的块异步运行,但是否有更简单的方法呢?

我认为 web worker 是个好东西,但它需要修改保存在 UI 线程上的一些数据结构。我已经尝试使用它来执行 ajax 调用,但当它将数据返回给 UI 线程时,界面仍然无响应。

提前感谢您


我最终采用了@jfriend00提出的示例,并进行了一些更改,以合并不同的键值和较小的块大小,因为操作更加昂贵。 - georgephillips
4个回答

127

你可以选择使用或不使用 WebWorkers:

不使用 WebWorkers

对于需要与 DOM 或应用程序中的大量状态进行交互的代码,无法使用 WebWorker,因此通常的解决方案是将工作分成块并在定时器上执行每个工作块。定时器之间的间隔允许浏览器引擎处理其他事件,这不仅可以使用户输入得到处理,还可以让屏幕绘制。

通常情况下,您可以承受在每个定时器上处理多个块,这比仅在每个定时器上处理一个块更有效率和更快速。该代码为 UI 线程提供了机会,在每个块之间处理任何待处理的 UI 事件,从而使 UI 处于活动状态。

function processLargeArray(array) {
    // set this to whatever number of items you can process at once
    var chunk = 100;
    var index = 0;
    function doChunk() {
        var cnt = chunk;
        while (cnt-- && index < array.length) {
            // process array[index] here
            ++index;
        }
        if (index < array.length) {
            // set Timeout for async iteration
            setTimeout(doChunk, 1);
        }
    }    
    doChunk();    
}

processLargeArray(veryLargeArray);

这里有一个工作示例,虽然不是相同的函数,但使用了与测试大量迭代概率情况相同的 setTimeout() 想法:http://jsfiddle.net/jfriend00/9hCVq/


您可以将上面的代码转换为更通用的版本,像.forEach()一样调用回调函数:

// last two args are optional
function processLargeArrayAsync(array, fn, chunk, context) {
    context = context || window;
    chunk = chunk || 100;
    var index = 0;
    function doChunk() {
        var cnt = chunk;
        while (cnt-- && index < array.length) {
            // callback called with args (value, index, array)
            fn.call(context, array[index], index, array);
            ++index;
        }
        if (index < array.length) {
            // set Timeout for async iteration
            setTimeout(doChunk, 1);
        }
    }    
    doChunk();    
}

processLargeArrayAsync(veryLargeArray, myCallback, 100);
与其猜测每次应该分块多少数据,还可以让经过的时间成为每块数据的指引,并在给定的时间间隔内尽可能地处理它们。这样有点类似于自动保证了浏览器的响应能力,无论迭代的计算量有多大。因此,您可以传入毫秒值(或者只是使用智能默认值),而不是传入块大小:
// last two args are optional
function processLargeArrayAsync(array, fn, maxTimePerChunk, context) {
    context = context || window;
    maxTimePerChunk = maxTimePerChunk || 200;
    var index = 0;

    function now() {
        return new Date().getTime();
    }

    function doChunk() {
        var startTime = now();
        while (index < array.length && (now() - startTime) <= maxTimePerChunk) {
            // callback called with args (value, index, array)
            fn.call(context, array[index], index, array);
            ++index;
        }
        if (index < array.length) {
            // set Timeout for async iteration
            setTimeout(doChunk, 1);
        }
    }    
    doChunk();    
}

processLargeArrayAsync(veryLargeArray, myCallback);

使用WebWorkers

如果您的循环中的代码不需要访问DOM,那么可以将所有耗时的代码放入一个WebWorker中。WebWorker将独立于浏览器主JavaScript运行,运行完成后可以通过postMessage与主线程通信传递任何结果。

WebWorker需要将所有将在其中运行的代码分离到单独的脚本文件中,但它可以在不影响浏览器其他事件处理的情况下完成运行,而且不用担心在主线程上进行长时间运行过程时可能出现的“无响应脚本”提示,并且不会阻止UI中的事件处理。


添加了一个更通用的版本,它通过 .forEach() 风格的回调函数工作,因此可以将同一实用函数用于许多目的。 - jfriend00
1
添加了另一个通用版本,它按时间分块而不是数量,因此它将根据迭代所需的时间自适应其自己的块大小(保持浏览器响应)。 - jfriend00
这如何应用于for..in对象枚举?创建一个数组,然后执行上述操作?(或者最好提出一个新问题?) - serv-inc
2
@user - 是的,你需要先创建数组(可以使用Object.keys()来创建数组),因为你不能直接使用for/in这种方式进行迭代。 - jfriend00
2
我认为使用 window.requestAnimationFrame() 而不是 setTimeout() 更好。这样,你可以确信你的代码不会阻塞,因为浏览器本身告诉你可以进行一些处理。 - dodov
显示剩余6条评论

9

这里有一个示例展示了如何执行“异步”循环。它会将迭代“延迟”1毫秒,在此期间,它会给UI一个机会去做一些事情。

function asyncLoop(arr, callback) {
    (function loop(i) {

        //do stuff here

        if (i < arr.Length) {                      //the condition
            setTimeout(function() {loop(++i)}, 1); //rerun when condition is true
        } else { 
            callback();                            //callback when the loop ends
        }
    }(0));                                         //start with 0
}

asyncLoop(yourArray, function() {
    //do after loop  
})​;

//anything down here runs while the loop runs

有一些替代方案,比如Web Workers目前提议的setImmediate,据我所知,后者在IE上需要加前缀。


不如之前的答案好,因为它在每个元素后都调用setTimeout。 - Andrey M.
1毫秒的延迟并不是UI更新的原因。setTimeout函数会将函数排队到回调队列中,在UI有机会完成其操作后再执行。您可以轻松地传入0秒的延迟。 - chris

0

在 @jfriend00 的基础上,这是一个原型版本:

if (Array.prototype.forEachAsync == null) {
    Array.prototype.forEachAsync = function forEachAsync(fn, thisArg, maxTimePerChunk, callback) {
        let that = this;
        let args = Array.from(arguments);

        let lastArg = args.pop();

        if (lastArg instanceof Function) {
            callback = lastArg;
            lastArg = args.pop();
        } else {
            callback = function() {};
        }
        if (Number(lastArg) === lastArg) {
            maxTimePerChunk = lastArg;
            lastArg = args.pop();
        } else {
            maxTimePerChunk = 200;
        }
        if (args.length === 1) {
            thisArg = lastArg;
        } else {
            thisArg = that
        }

        let index = 0;

        function now() {
            return new Date().getTime();
        }

        function doChunk() {
            let startTime = now();
            while (index < that.length && (now() - startTime) <= maxTimePerChunk) {
                // callback called with args (value, index, array)
                fn.call(thisArg, that[index], index, that);
                ++index;
            }
            if (index < that.length) {
                // set Timeout for async iteration
                setTimeout(doChunk, 1);
            } else {
                callback();
            }
        }

        doChunk();
    }
}

我更喜欢使用 if (...) return; ... 而不是把所有东西都放在 if 语句里。 - canbax

0

使用下面的代码,您可以使用数组函数(迭代数组)或映射函数(迭代映射)。

此外,现在有一个参数用于在块完成时调用函数(如果需要更新加载消息),以及一个参数用于在处理循环结束时调用函数(必要时进行异步操作完成后执行下一步操作)

//Iterate Array Asynchronously
//fn = the function to call while iterating over the array (for loop function call)
//chunkEndFn (optional, use undefined if not using) = the function to call when the chunk ends, used to update a loading message
//endFn (optional, use undefined if not using) = called at the end of the async execution
//last two args are optional
function iterateArrayAsync(array, fn, chunkEndFn, endFn, maxTimePerChunk, context) {
    context = context || window;
    maxTimePerChunk = maxTimePerChunk || 200;
    var index = 0;

    function now() {
        return new Date().getTime();
    }

    function doChunk() {
        var startTime = now();
        while (index < array.length && (now() - startTime) <= maxTimePerChunk) {
            // callback called with args (value, index, array)
            fn.call(context,array[index], index, array);
            ++index;
        }
        if((now() - startTime) > maxTimePerChunk && chunkEndFn !== undefined){
            //callback called with args (index, length)
            chunkEndFn.call(context,index,array.length);
        }
        if (index < array.length) {
            // set Timeout for async iteration
            setTimeout(doChunk, 1);
        }
        else if(endFn !== undefined){
            endFn.call(context);
        }
    }    
    doChunk();    
}

//Usage
iterateArrayAsync(ourArray,function(value, index, array){
    //runs each iteration of the loop
},
function(index,length){
    //runs after every chunk completes, this is optional, use undefined if not using this
},
function(){
    //runs after completing the loop, this is optional, use undefined if not using this
    
});

//Iterate Map Asynchronously
//fn = the function to call while iterating over the map (for loop function call)
//chunkEndFn (optional, use undefined if not using) = the function to call when the chunk ends, used to update a loading message
//endFn (optional, use undefined if not using) = called at the end of the async execution
//last two args are optional
function iterateMapAsync(map, fn, chunkEndFn, endFn, maxTimePerChunk, context) {
    var array = Array.from(map.keys());
    context = context || window;
    maxTimePerChunk = maxTimePerChunk || 200;
    var index = 0;

    function now() {
        return new Date().getTime();
    }

    function doChunk() {
        var startTime = now();
        while (index < array.length && (now() - startTime) <= maxTimePerChunk) {
            // callback called with args (value, key, map)
            fn.call(context,map.get(array[index]), array[index], map);
            ++index;
        }
        if((now() - startTime) > maxTimePerChunk && chunkEndFn !== undefined){
            //callback called with args (index, length)
            chunkEndFn.call(context,index,array.length);
        }
        if (index < array.length) {
            // set Timeout for async iteration
            setTimeout(doChunk, 1);
        }
        else if(endFn !== undefined){
            endFn.call(context);
        }
    }    
    doChunk();
}

//Usage
iterateMapAsync(ourMap,function(value, key, map){
    //runs each iteration of the loop
},
function(index,length){
    //runs after every chunk completes, this is optional, use undefined if not using this
},
function(){
    //runs after completing the loop, this is optional, use undefined if not using this
    
});

1
称呼这个函数为“async”是误导性的,我想。操作仍然在同一线程上同步进行。 - canbax

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