Webapi:setTimeout是怎么实现的|浏览器篇
# Webapi:setTimeout是怎么实现的
30 秒速记
setTimeout是浏览器提供的Web API,作用是注册一个至少等待指定时长后才有资格执行的回调。- 调用后会返回定时器编号,可将该编号传给
clearTimeout,在回调执行前撤销任务。 - 延迟参数不是准确执行时刻;回调仍受主线程占用、任务排队和浏览器限制影响。
- 它适合普通延迟调度,不适合依赖严格时间精度的场景。
setTimeout 本质上是浏览器提供的 Web API,用于注册一个至少等待指定时长后才有资格执行的回调。 调用它会返回一个定时器编号,在回调执行前可以把该编号传给 clearTimeout 来取消定时器。延迟参数并不代表准确的执行时刻,因为回调还会受到主线程占用、任务排队以及浏览器限制的影响。它适合普通的延迟调度,但如果业务依赖严格的时间精度,就不能只靠 setTimeout 保证。
那在接下来的两篇文章中,我会通过setTimeout和XMLHttpRequest这两个 WebAPI 来介绍事件循环的应用。这两个 WebAPI 是两种不同类型的应用,比较典型,并且在 JavaScript 中的使用频率非常高。你可能觉得它们太简单、太基础,但有时候恰恰是基础简单的东西才最重要,了解它们是如何工作的会有助于你写出更加高效的前端代码。
本篇文章主要介绍的是setTimeout。其实说起 setTimeout 方法,从事开发的同学想必都不会陌生,它就是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。下面的示例代码就演示了定时器最基础的使用方式:
function showName(){
console.log(" 极客时间 ")
}
var timerID = setTimeout(showName,200);
执行上述代码,输出的结果也很明显,通过 setTimeout 指定在 200 毫秒之后调用 showName 函数,并输出“极客时间”四个字。
简单了解了 setTimeout 的使用方法后,那接下来我们就来看看浏览器是如何实现定时器的,然后再介绍下定时器在使用过程中的一些注意事项
面试官追问
追问 1埋点负责人要求用户点击购买后第 200ms 必须发送请求,并把 setTimeout(send, 200) 当作准时保证,你会接受这个验收口径吗?
不能接受,200ms 不是回调必然执行的精确时刻,而是定时器设定的延后时间。回调到期后仍受当前任务、队列排队和浏览器调度影响,因此该实现只能保证不会有意早于设定时间执行,不能承诺硬期限。
追问 2商品页关闭浮层后,原先注册的 5s 推荐回调仍会更新已销毁组件;评审时你会要求怎样改造?
注册时应保存 setTimeout 返回的定时器编号,并在组件销毁或状态失效时调用 clearTimeout。同时回调仍应检查业务状态,因为取消操作只对尚未执行的定时器有效,无法中断已经开始运行的代码。
追问 3同一套促销代码从前台页面切到后台标签后,500ms 延迟任务明显变慢;产品要求仍按原节奏运行,你会怎样界定能力边界?
不能继续把 setTimeout 当作稳定节拍来源,后台页面可能受到浏览器降频策略影响。业务若依赖真实截止时间,应根据实际时间重新计算状态,而不是累计回调次数;普通网页事件循环仍无法提供硬实时保证。
追问 4线上偶发出现“定时器已注册却迟迟不执行”,你会让开发先采集哪些证据,而不是直接怀疑 setTimeout 失效?
应使用 performance.now() 记录注册与实际回调时间,并同步检查主线程长任务、前方任务排队和页面前后台状态。若等待时间已经达到但主线程仍被占用,回调延后属于调度结果;仅凭日志时间差无法判定计时器故障。
追问 5通知中心有两个方案:用 setTimeout 延后清理缓存,或用它驱动要求逐帧平滑的动画;你会批准哪一个?
更适合批准允许一定误差的延后清理,因为它不依赖回调在某个精确时刻执行。逐帧动画会受到任务阻塞、定时器最小间隔及后台降频影响,应优先考虑 requestAnimationFrame 并结合实际时间差,但它同样不是硬实时机制。
# 浏览器怎么实现 setTimeout
30 秒速记
- 主线程上的普通任务由事件循环从任务队列中依次取出执行,定时回调不能在注册时直接作为立即任务处理。
- 按文中所述的 Chrome 实现,浏览器会记录回调、创建时间、延迟值和定时器编号,并将其交给延迟任务结构管理。
- 事件循环处理任务期间会检查已到期的延迟任务;到期仅表示可以调度,仍需等待当前主线程工作结束。
clearTimeout(id)的核心语义是在回调尚未执行时,根据编号移除或取消对应的延迟任务。- 这种设计复用了单线程事件循环,因此无需让 JavaScript 回调在计时结束时抢占正在执行的代码。
浏览器实现 setTimeout 的核心,是把回调和创建时间、延迟值、定时器 ID 一起放进延迟任务队列。 主线程每处理完一个普通任务,会检查延迟任务,把已经到期的回调依次执行。到期只代表可以被调度,如果主线程还在忙,回调仍要等待,计时结束也不会抢占当前代码。调用 clearTimeout(id) 时,浏览器会按 ID 找到尚未执行的任务并将其取消。
要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。下面我们来看看那些典型的事件
- 当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM”事件添加到消息队列中,
- 当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中。
- 当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中。
- 同样,如果要执行一段异步 JavaScript 代码,也是需要将执行任务添加到消息队列中
以上列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中的顺序来执行事件。
所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中
源码中延迟执行队列的定义如下所示:
DelayedIncomingQueue delayed_incoming_queue;
当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下所示:
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;// 设置延迟执行时间
创建好回调任务之后,再将该任务添加到延迟执行队列中,代码如下所示:
delayed_incoming_queue.push(timerTask);
现在通过定时器发起的任务就被保存到延迟队列中了,那接下来我们再来看看消息循环系统是怎么触发延迟队列的。
我们可以来完善上一篇文章中消息循环的代码,在其中加入执行延迟队列的代码,如下所示
void ProcessTimerTask(){
// 从 delayed_incoming_queue 中取出已经到期的定时器任务
// 依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
for(;;){
// 执行消息队列中的任务
Task task = task_queue.takeTask();
ProcessTask(task);
// 执行延迟队列中的任务
ProcessDelayTask()
if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
break;
}
}
从上面代码可以看出来,我们添加了一个ProcessDelayTask 函数,该函数是专门用来处理延迟执行任务的。这里我们要重点关注它的执行时机,在上段代码中,处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。
设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用clearTimeout 函数,并传入需要取消的定时器的 ID。如下面代码所示:
clearTimeout(timer_id)
其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。
