JavaScript定时器及相关面试题
在单线程JavaScript这篇文章中,在介绍JavaScript单线程的同时,也介绍了setTimeout是如何工作的。但是对于定时器的一些内容,并没有做深入的讨论。这篇文章,会详细说说JS的两种定时器,setTimeout和setInterval,以及它们的工作方式。同时,会谈谈有关setTimeout的面试题。
setInterval
setInterval,也称为间歇调用定时器,是指允许设置间歇时间来调用定时器代码在特定的时刻执行。也就是说,setInterval会在每隔指定的时间就执行一次代码。
setInterval属于window对象上的私有方法,它可以接收多个参数,
第一个参数可以是一个函数,也可以是一个字符串。
第二个参数是每次执行之前需要等待的毫秒数,这里有一个很大的误区就是,当设定时间之后,很多人认为会立即执行定时器,其实不是。设定一个 150ms 后执行的定时器不代表到了 150ms 代码就立刻执行,它表示代码会在 150ms 后被加入到任务队列中。如果在这个时间点上,主线程上的所有同步任务都执行完毕,并且任务队列上没有其他任务,那么这个任务会被执行;如果主线程上的同步任务未执行完毕,且任务队列上还存在其他异步任务(包括时间更短的定时器),这时候就要等待以上同步任务和异步任务执行完毕之后,这个150ms的任务才会开始执行。
第三个参数以后是指传入函数的一些参数。其中,只有第一个参数是必须的,其他都是可选的。在默认情况下,第二个参数默认值为0。但是0毫秒实际上也是达不到的。根据HTML 5标准,setTimeout推迟执行的时间,最少是5毫秒。如果小于这个值,会被自动增加到5ms。
//let timer = setInterval(func[, delay, param1, param2, ...]);
let timer = setInterval(function(a, b) {
console.log(a, b);
}, 1000, 1, 2);
//在执行栈为空时,每隔一秒钟就会输出 1, 2
//不建议这样使用!传递字符串会导致性能损失
let timer = setInterval("alert('Hello world')", 1000);
调用完setInterval之后,该方法会返回一个定时器ID,主要用于取消超时调用。
关于setInterval间歇调用定时器,在MDN和《JavaScript高级程序设计(第三版)》上都是不推荐使用的,因为setInterval会带来一些问题。所以,一般情况下,我们会使用setTimeout来代替setInterval。但作为学习,还是要理解其中的原理。
setInterval问题在于(1)某些间隔会被跳过;(2)多个定时器代码之间的间隔可能会比预期的小。
假设,某个 onclick 事件处理程序使用 setInterval() 设置了一个 200ms 间隔的重复定时器。如果事件处理程序花了 300ms 的时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过间隔且连续运行定时器代码的情况。
这个例子中的第 1 个定时器是在 205ms 处添加到队列中的(即使任务队列为空,0ms实际上是达不到的,因此至少为5ms),但是直到过了 300ms 处才能够执行。当执行这个定时器代码时,在 405ms 处又给任务队列添加了另外一个副本。在下一个间隔,即 605ms 处,第一个定时器代码仍在运行,同时在任务队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中。结果在 5ms 处添加的定时器代码结束之后,405ms 处添加的定时器代码就立刻执行。因此,《JavaScript高级程序设计(第三版)》建议,使用超时调用(setTimeout)来模拟间歇调用(setInterval)的是一种最佳模式,原因是后一个间歇调用可能会在前一个间歇调用结束之前启动。
setTimeout
关于setTimeout,它的语法同setInterval。
由于setInterval间歇调用定时器存在一些问题,所以一般会使用setTimeout代替setInterval,至少我本人在开发中是不会使用setInterval的..替换代码如下。
setTimeout(function timer() {
//需要执行的代码
//setTimeout会等到定时器代码执行完毕之后才会重新调用自身(递归),要注意的是要给匿名函数添加一个函数名,以便调用自身。
setTimeout(timer, 1000);
}, 1000)
这样做的好处是,在前一个定时器执行完毕之前,不会向任务队列中插入新的定时器代码,因此确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续执行。这个模式主要用于重复定时器。再看看一些实例。
let num = 0;
let max = 10;
setTimeout(function timer() {
num++;
console.log(num);
if (num === max) {return}
setTimeout(timer, 500)
}, 500);
//或者是
setTimeout(function timer() {
num++;
console.log(num);
if (num < max) {setTimeout(timer, 500)}
}, 500);
综上,由于setInterval间歇调用定时器会因为在定时器代码未执行完毕时又向任务队列中添加定时器代码,导致某些间隔被跳过等问题,所以应使用setTimeout代替setInterval。
有关setTimeout的面试题
关于setTimeout的面试题,主要是循环中使用定时器以及定时器中this的指向性问题。在setTimeout内部,this绑定采用默认绑定规则,也就是说,在非严格模式下,this会指向window;而在严格模式下,this指向undefined。详细可参考此答案如何理解JavaScript中的this关键字
闭包的一些特点:
1. 基于词法作用域的查找规则,能够记住并访问所在的词法作用域
2. 将函数作为值传递(将函数作为参数传入另一个函数,或者将函数作为另一个函数的结果返回)
3. 闭包拥有更长的生命周期
4. 闭包中的this默认指向全局作用域,闭包中的this会指向全局的原因在于闭包都是在当前词法作用域之外被调用的(在ES6之前,this绑定取决于函数的调用位置)
对于循环中使用定时器,问题如下,然后各种问题慢慢开拓...
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000 * i)
}
//以上代码输入什么?
回答:以上代码输出5个5,并且每隔1s输出一个,一共用时4s。这里我想解释一下为什么会这样子输出。以下解释为个人想法,仅供参考。
我们给代码做一些调整。
for (var i = 0; i < 5; i++) {
let timer = setTimeout(function() {}, 1000 * i)
console.log(timer);
//输出1, 2, 3, 4, 5
}
控制台输出了5个不同的定时器ID,说明在for循环当中,创建了5个setTimeout定时器。(此部分由博友指出,已修改,加粗字体)//定时器会循环创建,但是会等到同步任务(for循环)执行完毕,输出0, 1, 2, 3, 4之后,主线程才会执行任务队列上的任务(定时器),几乎同时开始计时(for循环完毕的时间极短,时间可以忽略不计,因此可以将5个定时器看做是同时创建的,理解这个非常重要),但是会等到其他异步任务完毕才会执行定时器代码//。并且,setTimeout的第二个参数(指定多少ms将定时器推入任务队列中),并非引用的是全局作用域的i(即循环结束退出时的),而是正常情况,即按照循环变量i的累加(因为回调函数属于闭包,而第二个参数不属于闭包的一部分)。因此,可以将以上代码改写。
setTimeout(function() {
console.log(5);
}, 0);
setTimeout(function() {
console.log(5);
}, 1000);
setTimeout(function() {
console.log(5);
}, 2000);
setTimeout(function() {
console.log(5);
}, 3000);
setTimeout(function() {
console.log(5);
}, 4000);
这里需要注意的是,setTimeout回调函数中的i引用的是全局作用域下的i(即循环结束时的i),而设定时间的i与for循环的变量i累加相同。
这里,为什么会等待for执行完毕才开始计时,给出下面一段代码。
for (var i = 0; i < 5; i++) {
console.log(i);
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
//依次输出:0, 1, 2, 3, 4 接着输出5个5
稍稍的总结一下:javascript是单线程语言,只有主线程上的所有同步任务执行完毕,主线程才会读取任务队列上的异步任务。for循环属于同步任务,而定时器属于异步任务。所以会在for循环结束之后才开始执行定时器的代码。因此会输出5个5。同理。如果在for循环中创建点击事件也是如此。因为异步任务包括IO操作(ajax)和与用户交互的事件(click, mouseover等)。如下
// 注意:使用addEventListener可以为同一个元素绑定多个相同事件,而onclick则只能绑定一个相同事件
// 解决方法1:使用let关键字创建块级作用域
const node = document.querySelector('.button')
for (let i = 0; i < 5; i++) {
node.addEventListener('click', () => {
console.log(i)
}, false)
}
// 解决方法2:为事件创建闭包
for (let i = 0; i < 5; i++) {
((j) => {
node.addEventListener('click', () => {
console.log(j)
}, false)
})(i)
}
如果有不同意见的博友,请给我留言,共同学习。
问题二:问题一的代码如何让其输出0, 1, 2, 3, 4呢?
回答:这里有两种解决方法,不过其中的原理都相同,即给setTimeout定时器外层创建一个块作用域,或者是创建函数作用域以形成闭包。
关于闭包,我们知道,闭包的一个特点就是基于词法作用域的查找规则,由于此时的回调函数引用的是循环结束后i的值(即,此时已经查找到了全局作用域下),因此,当我们在定时器外添加函数作用域并且传入一个记录循环变量的值,就意味着我们在函数作用域就拥有了此变量i,而不用到全局作用域下查找。此时的定时器仍然是循环创建,并且几乎同时开始计时,不过唯一不同的是i的引用不再指向全局作用域。
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
//方法一:ES6 let关键字,创建块作用域
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 1000 * i)
}
//以上代码实际上是这样的
for (var i = 0; i < 5; i++) {
let j = i; //闭包的块作用域
setTimeout(function() {
console.log(j);
}, 1000 * j);
}
//方法二:IIFE
for (var i = 0; i < 5; i++) {
(function iife(j) { //闭包的函数作用域
setTimeout(function() {
console.log(j);
}, 1000 * i); //这里将i换为j, 可以证明以上的想法。
})(i);
}
//实际上,函数参数,就相当于函数内部定义的局部变量,因此下面的写法是相同的。
for (var i = 0; i < 5; i++) {
(function iife() {
var j = i;
setTimeout(function() {
console.log(j);
}, 1000 * i); //如果这里将i换为j, 可以证明以上的想法。
})();
}
这里简单说明方法二使用立即执行的函数表达式的原因。
给定时器外层创建了一个IIFE,并且传入变量i。此时,setTimeout会形成一个闭包,记住并且可以访问所在的词法作用域。因此,就会正常输出1, 2, 3, 4。
问题三: 如果原问题改为如下,会输出什么?
for (var i = 0; i < 5; i++) {
setTimeout((function() {
console.log(i);
})(), 1000 * i);
}
回答:立即输出0, 1, 2, 3, 4。因为是setTimeout的第一个参数是函数或者字符串,而此时函数又立即执行了。因此,此时的定时器无效了,直接输出0, 1, 2, 3, 4。上面的代码等同于如下
for (var i = 0; i < 5; i++) {
(function() {
console.log(i); //0, 1, 2, 3, 4
})();
}
问题四,代码如下,输出顺序是什么?
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
$.ajax({
url: "../index.php", //假如上一级目录下有php文件,并且echo '3';
data: 'GET',
success: function(data) {
console.log(data);
},
})
new Promise(function(resolve, reject) {
console.log(4);
resolve();
}).then(function() {
console.log(5);
}).then(function() {
console.log(6);
})
console.log(7);
回答:此时的输出顺序是1, 4, 7, 5, 6, 3, 2。这里涉及Promise对象,这道题的解释先留着,等到介绍Promise对象时再在Pormise的相关文章中回答。
总结:
最后,就此题做出一个关于在for循环中创建setTimeout定时器的总结:
1. 根据事件循环和任务队列的原理,定时器会在循环结束后才会加入到任务队列执行。
2. 定时器是循环创建的。
3. 定时器几乎是同时开始计时的。
4. 定时器中的回调函数属于闭包,包含着对循环后全局变量i的引用。在块作用域和定时器外创建一个函数作用域时,此时不会查找全局作用域。
5. 定时器的第二个参数不属于闭包的一部分,其值与循环i的值相同。
参考连接