理解setTimeout和setInterval
前面的话
很长时间以来,定时器一直是JavaScript动画的核心技术。但是,关于定时器,人们通常只了解如何使用setTimeout()
和setInterval()
,对它们的内在运行机制并不理解,对于与预想不同的实际运行状况也无法解决。本文将详细介绍定时器的相关内容。
setTimeout()
setTimeout()
方法用来指定某个函数或字符串在指定的毫秒数之后执行。它返回一个整数,表示定时器的编号,这个值可以传递给clearTimeout()
用于取消这个函数的执行。
以下代码中,控制台先输出0
,大概过1000ms
即1s
后,输出定时器setTimeout()
方法的返回值1
。
var Timer = setTimeout(function(){ console.log(Timer); },1000); console.log(0);
也可以写成字符串参数的形式,由于这种形式会造成JavaScript引擎两次解析,降低性能,故不建议使用
var Timer = setTimeout('console.log(Timer);',1000); console.log(0);
如果省略setTimeout
的第二个参数,则该参数默认为0
。
以下代码中,控制台出现0
和1
,但是0
却在前面,后面会解释这个疑问
var Timer = setTimeout(function(){ console.log(Timer); }); console.log(0);
实际上,除了前两个参数,setTimeout()
方法还允许添加更多的参数,它们将被传入定时器中的函数中。
以下代码中,控制台大概过1000ms
即1s
后,输出2
,而IE9-浏览器只允许setTimeout
有两个参数,不支持更多的参数,会在控制台输出NaN
:
setTimeout(function(a,b){ console.log(a+b); },1000,1,1);
可以使用IIFE传参来兼容IE9-浏览器的函数传参:
setTimeout((function(a,b){ return function(){ console.log(a+b); } })(1,1),1000);
或者将函数写在定时器外面,然后函数在定时器中的匿名函数中带参数调用:
function test(a,b){ console.log(a+b); } setTimeout(function(){ test(1,1); },1000);
注意:IE8-浏览器不允许向定时器中传递事件对象event,如果要使用事件对象中的某些属性,可以将其保存在变量中传递进去
div.onclick = function(e){ e = e || event; var type = e.type; setTimeout(function(){ console.log(type); //click console.log(e.type); //报错 }) }
this指向
在this机制系列已经详细介绍过this
指向的4种绑定规则,由于定时器中的this
存在隐式丢失的情况,且极易出错,因此在这里再次进行说明:
var a = 0;function foo(){ console.log(this.a); }; var obj = { a : 2, foo:foo } setTimeout(obj.foo,100); // => 0 //等价于 var a = 0; setTimeout(function foo(){ console.log(this.a); },100); // => 0
若想获得obj
对象中的a属性值,可以将obj.foo
函数放置在定时器中的匿名函数中进行隐式绑定:
var a = 0;function foo(){ console.log(this.a); }; var obj = { a : 2, foo:foo } setTimeout(function(){ obj.foo(); },100); // => 2
或者也可以使用bind
方法将foo()
方法的this
绑定到obj
上:
var a = 0;function foo(){ console.log(this.a); }; var obj = { a : 2, foo:foo } setTimeout(obj.foo.bind(obj),100);//2
clearTimeout()
setTimeout
函数返回一个表示计数器编号的整数值,将该整数传入clearTimeout
函数,取消对应的定时器:
//过100ms后,控制台输出setTimeout()方法的返回值1 var Timer = setTimeout(function(){ console.log(Timer); },100);
于是可以利用这个值来取消对应的定时器:
var Timer = setTimeout(function(){ console.log(Timer); },100); clearTimeout(Timer);
或者直接使用返回值作为参数:
var Timer = setTimeout(function(){ console.log(Timer); },100); clearTimeout(1);
一般来说,setTimeout
返回的整数值是连续的,也就是说,第二个setTimeout
方法返回的整数值比第一个的整数值大1:
//控制台输出1、2、3 var Timer1 = setTimeout(function(){ console.log(Timer1); // => 1 },100); var Timer2 = setTimeout(function(){ console.log(Timer2); // => 2 },100); var Timer3 = setTimeout(function(){ console.log(Timer3); // => 3 },100);
setInterval()
setInterval
的用法与setTimeout
完全一致,区别仅仅在于setInterval
指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行:
<button id="btn">0</button> <script> var timer = setInterval(function(){ btn.innerHTML = Number(btn.innerHTML) + 1; },1000); btn.onclick = function(){ clearInterval(timer); btn.innerHTML = 0; } </script>
注,我将用作者的示例代码修改了一下,但效果之类的都是一样:
<button id="btn">0</button>
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html, body { margin:0; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%, #bb6dec 100%); } button { color: #fff; font-size: 4vw; padding: 20px 60px; outline: none; background-color: #27ae60; border: none; border-radius: 5px; box-shadow: 0 9px #95a5a6; text-shadow: 2px 2px 0 rgba(0, 0, 0, .25), -2px -2px 0 rgba(0, 0, 0, .25); cursor: pointer; } button:hover { background-color: #2ecc71; } button:active { background-color: #2ecc71; box-shadow: 0 5px #95a5a6; transform: translateY(4px); }
const btn = document.getElementById('btn') let timer = setInterval(() => { btn.textContent = parseInt(btn.textContent) + 1 }, 1000) btn.addEventListener('click',() => { clearInterval(timer) btn.textContent = 0 }, false)
HTML5标准规定,setTimeout
的最短时间间隔是4ms
;setInterval
的最短间隔时间是10ms
,也就是说,小于10ms
的时间间隔会被调整到10ms
。
大多数电脑显示器的刷新频率是60HZ
,大概相当于每秒钟重绘60
次。因此,最平滑的动画效的最佳循环间隔是1000ms/60
,约等于16.6ms
。
为了节电,对于那些不处于当前窗口的页面,浏览器会将时间间隔扩大到1000ms
。另外,如果笔记本电脑处于电池供电状态,Chrome和IE10+浏览器,会将时间间隔切换到系统定时器,大约是16.6ms
。
运行机制
下面来解释前面部分遗留的疑问,为什么下面代码的控制台结果中,0
出现在1
的前面呢?
setTimeout(function(){ console.log(1); }); console.log(0);
实际上,把setTimeout
的第二个参数设置为0s
,并不是立即执行函数的意思,只是把函数放入代码队列:
在下面这个例子中,给一个按钮btn
设置了一个事件处理程序。事件处理程序设置了一个250ms
后调用的定时器。点击该按钮后,首先将onclick
事件处理程序加入队列。该程序执行后才设置定时器,再有250ms
后,指定的代码才被添加到队列中等待执行:
btn.onclick = function(){ setTimeout(function(){ console.log(1); },250); }
如果上面代码中的onclick
事件处理程序执行了300ms
,那么定时器的代码至少要在定时器设置之后的300ms
后才会被执行。队列中所有的代码都要等到JavaScript进程空闲之后才能执行,而不管它们是如何添加到队列中的:
如图所示,尽管在255ms
处添加了定时器代码,但这时候还不能执行,因为onclick
事件处理程序仍在运行。定时器代码最早能执行的时机是在300ms
处,即onclick
事件处理程序结束之后。
setInterval()的问题
使用setInterval()
的问题在于,定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而JavaScript引擎对这个问题的解决是:当使用setInterval()
时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。
但是,这样会导致两个问题:
- 某些间隔被跳过;
- 多个定时器的代码执行之间的间隔可能比预期的小
假设,某个onclick
事件处理程序使用setInterval()
设置了200ms
间隔的定时器。如果事件处理程序花了300ms
多一点时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过某间隔的情况:
例子中的第一个定时器是在205ms
处添加到队列中的,但是直到过了300ms
处才能执行。当执行这个定时器代码时,在405ms
处又给队列添加了另一个副本。在下一个间隔,即605ms
处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中
迭代setTimeout
为了避免setInterval()
定时器的问题,可以使用链式setTimeout()
调用:
setTimeout(function fn(){ setTimeout(fn,interval); },interval);
这个模式链式调用了setTimeout()
,每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()
调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行
使用setInterval()
<div id="myDiv" style="height: 100px;width: 100px;background-color: pink;position:absolute;left:0;"></div> <script> myDiv.onclick = function(){ var timer = setInterval(function(){ if(parseInt(myDiv.style.left) > 200){ clearInterval(timer); return false; } myDiv.style.left = parseInt(myDiv.style.left) + 5 + 'px'; },16); } </script>
<div id="myDiv" style="left: 0px;"></div>
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myDiv { height: 100px; width: 100px; background-color: darken(#f09, 30%); position: absolute; left: 0px; top: 50%; transform: translate(0, -50%); }
const myDiv = document.getElementById('myDiv') myDiv.addEventListener('click', () => { let Timer = setInterval(() => { if(parseInt(myDiv.style.left) > 200) { clearInterval(Timer) return false } myDiv.style.left = parseInt(myDiv.style.left) + 5 + 'px' },16) }, false)
使用链式setTimeout()
<div id="myDiv" style="height: 100px;width: 100px;background-color: pink;position:absolute;left:0;"></div> <script> myDiv.onclick = function(){ setTimeout(function fn(){ if(parseInt(myDiv.style.left) <= 200){ setTimeout(fn,16); }else{ return false; } myDiv.style.left = parseInt(myDiv.style.left) + 5 + 'px'; },16); } </script>
<div id="myDiv" style="left: 0px;"></div>
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myDiv { height: 100px; width: 100px; background-color: darken(#f09, 30%); position: absolute; left: 0px; top: 50%; transform: translate(0, -50%); }
const myDiv = document.getElementById('myDiv') myDiv.addEventListener('click', () => { setTimeout(function fn() { if(parseInt(myDiv.style.left) <= 200) { setTimeout(fn, 16) } else { return false } myDiv.style.left = parseInt(myDiv.style.left) + 5 + 'px' }, 16) }, false)
作用
数组分块
数组分块是一种使用定时器分割循环的技术,为要处理的项目创建一个队列,然后使用定时器取出下一个要处理的项目进行处理,接着再设置另一个定时器。
在数组分块模式中,array
变量本质上就是一个“待办事宜”列表,它包含了要处理的项目。使用shift()
方法可以获取队列中下一个要处理的项目,然后将其传递给某个函数。如果在队列中还有其他项目,则设置另一个定时器,并通过arguments.callee
调用同一个匿名函数:
function chunk(array,process,context){ setTimeout(function(){ //取出下一个条目并处理 var item = array.shift(); process.call(context,item); //若还有条目,再设置另一个定时器 if(array.length > 0){ setTimeout(arguments.callee,100); } },100); } var data = [1,2,3,4,5,6,7,8,9,0];function printValue(item){ var div = document.getElementById('myDiv'); div.innerHTML += item + '<br>'; } chunk(data.concat(),printValue);
数组分块的重要性在于它可以将多个项目的处理在执行队列上分开,在每个项目处理之后,给予其他的浏览器处理机会运行,这样就可能避免长时间运行脚本的错误。
一旦某个函数需要花50ms以上的时间完成,那么最好看看能否将任务分割为一系列可以使用定时器的小任务。
函数节流
浏览器中某些计算和处理要比其他的昂贵很多。例如,DOM操作比起非DOM交互需要更多的内存和CPU时间。连续尝试进行过多的DOM相关操作可能会导致浏览器挂起,有时候甚至会崩溃。尤其在IE中使用onresize
事件处理程序的时候容易发生,当调整浏览器大小的时候,该事件会连续触发。在onresize
事件处理程序内部如果尝试进行DOM
操作,其高频率的更改可能会让浏览器崩溃。
函数节流背后的基本思想是指,某些代码不可以在没有间断的情况连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行。其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行。
var processor = { timeoutId: null, //实际进行处理的方法 performProcessing: function(){ //实际执行的方法 }, //初始处理调用的方法 process: function(){ clearTimeout(this.timeoutId); var that = this; this.timeoutId = setTimeout(function(){ that.performProcessing(); },100); } } processor.process();
函数节流在resize
事件中是最常见的。如果基于该事件来改变页面布局的话,最好控制处理的频率,以确保浏览器不会在极短的时间内进行过多的计算:
function throttle(method,context){ clearTimeout(method.tId); method.tId = setTimeout(function(){ method.call(context); },100) } function resizeDiv(){ var div = document.getElementById('myDiv'); div.style.height = div.offsetWidth + 'px'; } window.onresize = function(){ throttle(resizeDiv); }
只要代码是周期性执行的。都应该使用函数节流。多数情况下,用户是感觉不到变化的,但是给浏览器节省的计算可能会非常大。
应用
使用定时器来调整事件发生顺序
网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,我们先让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)
。
正常情况下,点击div
元素,先弹出0
,再弹出1
:
<div id="myDiv" style="height: 100px;width: 100px;background-color: pink;"></div> <script> myDiv.onclick = function(){ alert(0); } document.onclick = function(){ alert(1); } </script>
<div id="myDiv"></div>
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myDiv { height: 100px; width: 100px; background-color: darken(#f09, 30%); position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
const myDiv = document.getElementById('myDiv') myDiv.addEventListener('click', () => { alert(0) }, false) document.addEventListener('click', () => { alert(1) }, false)
如果进行想让document
的onclick
事件先发生,即点击div元素,先弹出1
,再弹出0
。则进行如下设置:
<div id="myDiv" style="height: 100px;width: 100px;background-color: pink;"></div> <script> myDiv.onclick = function(){ setTimeout(function(){ alert(0); }) } document.onclick = function(){ alert(1); } </script>
<div id="myDiv"></div>
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myDiv { height: 100px; width: 100px; background-color: darken(#f09, 30%); position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
const myDiv = document.getElementById('myDiv') myDiv.addEventListener('click', () => { setTimeout(() => { alert(0) }) }, false) document.addEventListener('click', () => { alert(1) }, false)
用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress
事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的:
<input type="text" id="myInput"> <script> myInput.onkeypress = function(event) { this.value = this.value.toUpperCase(); } </script>
<input type="text" id="myInput">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myInput { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 0.5em 0.25em; border: none; border-radius: 0.2em; font-size: 1.5em; text-align: left; box-shadow: 0 0 1em 0.25em rgba(0,0,0,0.2); }
const myInput = document.getElementById('myInput') myInput.addEventListener('keypress', function(event){ this.value = this.value.toUpperCase() })
上面代码想在用户输入文本后,立即将字符转为大写。但是实际上,它只能将上一个字符转为大写,因为浏览器此时还没接收到文本,所以this.value
取不到最新输入的那个字符。
只有用setTimeout
改写,上面的代码才能发挥作用:
<input type="text" id="myInput"> <script> myInput.onkeypress = function(event) { setTimeout(function(){ myInput.value = myInput.value.toUpperCase(); }); } </script>
<input type="text" id="myInput">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:400,400italic,600,600italic,700italic); html,body { margin:0; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #EA5C54 0%,#bb6dec 100%); } #myInput { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 0.5em 0.25em; border: none; border-radius: 0.2em; font-size: 1.5em; text-align: left; box-shadow: 0 0 1em 0.25em rgba(0,0,0,0.2); }
const myInput = document.getElementById('myInput') myInput.addEventListener('keypress', function(event){ setTimeout(() => { myInput.value = myInput.value.toUpperCase() }) })