【简介】执行 JavaScript 的过程中,页面处于不可响应的状态,因此,构建我们需要通过一些方法,来处理需要长时间运行脚本的情况下,页面也能及时作出响应,提高网页的友好型。
1. 浏览器 UI 线程(The Browser UI Thread)
「浏览器 UI 线程」:用于执行 JavaScript 和更新用户界面的进程。UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么是执行 UI 更新(包括我们前面提到的重绘和重排)。
考虑一个简单的交互,点击按钮,屏幕上显示一条信息:
<html>
<head>
<title>UI 线程</title>
<script type="text/javascript" src="jquery-1.8.1.min.js"></script>
<body>
<button onclick="handleClick()">点击</button>
<script type="text/javascript">
function handleClick() {
var div = document.createElement("div");
div.innerHTML = "Clicked!";
document.body.appendChild(div);
}
</script>
</body>
</html>
用于点击按钮,会触发两个任务到队列中。第一个是点击按钮时,按钮反馈的「被点击」的效果,有一个更新按钮的 UI 的任务。第二个任务是执行 handleClick 方法中的代码。
在运行 handleClick 方法的过程中,handleClick 会创建一个新的 div 元素,并把它附加在 body 元素末尾,这实际上又执行了一次更行页面 UI 的过程。
当所有 UI 线程任务都执行完毕,进程进入空闲状态,并等待更多任务加入队列。空闲状态是理想的。因为用户的所有交互都会立刻触发 UI 更新。如果用户试图在任务运行期间与页面交互,不仅没有及时的 UI 更新,甚至可能新的 UI 更新任务都不会被创建并加入队列。事实上,大多数浏览器在 JavaScript 运行时会停止把新任务加入 UI 线程的队列中,也就是 JavaScript 任务必须尽快结束,以避免对用户体验造成不良影响。
1-1. 浏览器限制(Browser Limits)
浏览器会主动限制 JavaScript 任务的运行时间,这是为了确保某些恶意代码不能通过永不停止的秘籍操作锁住用户的浏览器或计算机。这种限制分两种:调用栈大小限制和长时间运行脚本限制。
事实上,为了更好的用户体验,JavaScript 代码的运行时间应当远远小于浏览器的限制。如果界面子啊100毫秒内响应用户输入,用户会认为自己在「直接操纵界面中的对象」。如果超过 100 毫秒,用户就会感觉失去了对界面的控制。
所以,最好的方法是,限制所有的 JavaScript 任务在 100 毫秒或更短的时间内完成,以达到良好响应。
2. 使用定时器让出时间片段(Yielding with Timers)
对于一个功能越来越复杂的应用,限制所有的 JavaScript 任务在 100 毫秒或更短的时间内完成,有的时候是不切实际的。因此,这种情况下,最理想的方法是让出 UI 线程的控制权,使得 UI 可以更新。
2-1. 定时器基础(Timer Basics)
function greeting() {
alert("Hello World");
}
setTimeout(greeting, 2500);
setInterval(greeting,2500);
设置定时器有两个方法 setTimeout() 和 setInterval() 。它们都接收两个参数:要执行的函数(这里是 greeting)和执行前等待的时间(2500ms,即2.5秒)。区别在于 setTimeout() 只执行一次,而 setInterval() 则创建一个周期性重复运行的定时器。注意,这里的等待时间 2.5 秒,是指定时器调用时开始计时,在 2.5 秒后加入队列。而不是 2.5 秒后开始执行,因为有可能在 2.5 秒的时候,仍然有任务在执行,那么定时器中的任务会延后运行。
利用定时器的这一特性,如果已经处在队列中的任务的执行时间超过了定时器设置的时间,而定时器任务已经在这之前添加进队列,就可以模拟出不延迟的任务切换效果。
2-2. 使用定时器处理数组(Array Processing with Timer)
思考下面一个循环模式:
for (var i = 0, len = items.length; i < len; i++) {
process(items[i]);
}
其中,items 是一个数组,process() 是一个处理函数。这类循环结构运行时间过长的原因一般取决于 process() 的复杂度和 items 的大小。
比如,我们看这样一个循环:
for(var i=0;i<5000;i++){
document.body.innerHTML += "hello";
}
在这段 JavaScript 脚本中,执行的过程会触发「向 body 中添加文本的任务」的 UI 更新的任务,但这个 UI 任务不会在调用到「document.body.innerHTML += “hello”+i;」就执行,而是添加到任务队列中,直到这段 JavaScript 脚本执行完成,才会进行 UI 任务。而执行这个循环,需要几秒钟的时间,这就导致了用户感觉浏览器失去了响应,导致界面十分不友好。
对于这类 process() 的处理过于复杂,items 数组的长度过长,则可以考虑是否可以把循环的工作分解到一系列定时器中。一种基本的异步代码模式如下:
setTimeout(function() {
// 取得数组的一个元素处理
process(todo.shift());
// 如果还有需要处理的元素,创建另一个定时器
if (todo.length > 0) {
setTimeout(arguments.callee, 25);
} else {
// 没有的话,调用回调函数
callback(items);
}
}, 25);
这段代码中,首先我们使用 concat 复制一个数组(操作这个复制的数组,保留原数组)。然后使用 shift 函数取出元素(shift 函数的作用是删除数组中的第一个元素,函数的返回是删除掉的这个元素),然后判断 todo 数组中是否还元素(数组的 length 长度不为零),有的话继续执行这个函数(arguments.callee 返回调用参数的这个函数);没有的话,调用 callback 回调函数。
我们将这段代码用函数封装起来,方便调用:
function processArray(items, process, callback) {
var todo = items.concat();
setTimeout(function(){
process(todo.shift());
if (todo.length >0) {
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);
}
processArray 函数接收三个参数:待处理的数组,对每一个数组元素项调用的函数,处理完成后运行的回调函数。用法如下:
var items = [123, 789,234, 543, 626, 854, 252, 475, 547, 568];
function outputValue(value) {
console.log(value);
}
processArray(items, outputValue, function () {
console.log("Done!");
})
我们再来处理一下先前的例子:
var items = new Array(5000);
function outputValue(value) {
document.body.innerHTML += "hello";
}
processArray(items, outputValue, function() {
alert("done!");
});
我们可以看到,虽然这个执行过程仍然很长,但经过异步处理,UI 更新的任务能够在定时器中间执行,使得页面能够得到更新,使得页面看起来比较友好。
这样一个比较实际的例子是我们向服务器请求了一个超长的新闻目录列表,如果我们直接执行:
for(var i=0,len=msg.length;i<len;i++){
process(msg[i]);
}
会导致新闻列表页面卡顿,但通过这样的异步处理,就能很好的解决这个问题。
2-3. 分割任务(Splitting Up Tasks)
如果一个函数很长,我们可以把它拆分成一系列子函数来处理,比如:
// 修改文档
function savaDocument(id) {
// 打开文档
openDocument(id);
// 向文档中写入内容
writeText();
// 关闭文档
closeDocument(id);
// 将成功信息更行到界面
updateUI(id);
}
然后我们可以把这些小的任务,放到数组中,使用定时器中调用:
function savaDocument(id) {
var tasks = [openDocument, writeText, closeDocument, updateUI];
setTimeout(function() {
var task = tasks.shift();
task(id);
if (tasks.length > 0) {
setTimeout(arguments.callee, 25);
}
}, 25);
}
通过这样的方式,我们就变成了前面数组的处理方式,能够构建起快速响应的脚本。
同样我们可以对这样一个功能函数进行封装:
function mutistep(steps, args, callback) {
// 克隆数组
var tasks = steps.concat();
setTimeout(function() {
// 执行下一个任务
var task = tasks.shift();
task.apply(null, args || []);
// 检查是否还有其他任务
if (tasks.length > 0) {
setTimeout(arguments.callee, 25);
} else {
callback();
}
}, 25);
}
这个函数接收三个参数:由待执行函数组成的数组,为每个函数运行时提供参数的数组,以及处理结束时调用的回调函数。例如:
function savaDocument(ids) {
var tasks = [openDocument, writeText, closeDocument, updateUI];
mutistep(tasks, [ids], function() {
alert("文件保存成功");
});
}
2-4. 记录代码运行时间(Timed Code)
有时,每次执行一个任务的效率并不高,或者说,将每个任务都放在一个定时器中的效率并不高。考虑我们上面执行5000次循环的情况,如果没处理一次需要 1 毫秒,而每个定时器只处理一个循环,且在两次之间产生25 毫秒的延迟,这意味着处理整个循环需要 (25+1) * 5000 = 130 000 毫秒(或130秒)。如果一次批处理50个,每批之间有 25 毫秒的延迟,这个过程变成 (5000/50) * 25+5000=7500 毫秒(或7.5秒),而且用户不会察觉到界面阻塞,因为最长的脚本运行时间只持续了 50 毫秒。
按照我们前面所说的,JavaScript 可以持续运行的最长时间为 100 毫秒,那么通过这样的方式,我们可以让 JavaScript 代码持续运行的时间在 50 毫秒以内。
在介绍批处理技术之前,我们先来了解一下怎么跟踪代码的运行时间,这也是大多数 JavaScript 分析工具的工作原理:
var start = +new Date(),
stop;
someLongProcess();
stop = +new Date();
if (stop-start <50) {
alert("Just about right.");
} else {
alert("Taking too long.");
}
记录下 start 开始和 stop 结束的时间差,就可以记录代码的运行时间。其中加号(+)可以将 Date 对象转换成数字。
接下来我们就在原来的 processArray 函数中添加一个时间检测机制来改进这个函数,使得每个定时器能够处理多个任务,而每批任务由不超过50 ms 的时间:
function timedProcessArray(items, process, callback) {
var todo = items.concat();
setTimeout(function() {
var start = +new Date();
do {
process(todo.shift());
} while (todo.length>0 && (+new Date()-start<50));
if (todo.length>0) {
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
},25);
}
这个函数中添加了一个 do-while 循环,它在每个数组元素处理完后检测执行时间。
我们回过头来看我们的5000次循环:
var items = new Array(5000);
function outputValue(value) {
document.body.innerHTML += "hello";
}
timedProcessArray(items, outputValue, function() {
alert("done!");
});
可以发现,很明显在不影响页面快速响应的基础上,加载完成快了很多。
[1] 1. setTimeout与setInterval 定时器与异步循环数组
附: 欢迎大家关注我的新浪微博 - 一点编程,了解最新动态 。