浏览器怎么实现setTimeout的?

定时器的概念非常简单,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的ID,同时你还可以通过这个ID来取消这个定时器。

function showName(){
  console.log("这是一个定时器")
}
const timerID = setTimeout(showName, 2 * 1000);

定时器虽然概念简单,但是它里面的陷阱很多,比如:

  • 定时器是根据第二个参数准时执行的吗?
  • 定时器的嵌套调用,会有问题吗?
  • 定时器的第二个参数有最大值吗?
  • 定时器的第一个参数中 this 指向有什么不一样?

要想解答这些问题,我们需要了解浏览器到底是如何实现 setTimeout 的。

浏览器怎么实现 setTimeout ?

我们再来回顾我在上篇文章讲的事件循环系统。我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按顺序执行消息队列中的任务。

我们再来看几个典型的事件:

  • 当接收到HTML文档数据,渲染引擎就会将“解析DOM”事件添加到消息队列中
  • 当用户改变来Web页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中
  • 当触发了JS引擎的垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中
  • 同样,如果要执行一段异步JS代码,也要将执行任务添加到消息队列中

上面列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中到顺序来执行事件。

所以说要执行一个异步任务,需要先将任务添加到消息队列之后。不过定时器有点特别,因为它需要保证回调函数在指定的时间间隔内被调用,但消息队列中的任务是按顺序执行的,这样时间是不可控的。所以要保证回调函数能在指定时间内执行,就不能将定时器的回调函数直接添加到消息队列中。

那应该怎么设计才能让定时器的回调函数在规定的时间内被执行呢?Chrome是这样实现的,在Chrome中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过JS创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

源码中延迟执行队列是这样的

DelayedIncomingQueue delayed_incoming_queue;

下面是一段示例代码

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); //获取当前时间
timerTask.delay_time = 200;//设置延迟执行时间

当通过JS调用setTimeout设置回调函数的时候,渲染进程将会创建一个回调任务,任务里包含了回调函数名字、延迟的时间。这样创建好任务后,再将该任务添加到延迟执行队列中

delayed_incoming_queue.push(timerTask);

现在通过定时器发起的任务已经保存在延迟队列中了,那怎么触发这个延迟队列呢?

我们来看看前面提到的消息循环代码

function ProcessDelayTask(){
  //从delayed_incoming_queue中取出已经到期的定时器任务
  //依次执行这些任务
}

const task_queue = [];
ProcessTask();
let keep_running = true;
function MainTherad(){
  for(;;){
    //执行消息队列中的任务
    const task = task_queue.shift();
        if (task)
        ProcessTask(task);

    //执行延迟队列中的任务
    ProcessDelayTask()

    if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
        break;
  }
}

代码里可以看出,我们添加了一个 ProcessDelayTask 函数,这个函数是专门来处理延迟执行任务的。在一个循环过程中,当消息队列中的任务被执行完之后,就开始执行ProcessDelayTask函数。ProcessDelayTask函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。然后这些延迟任务执行完成之后,再继续下一个循环过程。

还有一个操作就是,我们可以通过clearTimeout来取消一个定时器,它的实现也很简单。

clearTimeout(timer_id)

当我们设置一个定时器的时候,JS引擎会返回一个定时器的ID。取消的时候直接从延迟队列中找到ID对应的任务,然后把它删掉就可以了。这样就实现了一个完成的定时器。

使用 setTimeout 的一些注意事项

现在你应该知道在浏览器内部定时器是如何工作的了。不过在使用定时器的过程中,如果你不了解定时器的一些细节,那么很有可能掉进定时器的一些陷阱里。下面我们就来回答一下开始的那几个问题。

  1. 如果当前任务执行时间过久,会影响定时器任务的执行,导致时间不准

    其实通过上面的流程,我们可以看出来定时器的时间可能是不准的。如果消息队列里面的一个任务执行时间过长,就会导致定时器的任务会被延后执行。

长任务导致定时器被延后执行

  1. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

还有就是如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。这也就是说定时器函数里面嵌套定时器,也会延长定时器的执行时间。

function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);

我们来看一段代码,相应的执行过程如下

循环嵌套调用 setTimeout

图里的竖线就是定时器的函数回调过程,从图中可以看出,前面五次调用的时间间隔比较小,嵌套调用5次以上,后面每次调用最小时间间隔是4毫秒。这样做的原因是,定时器嵌套调用5次以上,会被chrome认为该方法被阻塞了,如果这个定时器的调用时间小于4毫秒,那么chrome会将每次调用的时间间隔设置为4毫秒。

所以一些实时性较高的需要就不太适合用setTimeout了,比如你用setTimeout去实现一个JS动画就不是个好主意。至于替代方案,可以使用CSS3动画,虽然是强大的CSS3也有鞭长莫及的时候,比如像scrollTop值。所以这个时候还需要JS出马,使用window.requestAnimationFrame函数。

这个函数名表意为“请求动画帧”。它是怎么执行的呢。它是根据设备的刷新率更新的。正常来讲屏幕的刷新率是60帧每秒,也就是说16.7毫秒会更新一帧,一帧就是一张图片。这个函数就是在浏览器每次重绘之前调用这个函数。所以它是不用设置具体的间隔时间的,而且这样执行效率很高,因为每次执行的时机都是固定的。当然raf的回调函数也是在主线程上执行的,如果回调函数执行时间过长,也会影响到其他任务。

  1. 延时执行时间有最大值

还有一点就是定时器的执行时间是有最大值的。现在主流的浏览器都是以32个bit来存贮这个时间的。32bit只能存放2^31-1毫秒,也就是大约24.8天。如果设置的时间大于这个数,这个值就会溢出,那么就相当于把时间设置成0了,它就会马上执行。

  1. 使用 setTimeout 设置的回调函数中的 this 不符合直觉

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。这点在前面介绍 this 的时候也提过,你可以看下面这段代码的执行结果:

var name= 1;
var MyObj = {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(MyObj.showName,1000)

这里输出的是 1,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window,如果是严格模式,会被设置为 undefined。

另外还有一种情况,就是当前页面可能不是激活页面,那么 Chrome 会将定时器的最小时间间隔设置为1秒,显然这样可以降低消耗。