在浏览器中,Javascript执行与UI更新是发生在同一个进程(浏览器UI线程)中的。UI线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲时被提取出来执行。所以Javascript的执行会阻塞UI更新;反之,UI更新也会阻塞Javascript的执行。给用户的表现就是浏览器在工作时短暂或长时间失去反应,用户的操作不能及时得到响应。而UI线程的阻塞很多时候是由于我们要在代码里进行长时间的脚本运算,超过了浏览器限制,导致浏览器失去响应,冻结用户界面。

所以,编码时对于耗时较长的运算我们不得不考虑UI线程的问题,《High Performance JavaScript》建议javascript操作耗时不应该超过100毫秒,那么这个100毫秒是如何得出来的呢?

我们来看下2个相关的研究:

1.1、Definitions of Response Time

引用:Robert B. Miller:《Response time in man-computer conversational

不同类型的响应以及响应延时,适用于不同的行为水平。响应时间是基于对心理依据的估计而定义的。Robert Miller在其研究中定义了17种不同类型的响应时间,有兴趣的同学可以细读。这里只列举2条比较重要的:

 Topic 1. Response to control activation (开关控制响应)

The click of the typewriter key, or the change in control force after moving a switch past a detent position are examples. They indicate responsiveness of the terminal as an object. This response should be immediate and perceived as a part of the mechanical action induced by the operator. Time delay: No more than 0.1 second.

开关类操作中,终端响应能力对于操作者应该是直接并可感知的。这类型的延时不应大于0.1秒。

the delay between depressing the key and the visual feedback should be no more than 0.1 to 0.2 seconds.
按键与可视反馈延时不应大于0.1~0.2秒。

Note that this delay in feedback may be far too slow for skilled keyboard users.
研究中同时提到,以上数值对于那些键盘高手还是很慢的。他们的预期值比一般人要高,所以以上数值只是一个普遍适用的数值。

Topic 13. Graphic response from light pen (光标图形响应)

Where the lines are drawn with deliberation by the user—relatively slowly as compared with slashing sketch strokes—a delay of up to 0.1 second seems to be acceptable. There must not be variability perceived by the user in this delay.
用户的光标输入延时,0.1秒是可被接受的。延时期间用户是动态可感知的。

The response delay in the image following the light pen may be as much as one second because the user is not tracing a line but positioning an image that, for him, is completed when his stylus touches the destination for the image.
在构图中,类似的延时1秒是可以被接受的。

1.2、Response Times: The 3 Important Limits

引用:Jakob Nielsen:《 Response Times: The 3 Important Limits

  • 0.1 second is about the limit for having the user feel that the system is reacting instantaneously, meaning that no special feedback is necessary except to display the result.
  • 1.0 second is about the limit for the user’s flow of thought to stay uninterrupted, even though the user will notice the delay. Normally, no special feedback is necessary during delays of more than 0.1 but less than 1.0 second, but the user does lose the feeling of operating directly on the data.
  • 10 seconds is about the limit for keeping the user’s attention focused on the dialogue. For longer delays, users will want to perform other tasks while waiting for the computer to finish, so they should be given feedback indicating when the computer expects to be done. Feedback during the delay is especially important if the response time is likely to be highly variable, since users will then not know what to expect.

响应时间:3条重要的限制

  • 0.1 senond : 0.1S,限制为达到用户感知系统瞬时响应,意味着无需特别反馈。
  • 1.0 senond : 1.0S,限制为用户思路不被打断,即使用户感觉到延时。通常情况下,在0.1S~1.0S间无需特别的反馈,但用户此次操作确实感觉到了兴趣上的损失。
  • 10 seconds : 10S,限制为用户在对话中的注意力极限;更长的延时会导致用户切换到其它任务以等待计算机完成当前任务。所以,在计算机完成当前任务前需要给予适当的反馈。如果响应时间是高度可变的话,延时期间的反馈是尤其重要的,因为用户不知道接下来将会发生什么。

实际工作中,很多任务都不可能在100ms内完成。很多复杂的运算(例如递归与迭代),都要占用UI线程,用户的其它 操作没有得到及时的响应,浏览器界面被冻结、栈溢出等情况时有发生。大部分浏览器在遇到长时间脚本运算时都会给予用户提示是否终止操作,对于很多用户来 说,选择终止操作绝对是占大多数的。

2、调用栈大小限制:

复杂的算法一般都会使用到递归,例如斐波那契数列(Fibonacci sequence),递归函数的执行会受到浏览器调用栈大小限制(Call Stack Limits)。

何为调用栈限制?《High Performance JavaScript》里面是这么解释的:

The amount of recursion supported by JavaScript engines varies and is directly related to the size of the JavaScript call stack. With the exception of Internet Explorer, for which the call stack is related to available system memory, all other browsers have static call stack limits. The call stack size for the most recent browser versions is relatively high compared to older browsers (Safari 2, for instance, had a call stack size of 100).
翻译过来就是:Javascript引擎所支持的递归数量与调用栈大小直接相关。IE除外,它的调用栈大小与系统内存相关,其它浏览器都有固定的调用栈大小限制。大多数现代浏览器的调用栈大小都比老版浏览器要高(例如Safari2,其调用栈大小为100)。


JavaScript call stack size in browsers(浏览器调用栈大小)

对浏览器的调用栈深浅可以作个简单的测试:

var i = 0;
function fn() {
    fn(i++);
}
try {
    fn();
} catch (e) {
    alert(i);
}

测试结果如下(我只拿了手头上有的浏览器作了个简单的测试,数据仅作参考):

Firefox 6.0 9015
Chrome 14 26176
Opera 11.51 32631
IE7/8 3064
IE6 1131

从数据来看,递归不是无限制的,不同浏览器的调用栈深浅差别是很大的。递归调用过程,系统会为每一层返回点开辟栈来存储,先是逐级扩展,再是收缩回溯,伴随递归次数的增多,消耗的资源也随之增多,最终可能造成栈溢出。所以在使用递归时,必须要有明确的递归结束条件,也叫递归出口。

对于重复的运算可以使用Memoization技术来缓存上一次运算结果,以减少重复运算带来的性能损失。Memoization技术主要是利用了散列表(或者叫键值对)来缓存运算结果,查询表比执行函数要快来达到性能优化的目的。

以下是 斐波那契数列(Fibonacci sequence) 的一个简单实现:

function fibonacci(n) {
    n = parseInt(n, 10);
    if (n < 2) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

( 以下数据基于IE8测试)

函数 调用次数(次) 耗时(ms)
fibonacci(10) 77 0
fibonacci(20) 1891 16
fibonacci(30) 2692537 2344

随着n的递增,调用次数与耗时递增明显。摒弃耗时不说,你会发现浏览器已经无法响应用户操作了,浏览器UI线程已经被阻塞了。fibonacci(40)执行的时候,IE还连续弹出“是否停止运行此脚本”提示。

使用Memoization技术后,实现的版本如下:

var fibonacci = (function() {
    var cache = [1, 1];
    var fib = function(n) {
        if (n > 1) {
            for (var i = cache.length; i <= n; i++) {
                cache[i] = cache[i - 1] + cache[i - 2];
            }
        }
        return cache[n - 1];
    };
    return fib;
})();

迭代取代了递归,没有了浏览器调用栈大小限制,性能上的提升是非常明显的。有兴趣的TX可以自己对比下,效率已经不是一个数量级的了。

3、长时间运行脚本限制

还是上面使用Memoization技术实现的fibonacci函数,执行fibonacci(10000000),由于运算时间长,触发了浏览器长时间运行脚本限制,UI线程被阻塞了。所以,迭代中有大规模运算,100ms内完成不了的任务可以进行拆分,让javascript短暂让出UI线程控制权,以执行其他任务。

要短暂让出UI线程控制权,可以使用setTimeout。

setTimeout的时间精度:
javascript中定时器是有时间精度的, IE9(非充电模式)、IE8及其以下版本的时间精度是15.6ms;IE9(充电模式)下是4ms;其他浏览器一般也是4ms,低于4ms会降低电池使用寿命。所以,setTimeout(fn, 0)并非马上执行的,其执行时机取决于时间精度。

为了解决setTimeout在不同浏览器的时间精度问题,W3C因此引入了新的setImmediate()函数。setImmediate与setTimeout类似,setImmediate会在UI线程空闲时将任务插入到队列并执行,我们不再需要关心时间精度的影响。并且,setImmediate执行起来比setTimeout(fn, 0)要快。

由于任务运行时间的不确定性,在迭代运算中,可以加上运算时间监控,决定此次迭代是否需要拆分任务。

我们来做一个简单的demo,0~n的累加运算:

// o~n的累加运算
var test = function(n, callback) {
    var result = 0;
    var i = 0;
    (function() {
        var st = +new Date();
        for (; i < n; i++) {
            if ((+new Date()) - st < 100) {
                result++;
            } else {
                setTimeout(arguments.callee, 0); // 运算时间差大于100ms时,中断运算,让出UI线程控制权
                return;
            }
        }
        callback && callback();
    })();
};
test(10000000, showResult); // 1千万次累加

由于额外的流程控制开销,setTimeout方式相对直接运算会消耗更多的时间,好处是UI线程不再阻塞,可以处理更多的任务。

除了setTimeout方式,将需要长时间运算的操作放到flash里面进行也是一种解决方案,避开javascript单一线程的影响。

Duff’s device(达夫设备):

Duff’s device是一种加速循环的技巧,其思想是尽可能减少循环的执行次数。

/**
 * Duff’s Device
 * http://home.earthlink.net/~kendrasg/info/js_opt/jsOptMain.html#duffsdevice
 */
var n = iterations / 8;
var caseTest = iterations % 8;
do {
    switch (caseTest) {
        case 0:
            testVal++;
        case 7:
            testVal++;
        case 6:
            testVal++;
        case 5:
            testVal++;
        case 4:
            testVal++;
        case 3:
            testVal++;
        case 2:
            testVal++;
        case 1:
            testVal++;
    }
    caseTest = 0;
} while (– n > 0 );

以上是Duff’s device的一个实现版本。

Duff’s device将一个大循环分成每次迭代8次的小循环,8的余数再执行一次小循环,以达到减少循环的次数。

HTML5 web workers:

随着HTML5技术的发展,在浏览器UI线程外运行javascript代码成为了可能。web workers提供了一个简单的方式让javascript代码在后台线程运行而不影响UI线程。每一个web workers间都是相互独立的,都在自己的线程中运行。

var worker = new Worker(‘my_task.js’);
worker.onmessage = function(event) { // This event handler will be called when the worker calls its own postMessage() function
    console.log(“Called back by the worker!\n”);
};
worker.postMessage(); // start the worker
worker.terminate(); // terminate a running worker

需要注意的一点是:在web workers内不能操纵DOM,可用于处理与UI线程无关的长时间运行脚本。

一些参考文档:

  1. [翻译]High Performance JavaScript(013) http://blog.csdn.net/situdesign/article/details/5635692
  2. 高性能Javascript【六】快速响应的用户界面 http://www.hotels2map.com/blog/2011/09/fast-interact-interface/Using
  3. yielding with setImmediate http://www.nczonline.net/blog/2011/09/19/script-yielding-with-setimmediate/
  4. Duff’s device http://jsperf.com/duffs-device
  5. web workers https://developer.mozilla.org/En/Using_web_workersScript