写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。本博客的Github地址

JavaScript提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()setInterval()这两个函数来完成。它们向任务队列添加定时任务。

js是运行于单线程的环境中的,定时器仅仅只是计划代码在未来的某个时间执行(但是并不保证在该时间点一定执行)执行时机是不能保证的,因为在页面的生命周期中,不同时间可能有其他代码在控制js进程。在页面下载完后的代码运行、事件处理程序、Ajax回调函数都必须使用同样的线程来执行。实际上,浏览器负责进行排序,指派某段代码在某个时间点运行的优先级。

如上图所示:我们可以把javascript想象成在时间线上运行的。当页面载入时,首先执行是任何包含在<script>元素中的代码,通常是页面生命周期后面要用到的一些简单的函数和变量的声明,有时候也包含一些初始数据的处理。在这之后,javascript进程将等待更多代码执行,当进程空闲时,下一个代码会被触发并立刻执行。例如:当点击某个按钮时,onclick事件处理程序会立刻执行,只要javascript进程处于空闲状态。

除了javascript主执行进程外,还有一个需要在进程下一次空闲时执行的代码队列。随着页面在其生命周期中的推移,代码会按照执行顺序添加到队列中。例如:当某个按钮被按下,它的事件处理程序代码就会被添加到队列中,并在下一个可能的时间里执行。当接收到某个Ajax响应时,回调函数的代码会被添加到队列。在javascript中没有任何代码是立刻执行的,但是一旦进程空闲则尽快执行。

定时器对队列的工作方式是: 当特定时间过去后将代码插入。注意,给队列添加代码并不意味着对它立刻执行,而只能表示它会尽快执行。例如:设定一个150ms后执行的定时器不代表到了150ms代码就立刻执行,它表示代码会在150ms后被加入到队列中。如果在这个时间点,队列中没有其他东西,那么这段代码就会被执行,表面上看上去就好像代码就在精确的时间点上执行了。其他情况,代码可能明显等待更长时间才执行。

在上图中:给按钮设置了一个事件处理程序,该事件处理程序设置了一个250ms后调用的定时器。点击该按钮后,首先将onclick事件处理程序加入队列。该事件处理程序执行后才设置定时器,再有250ms后,指定的代码才被添加到队列中等待执行

对于定时器而言:我们要记住指定的时间间隔表示何时将定时器的代码添加到队列,而不是何时实际执行代码。如果上图中的onclick事件处理程序执行了300ms,那么定时器的代码至少要在定时器设置之后的300ms后才会被执行。队列中所有的代码都要等到js进程空闲之后才能执行,而不管它们是如何添加到队列中的。

上图中,尽管在255ms处添加了定时器代码,但是这个时候不能执行,因为onclick事件处理程序还在运行。定时器代码最早的执行时机在300ms处,即onclick事件处理程序结束之后。

需要注意:

  1. 定时器并不能保证真正的定时执行,一般会延迟一点(可以接受),也有可能延迟很长时间(不能接受)。
  2. 定时器的回调函数是在主线程上执行的,因为js是单线程的
  3. 定时器的实现依赖于事件循环模型。
document.querySelector('#btn').onclick = function() {
     let start = Date.now();
     console.log('启动定时器前');
     setTimeout(function() {
         console.log('定时器执行了', Date.now() - start);
     }, 300);
     console.log('启动定时器后');

     // 增加一个耗时的操作
     for (let i = 0; i < 1000000000; i++) {}
}
1
2
3
4
5
6
7
8
9
10
11

setTimeout

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

const timer = setTimeout(func|code, delay);
1

上面代码中,setTimeout函数接受两个参数,第一个参数func|code是将要推迟执行的函数名或者一段代码,第二个参数delay是推迟执行的毫秒数。

demo1(第一个参数是code)

console.log(111);
setTimeout('console.log(222)', 2000);
console.log(333);
// 运行结果:
111
333
222
1
2
3
4
5
6
7

上面代码会先输出111和333,然后等待2秒再输出222。特别注意:console.log(2)必须以字符串的形式,作为setTimeout的参数。

demo2(第一个参数是函数)

如果推迟执行的是函数,就直接将函数名作为setTimeout的参数。

const fn = () => {
    console.log(222);
}

console.log(111);
setTimeout(fn, 2000);
console.log(333);
// 运行结果:
111
333
222
1
2
3
4
5
6
7
8
9
10
11

特别注意:setTimeout的第二个参数如果省略,则默认为0。

setTimeout(f);
// 等同于
setTimeout(f, 0);
1
2
3

setTimeout参数

除了前面提到的两个参数,setTimeout还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)。

console.log(111);
setTimeout((a,b) => {
    console.log(a + b);
}, 1000, 1, 2);
console.log(333);
// 运行结果:
111
333
3
1
2
3
4
5
6
7
8
9

上面代码中,setTimeout共有4个参数。最后那两个参数(1和2),将在1秒之后回调函数执行时,作为回调函数的参数。

setTimeout的回调函数是对象的方法

特别注意:如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。

var x = 1;

var obj = {
  x: 2,
  y: function () {
    // 这里this指向window
    console.log(this.x);
  }
};

setTimeout(obj.y, 1000); // 1
1
2
3
4
5
6
7
8
9
10
11

上面代码输出的是1,而不是2。因为当obj.y在1秒后运行时,this所指向的已经不是obj了,而是全局环境window

解决方法一(将obj.y放入一个函数)

var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(function () {
  console.log(this); // window
  obj.y(); // 2
}, 1000);
1
2
3
4
5
6
7
8
9
10
11
12
13

上面代码中,obj.y放在一个匿名函数之中,这使得obj.yobj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

解决方法二(使用bind方法,将obj.y这个方法绑定在obj上面)

var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(obj.y.bind(obj), 1000); // 2
1
2
3
4
5
6
7
8
9
10

自己在总结的时候,自己用ES6声明全局变量x和obj。这样导致输出undefined。原因在于:let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩,这一点要特别注意。

const x = 1;

const obj = {
  x: 2,
  y: () => {
    console.log(this); // window
    console.log(this.x);
  }
};

setTimeout(obj.y, 1000); // undefined
1
2
3
4
5
6
7
8
9
10
11

ES6声明变量的六种方法

ES5只有两种声明变量的方法:var命令和function命令ES6 除了添加let和const命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6一共有6种声明变量的方法。

顶层对象的属性

顶层对象,在浏览器环境指的是window对象,在Node中指的是global对象。需要注意的是在ES5中,顶层对象的属性与全局变量是等价的。

window.a = 1;
a // 1

a = 2;
window.a // 2
1
2
3
4
5

上面代码中,顶层对象的属性赋值与全局变量的赋值,是一回事。

需要注意的是:在ES6中改变了这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩。

var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b // undefined
1
2
3
4
5
6
7

上面代码中,全局变量a由var命令声明,所以它是顶层对象的属性;全局变量b由let命令声明,所以它不是顶层对象的属性,返回undefined。

setInterval(重复的定时器)

使用setInterval创建的定时器确保了定时器代码规则地插入队列中。但是该方法的问题在于: 定时器代码可能在代码再次被添加到队列之前还没有执行完成,结果导致定时器代码连续运行好几次,而之间没有任何停顿。然而,javascript引擎够聪明,能避免这个问题。当使用setInterval时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

setInterval函数的用法与setTimeout完全一致,区别仅仅在于:setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。

var timer = setInterval(function() {
  console.log(2);
}, 1000)
1
2
3

上面代码中,每隔1秒就输出一个2,会无限运行下去,直到关闭当前窗口。与setTimeout一样,除了前两个参数,setInterval方法还可以接受更多的参数,它们会传入回调函数。

// 通过setInterval方法实现网页动画的例子。
var div = document.querySelector('#box');
var opacity = 1;
var fader = setInterval(() => {
  opacity -= 0.1;
  if (opacity >= 0) {
    div.style.opacity = opacity;
  }
  else {
    clearInterval(fader);
  }
}, 100);
1
2
3
4
5
6
7
8
9
10
11
12

上面代码每隔100毫秒,设置一次div元素的透明度,直至其完全透明为止。

setInterval的一个常见用途是:实现轮询

// 轮询URL的Hash值是否发生变化
var hash = window.location.hash;
var hashWatcher = setInterval(function() {
  if (window.location.hash != hash) {
    updatePage();
  }
}, 1000);
1
2
3
4
5
6
7

需要注意的是:setInterval指定的是函数开始执行之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如:setInterval指定某个函数每100ms执行一次,函数每次执行需要5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。

如上图所示:重复定时器有两个问题:1.某些间隔会被跳过;2.多个定时器的代码执行之间的间隔可能会比预期的小。假设,某个onclick事件处理程序使用setInterval设置了一个200ms间隔的重复定时器。如果事件处理程序花费了300ms多一点的时间完成,同时定时器代码也花费了差不多的时间,就会跳过一个间隔同时运行着一个定时器代码。

在上图的例子中:第一个定时器在205ms时被添加到队列中,但是直到过了300ms处才能够执行。当执行这个定时器代码时,在405ms处又给队列添加了另外一个副本。在下一个间隔,即605ms处。第一个定时器代码扔在运行,同时在队列中已经存在一个定时器代码的实例。结果导致在这个时间点上的定时器代码不会被添加到队列中。同时,当5ms处添加的定时器代码结束后,405ms处添加的定时器代码就立刻执行。

为了避免setInterval的这两个缺点,确保两次执行之间有固定的间隔,可以使用链式setTimeout,即每次执行结束后,使用setTimeout指定下一次执行的具体时间。

// 主要用于重复定时器
var timer = setTimeout(function () {
  // 处理中
  timer = setTimeout(arguments.callee, 2000);
}, 2000);
1
2
3
4
5

上述代码中,链式调用了setTimeout。每次函数执行的时候都会创建一个新的定时器。第二个setTimeout调用使用了arguments.callee来获取对当前执行的函数的引用,并为其设置另外一个定时器。这样做的好处是:在前一个定时器代码执行完成之前,不会向队列中插入新的定时器代码,确保不会有任何缺失的间隔。而且,可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免连续的运行。上面代码可以确保,下一次执行总是在本次执行结束之后的2秒开始

clearTimeout和clearInterval

setTimeout和setInterval函数,都返回一个整数值,表示计数器编号。将该整数传入clearTimeout和clearInterval函数,就可以取消对应的定时器。

var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);

clearTimeout(id1);
clearInterval(id2);
1
2
3
4
5

上面代码中,回调函数f不会再执行了,因为两个定时器都被取消了。

setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。

function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12
1
2
3
4

上面代码中,连续调用三次setTimeout,返回值都比上一次大了1。

利用这一点,可以写一个函数,取消当前所有的setTimeout定时器。

(function() {
  var gid = setInterval(clearAllTimeouts, 0);

  function clearAllTimeouts() {
    var id = setTimeout(function() {}, 0);
    while (id > 0) {
      if (id !== gid) {
        clearTimeout(id);
      }
      id--;
    }
  }
})();
1
2
3
4
5
6
7
8
9
10
11
12
13

上面代码中,先调用setTimeout,得到一个计算器编号,然后把编号比它小的计数器全部取消。

实例应用:debounce(防抖)

有时,我们不希望回调函数被频繁调用。比如:用户填入网页输入框的内容,希望通过Ajax方法传回服务器,jQuery的写法如下:

$('textarea').on('keydown', ajaxAction);
1

这样写有一个很大的缺点是:如果用户连续击键,就会连续触发keydown事件,造成大量的Ajax通信。这是不必要的,而且很可能产生性能问题。正确的做法应该是,设置一个门槛值,表示两次Ajax通信的最小间隔时间。如果在间隔时间内,发生新的keydown事件,则不触发Aja 通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,再将数据发送出去。这种做法叫做debounce(防抖动)。假定两次Ajax通信的间隔不得小于2500毫秒,上面的代码可以改写成下面这样。

$('textarea').on('keydown', debounce(ajaxAction, 2500));

function debounce(fn, delay){
  var timer = null; // 声明计时器
  return function() {
    var context = this;
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

运行机制

setTimeoutsetInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着:setTimeout和setInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的。所以,没有办法保证setTimeout和setInterval指定的任务一定会按照预定时间执行。

setTimeout(someTask, 100);
veryLongTask();
1
2

上面代码的setTimeout,指定100ms以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100ms还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。

setInterval(function () {
  console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
  var start = Date.now();
  while ((Date.now() - start) < ms) {
  }
}
1
2
3
4
5
6
7
8
9
10
11

上面代码中,setInterval要求每隔1秒,就输出一个2。但是,紧接着的sleep语句需要3秒才能完成,那么setInterval就必须推迟到3秒之后才开始生效。特别注意: 生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

setTimeout(f,0)

含义

setTimeout的作用是:将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?

答案是不会。因为setTimeout指定的回调函数f,必须要等到当前脚本的同步任务全部处理完以后才会执行。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行。

setTimeout(() => {
  console.log(1);
}, 0);
console.log(2);
// 2
// 1
1
2
3
4
5
6

上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f。

应用

setTimeout(f, 0)有几个非常重要的用途。它的一大应用是:可以调整事件的发生顺序。 比如:网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果想让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)

<body>
    <input id="btn" type="button" value="click">
    <script>
        var btn = document.querySelector('#btn');
        btn.addEventListener('click', function() {
            btn.value += ' input';
        }, false);
        document.body.addEventListener('click', function() {
            btn.value += ' body';
        }, false);
    </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12

上面代码按照常规的事件冒泡机制触发。

<body>
    <input id="btn" type="button" value="click">
    <script>
        var btn = document.querySelector('#btn');
        btn.addEventListener('click', function() {
            setTimeout(function() {
                btn.value += ' input';
            })
        }, false);
        document.body.addEventListener('click', function() {
            btn.value += ' body';
        }, false);
    </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面代码在点击按钮后,setTimeout将子元素的回调函数推迟到下一轮事件循环执行,这样就起到了先触发父元素的回调函数的目的了。

另一个应用是:用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。如下图所示:

<body>
    <input type="text" id="txt">
    <script>
        var txt = document.querySelector('#txt');
        function handleKeyPress(e) {
            e.target.value = e.target.value.toUpperCase();
        }
        txt.addEventListener('keypress', handleKeyPress, false);
    </script>
</body>
1
2
3
4
5
6
7
8
9
10

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以this.value取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。如下图所示:

<body>
    <input type="text" id="txt">
    <script>
        var txt = document.querySelector('#txt');
        function handleKeyPress(e) {
            setTimeout(function() {
                e.target.value = e.target.value.toUpperCase();
            }, 0);
        }
        txt.addEventListener('keypress', handleKeyPress, false);
    </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于setTimeout(f, 0)实际上意味着:将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f, 0)里面执行。

<body>
    <div id="box" style="width: 100px;height: 100px;"></div>
    <script>
        var box = document.querySelector('#box');
        // 写法一
        for (var i = 0xA00000; i < 0xFFFFFF; i++) {
            div.style.backgroundColor = '#' + i.toString(16);
        }
        // 写法二
        var timer = null;
        var i = 0x100000;
        function fn() {
            timer = setTimeout(fn, 0);
            box.style.backgroundColor = '#' + i.toString(16);
            if(i++ === 0xFFFFFF) clearTimeout(timer);
        }
        timer = setTimeout(fn, 0);
    </script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

上面代码有两种写法都是改变一个网页元素的背景色。写法一会造成浏览器堵塞,因为JavaScript执行速度远高于DOM,会造成大量DOM操作堆积,而写法二就不会,这就是setTimeout(f, 0)的好处。

另一个使用这种技巧的例子是:代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成setTimeout(highlightNext, 50)的样子,性能压力就会减轻。

参考文档

  1. 顶层对象的属性
  2. 定时器