原文链接:http://javascript.info/settimeout-setinterval,translate with ❤️ by zhangbao.
我们有时决定不现在执行函数,而是等一段时间。这称为“调度调用”。
有两个方法供使用:
setTimeout 允许在指定的时间间隔之后触发一次函数。
setInterval 允许在指定的时间间隔定期触发函数。
setTimeout
语法:
let timerId = setTimeout(func|code, delay[, arg1, arg2...])
参数:
func|code``
要执行的函数和字符串类型的代码。通常是一个函数。因为历史原因,字符串代码也可以被传递,但是并不推荐。
delay
执行前的延迟时间,以毫秒计(1000ms = 1秒)。
arg1,arg2...
传递给函数的参数(IE9- 不支持)。
例如,下面代码在 1 秒后调用 sayHi():
function sayHi() {
alert('Hello');
}
setTimeout(sayHi, 1000);
携带参数:
function sayHi(phrase, who) {
alert( phrase + ', ' + who );
}
setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John
如果第一个参数是字符串,那么 JavaScript 就会用它创建函数。
就是说,下面也是可以的:
setTimeout("alert('Hello')", 1000);
但是使用字符串并不推荐,用函数吧:
setTimeout(() => alert('Hello'), 1000);
注:我们只传递函数,不执行
许多开发者经常犯的错误是在函数后面使用 () 调用它,这不需要!
// 错误的!
setTimeout(sayHi(), 1000);
这不行,因为 setTimeoiut 期望的是一个函数引用。sayHi() 是执行函数,就是把函数执行之后的结果传递给 setTimeout。在我们这个例子里,sayHi() 返回的结果是 undefined(函数没有返回值),所以没有啥会进入调度。
使用 clearTimeout 取消定时器
调用 setTimeout 后,返回的是“定时器 ID”timerId,我们可以用它去取消定时器。
语法如下:
let timerId = setTimeout(...);
clearTimeout(timerId);
在下面的代码中,我们调度函数,然后取消它(改变了注意)。结果,什么也没发生:
let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // 定时器 id
clearTimeout(timerId);
alert(timerId); // 相同的 id 值(在取消之后没有变成 null)
正如我们从警报输出中看到的,在浏览器中,计时器标识符是一个数字。在其他环境中,这可能是另一种情况。例如,Node.js 返回一个带有附加方法的计时器对象。
同样,这些方法没有通用规范,所以这没什么错。
对于浏览器来说,计时器是在 HTML5 标准的计时器部分中描述的。
setInterval
setInterval 方法有着与 setTimeout 一样的语法:
let timerId = setInterval(func|code, delay[, arg1, arg2...])
所有的参数都是一个意思。但是不像 setTimeout,他执行函数可不是一次,而是在指定间隔去定时触发的。
为了阻止进一步调用,我们应该调用 clearInterval(timerId)。
下面的例子将每2秒显示一条消息。5秒后,输出停止:
// 每 2 秒重复一次
let timerId = setInterval(() => alert('tick'), 2000);
// 5 秒钟后停止
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);
注:在 Chrome/Opera/Safari 中,弹出的模态框会阻止时间进行。
在 IE 和 FireFox 浏览器中,当显示 alert/confirm/prompt 弹框时,内部计时器会继续“计时”,但在 Chrome/Opera/Safair 中,则会阻止计时器计时。
如果你执行上面的代码,不关闭 alert 框的时候,在 FireFox/IE 中,下一个 alert 会立即弹出(距离上次调用已经过去两秒了),在 Chrome/Opera/Safari 中,得等个 2 秒后,才会弹出(在 alert 期间不会继续去计时)。
递归调用 setTimeout
有两种方法可以定期运行函数。
一种是 setInterval,还有一个递归调用 setTimeout,像这样:
/** 不使用:
let timerId = setInterval(() => alert('tick'), 2000);
*/
// 而是用
let timerId = setTimeout(function tick() {
alert('tick');
timerId = setTimeout(tick, 2000); // (*)
}, 2000);
上面的 setTimeout 会在 (*) 处之后立即调用下一次调度。
递归调用 setTimeout 是一种比 setInterval 更加灵活的方式。通过这种方式,下一个调用可能会以不同的方式调度,这取决于当前的结果。
例如,我们需要编写一个服务,它每5秒钟向服务器发送一个请求,请求数据,但是如果服务器超载,它应该将间隔增加到 10、20、40 秒。
这是伪代码:
let delay = 5000;
let timerId = setTimeout(function request() {
...send request...
if (request failed due to server overload) {
// 下次调用时,增加间隔
delay *= 2;
}
timerId = setTimeout(request, delay);
}, delay);
如果我们经常有一个非常耗费 CPU 的任务,那么我们就可以测量执行所花费的时间,并尽早计划下一次的调用。
递归调用 setTimeout 保证了执行之间的延迟,而 setInterval 不会。
让我们比较两个代码片段。第一个使用 setInterval:
let i = 1;
setInterval(function() {
func(i);
}, 100);
第二种方法使用递归 setTimeout:
let i = 1;
setTimeout(function run() {
func(i);
setTimeout(run, 100);
}, 100);
对 setInterval 来说,内部的调度器会每隔 100 毫秒就执行下 func(i):
注意到了吗?
使用 setInterval 调用 func 时,每次函数之间的延迟比 100 毫秒要小!
这很正常,因为时间间隔被中间的函数执行时间“消费了”。
还有种可能,就是 func 函数的执行时间可能比 100 毫秒还要长呢。
在这种情况下,引擎等待 func 完成,然后检查调度程序,如果时间到了,就立即运行它。
在边缘情况下,如果函数的执行时间总是比延迟时间长,那么调用就会在没有暂停的情况下发生。
这是递归 setTimeout 的过程:
递归 setTimeout 保证了固定的延迟(这里是100 ms)。
这是因为在前一个调用的末尾有一个新的调用。
注:垃圾收集
当一个函数在 setinterval/settimeout 中传递时,会创建一个内部引用,并保存在调度程序中,它可以避免函数即使在没有其他的引用的情况下,也不会被被垃圾收集器清除。
// 函数保留在内存里知道调度程序调用它
setTimeout(function() {...}, 100);
于 setInterval 来说,函数在内存中停留,直到 clearInterval 被调用。
有一个副作用。函数引用外部词汇环境,因此,当它活着时,外部变量也会存在。它们可能比函数本身占用更多的内存。所以当我们不再需要调度函数时,最好取消它,即使它很小。
setTimeout(…, 0)
有一个特殊用例:setTimeout(func, 0)。
这将尽快安排 func 的执行。但是调度器只有在当前代码完成之后才会调用它。
以这个函数被调度在当前代码之后运行。换句话说,就是异步。
例如,这个输出“Hello”,然后立即“world”:
setTimeout(() => alert("World"), 0);
alert("Hello");
第一行“在0ms之后将调用放入日历中”。但是,在当前代码完成之后,调度程序只会“检查日历”,所以“Hello”先打印出来,之后打印“world”。
分离强 CPU 消耗任务
这里有一个技巧,使用 setTimeout 来分离消耗 CPU 的任务。
例如,语法高亮脚本(用于在页面上着色代码示例)是非常耗费 CPU 的。为了高亮代码,它执行分析、创建许多有色元素,并将它们添加到文档中——对于大文本来说就比较耗费资源。它甚至可能会导致浏览器“挂起”,这是不可接受的。
因此我们可以将长文本分离成一个个片段。使用 setTimeout(…, 0) 先执行前 100 行,然后在执行后续的 100 行代码等等。
为了清晰起见,我们来看一个更简单的例子。我们有一个函数从 1 计数到 1000000000。
如果你执行这个脚本,CPU 就会挂起。在服务器端 JS 上会表现的很明显,如果实在浏览器中执行,尝试点击页面中的按钮不会有响应——整个 JavaScript 脚本都会暂停,在它完成之前,没有其他的操作可以进行。
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
浏览器时是甚至会显示“脚本花费太长时间”的警告(但希望它不会,因为这个数字不是很大)。
我们内嵌 setTimeout 函数来分离任务:
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count, 0); // schedule the new call (**)
}
}
count();
现在,浏览器用户界面在“计数”过程中是完全功能的。
我们在 (*) 处完成部分工作:
第一次执行:i=0…1000000。
第二次执行:i=1000001…2000000。
以此类推,while 检查 i 能否被 1000000 整除。
如果不是的话,就继续执行 (*) 处执行下一次调用进行工作。
在每次 count 执行期间,JavaScript 得以有“喘息”的机会去做别的事情,响应用户的其他操作。
值得注意的是,这两种变体都是通过 setTimeout 将作业分割开——在速度上不相上下。总的计数时间没有太大的差别。
为了让他们更接近,让我们做一个改进。
我们移动调度在开始时执行 count():
let i = 0;
let start = Date.now();
function count() {
// move the scheduling at the beginning
if (i < 1e9 - 1e6) {
setTimeout(count, 0); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
现在,当我们开始 count() 并且知道我们需要计算更多 count 时,我们会在做作业之前立即安排它。
如果你运行它,很容易注意到它花费的时间要少得多。
⚠️浏览器内嵌套计时器的最小延迟
在浏览器中,内嵌计时器执行频率是有限制的。HTML 标准里说道:“在五个嵌套的计时器之后,这个间隔至少需要 4 毫秒。”
让我们用下面的例子来演示它的含义。setTimeout 调用在 0ms 之后重新调度自己。
每一个调用都能记住在 times 数组中前一个调用的真实时间。真正的延迟是什么样子的?让我们来看看:
let start = Date.now();
let times = [];
setTimeout(function run() {
times.push(Date.now() - start); // remember delay from the previous call
if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
else setTimeout(run, 0); // else re-schedule
}, 0);
// an example of the output:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100
首先计时器会立即执行(就像在规范里描述的一样),然后延迟就开始发挥作用了,我们会看到 9, 15, 20, 24…。
这种限制来自远古时代,许多剧本都依赖于它,所以它存在于历史原因。
对于服务器端 JavaScript 来说,这种限制并不存在,并且存在其他方法来安排即时的异步作业。像 Node.js 的 process.nextTick 和 setImmediate。所以这个概念只针对于浏览器。
允许浏览器渲染
浏览器脚本的另一个好处是,它们可以向用户显示进度条或其他东西。这是因为浏览器通常在脚本完成后进行所有的“重新绘制”。
所以如果我们做一个巨大的函数,即使它改变了一些东西,这些变化也不会在文档中反映出来。
这是 demo:
<div id="progress"></div>
<script>
let i = 0;
function count() {
for (let j = 0; j < 1e6; j++) {
i++;
// put the current i into the <div>
// (we'll talk more about innerHTML in the specific chapter, should be obvious here)
progress.innerHTML = i;
}
}
count();
</script>
如果您运行它,那么对 i 的更改将在整个计数结束后显示出来。
如果我们使用 setTimeout 将其分割成块,那么在运行之间应用更改,这样看起来更好:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// 繁重任务分隔成一个个小任务 (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e9) {
setTimeout(count, 0);
}
}
count();
</script>
现在,
总结
setInterval(func, delay, …args) 和 setTimeout(func, delay, …args) 允许让函数在 delay 毫秒之后执行一次或者定期规律执行。
取消执行,我们应该调用 clearInterval/clearTimeout 来清除对应 setInterval/setTimeout 返回的值。
嵌套 setTimeout 是比 setInterval 更加灵活的一个可选方案。他能保证两次执行之间的最小时间。
0 暂停(Zero-timeout)调度 setTimeout(…, 0) 用来“尽可能快的,在当前代码完成之后”调度的意思。
一些使用 setTimeout(…, 0) 的场景:
分离耗费 CPU 的任务成一个个小任务,避免脚本“挂起”。
在进程进行的时候让浏览器做其他的事情(绘制进度条)。
请注意,所有的调度方法都不能保证准确的延迟,我们不应该在调度代码中依赖它。
例如,浏览器内的计时器可能会慢下来,原因有很多:
CPU 超载了。
浏览器选项卡在后台模式中。
笔记本在充电。
所有这些可能会增加最小的计时器分辨率(最小的延迟)到300毫秒,甚至1000毫秒,这取决于浏览器和设置。
(完)