Javascript setTimeout & setInterval
有时我们并不想立即执行一个函数,而是等待特定一段时间之后再执行,目前有两种方式可以实现:
setTimeout
将函数的执行推迟到一段时间之后再执行。setInterval
让函数间隔一定时间周期性执行。
这两个方法并不存在于 JavaScript 的规范中。但是大多数运行环境都有内置的定时器,而且也提供了这两个方法的实现。目前来讲,所有浏览器,包括 Node.js 都支持这两个方法。
setTimeout
用法:
let timerId = setTimeout(func|code, delay[, arg1, arg2...])
参数说明:
func|code
:想要执行的函数或代码字符串。一般传入的都是函数,介于某些历史原因,代码字符串也支持,但是不建议使用这种方式。delay
:执行前的延时,以毫秒为单位。arg1
,arg2...
:要传入被执行函数(或代码字符串)的参数列表(IE9 以下不支持)。
在下面这个示例中,showAlert()
方法会在 1 秒后执行:
function Demo() { function showAlert() { alert('Hello'); } return <button onClick={() => setTimeout(showAlert, 1000)}>Click Me</button>; }
带参数的情况:
function Demo() { function showAlert(phrase1, phrase2) { alert(phrase1 + ', ' + phrase2); } return ( <button onClick={() => setTimeout(showAlert, 1000, 'Hello', 'World')}> Click Me </button> ); }
如果第一个参数位传入的是字符串,JavaScript 会自动为其创建一个函数。
所以这么写也是可以的:
setTimeout("alert('Hello')", 1000);
但是,毕竟这种方式并不推崇,所以建议还是用函数方式:
setTimeout(() => alert('Hello'), 1000);
clearTimeout
setTimeout
在调用时会返回一个定时器的编号(不同环境返回的不一定是正整数),接下来用 timerId
来取消调度。
let timerId = setTimeout(...);
clearTimeout(timerId);
在下面代码中,我们设定了一个定时器,紧接着取消了该定时器(中途反悔 了),所以最后什么也没发生:
let timerId = setTimeout(() => alert('never happens'), 1000);
alert(timerId); // 定时器 id
clearTimeout(timerId);
alert(timerId); // 还是那个 id 没变(并没有因为调度被取消了而变成 null)
这并不是清除定时器,而是终止其执行,有点类似 for 循环里面的 break 的作用。clearTimeout 也是类似。
从 alert
的输出来看,定时器 id 在浏览器中是一串数字,然而在其他运行环境下可能是别的东西。就比如 Node.js 返回的是一个定时器对象,这个对象包含一系列方法。
这个方法没有统一的规范定义,但也无伤大雅。针对浏览器环境,定时器在 HTML5 的标准中有详细描述,详见 WebApp APIs: Timers。
setInterval
setInterval
方法和 setTimeout
的用法是相同的:
let timerId = setInterval(func|code, delay[, arg1, arg2...])
所有参数的意义也是相同的,不过 setTimeout
只执行一次,setInterval
是每间隔一定时间周期性执行。
setTimeout 和 setInterval 共用一个编号池。
在同一个对象上(一个 window 或者 worker),setTimeout 或者 setInterval 在后续的调用不会重用同一个定时器编号。但是不同的对象使用独立的编号池。
clearInterval
想要阻止后续调用,我们需要调用 clearInterval(timerId)
。技术上,clearTimeout 和 clearInterval 可以互换。但是,为了避免混淆,不要混用取消定时函数。
下面的例子中,每间隔 2 秒就会输出一条消息。5 秒之后,输出停止:
function Demo() { function start() { // 每 2 秒重复一次 let timerId = setInterval(() => alert('tick'), 2000); // 5 秒之后停止 setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000); } return <button onClick={() => start()}>Start</button>; }
Chrome 在显示 alert/confirm/prompt
时,内部的定时器仍旧会继续滴答。所以,在执行以上代码时,如果在一定时间内没有关掉 alert
弹窗,那么在你关闭弹窗后,Chrome 会立即显示下一个 alert
弹窗(前提是距离上一次执行超过了 2 秒)。
clearInterval(timerId) 和 timerId = null 区别
clearInterval(timerId)
清除了 timer 指向的定时器。timerId = null
是修改 timerId
的指向,使 timerId
这个变量不指向某个定时器,然而并没有终止这个定时器的执行,定时器依旧在运行。
let timerId = setInterval(function () {
alert();
timerId = null;
}, 1000);
这段代码依然会不断运行。
周期性定时器
要想达到周期性调度有两种方式。
方式一:setInterval
function Demo() { const [time, setTime] = useState(); function start() { setInterval(() => setTime(new Date().getSeconds()), 2000); } return ( <> <button onClick={() => start()}>Start</button> <div>{time}</div> </> ); }
方式二:递归版 setTimeout
function Demo() { const [time, setTime] = useState(); function start() { let delay = 2000; setTimeout(function tick() { // (*) setTime(new Date().getSeconds()); setTimeout(tick, delay); }, delay); } return ( <> <button onClick={() => start()}>Start</button> <div>{time}</div> </> ); }
如果 (*)
没有用 setTimeout
的话,那么会第一次将会同步执行。
优点一:更灵活
递归版的 setTimeout
其实要比 setInterval
灵活的多,采用这种方式可以根据当前执行结果来安排下一次调用。
譬如,我们要实现一个服务,每间隔 5 秒向服务器请求数据,如果服务器过载了,那么就要降低请求频率,比如将间隔增加到 10, 20, 40 秒等:
let delay = 5000;
let timerId = setTimeout(function request() {
//...send request...
if (request failed due to server overload) {
// 下一次执行的间隔是当前的 2 倍
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
函数的实际调用间隔要比代码给出的间隔时间要短。因为 func
的执行时间抵消掉了一部分间隔时间。
如果
func
的执行时间超出了 100 毫秒呢?
这时候,JavaScript 引擎会等待 func
执行完,然后向定时器询问是否时间已到,如果是,那么立马再执行一次。极端情况下,如果函数每次执行时间都超过 delay
设置的时间,那么每次调用之间将毫无停顿,比如上面介绍 setInterval 的时候举的例子,用户停顿时间超过 2s 将会没有时间间隔就立马弹出下一个 alert。
再来看递归版 setTimeout
:
递归的 setTimeout
能确保固定的时间间隔(定时器降速的情况除外)。
这是因为下一次调用是在前一次调用完成时再调度的。
尽量递归版 setTimeout
,它比 setInterval
用起来更加灵活,同时也能保证每一轮执行的最小时间间隔。
setTimeout(fn, 0)
还有一种特殊的用法:setTimeout(fn, 0)
,即 0 延时调度,可以用来安排在当前代码(同步宏任务和微任务)执行完时,需要尽快执行的函数(异步宏任务)。
下面例子中,代码会先输出 "Hello",然后紧接着输出 "World":
setTimeout(() => alert('World'), 0);
alert('Hello');
给浏览器渲染的机会
setTimeout(fn, 0)
可以使得进程繁忙时也能让浏览器抽身做其它事情,例如绘制进度条。
我们知道浏览器在所有脚本执行完后,才会开始“重绘(Repaint)”过程。如果运行一个非常耗时的函数,即便在这个函数中改变了文档内容,除非这个函数执行完,那么变化是不会立刻反映到页面上的。
以下是一个示例:
<div id="progress"></div>
<script>
let i = 0;
let progress = document.getElementById('progress');
function count() {
for (let j = 0; j < 1e6; j++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
运行后会发现,i
值只在整个计数过程完成后才显示。
接下来用 setTimeout
对任务进行分割,这样就能在每一轮运行的间隙观察到变化了,效果要好得多:
<div id="progress"></div>
<script>
let i = 0;
let progress = document.getElementById('progress');
function count() {
// 每次只完成一部分 (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e9) {
setTimeout(count, 0);
}
}
count();
</script>
之前在《宏任务和微任务》中也提到执行的大致流程:
宏任务(同步任务) → 微任务 → 渲染 → 宏任务(异步任务)
这样渲染会在不断进行中,就可以观察到 <div>
里 i
值的增长过程了。