setTimeout 和 XMLHttpRequest 是事件循环的两种不同类型的应用,非常典型。
function showName(){ console.log("极客时间") }
var timerID = setTimeout(showName,200);// 指定showName在xx毫秒后执行,返回一个整数表示定时器编号,同时可通过该编号取消这个定时器
渲染进程中,所有运行在主线程的任务都要先添加到消息队列,然后 eventloop 系统执行队列中的任务。典型的事件:
- 当接收到 HTML 文档数据,渲染引擎就会将 "解析 DOM"事件添加到消息队列中,
- 当用户改变 了 Web 页面的窗口大小 ,渲染引擎就会将 "重新布局"事件添加到消息队列中。
- 当触发了 JS 引擎垃圾回收机制 ,渲染引擎会将 "垃圾回收"任务添加到消息队列中。
- 如果要执行一段异步 JS 代码 ,也要将执行任务添加到消息队列中。
定时器的工作原理:延迟队列
延迟队列
要执行一段异步任务,要先将其添加到消息队列中。定时器的 callback 需在指定时间间隔内被调用,而消息队列中的任务是按顺序执行的,为保证回调函数能在指定时间执行,不能将定时器callback直接添加到消息队列中。
怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?
Chrome 中除了正常使用的消息队列外,还有另一个消息队列,维护了需要延迟执行的任务列表,包括定时器和 Chromium 内部一些需要延迟执行的任务。所以创建一个定时器时渲染进程会将该定时器的回调任务添加到延迟队列 中。Chromium 中关于队列部分的源码中延迟执行队列的定义: DelayedIncomingQueue delayed_incoming_queue;
1.渲染进程创建一个回调任务
JS 调用 setTimeout 设置 callback 时,渲染进程将会创建一个回调任务,包含回调函数 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;//设置延迟执行时间
2.将任务添加到延迟执行队列中
创建好回调任务之后,将该任务添加到延迟执行队列中: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; //如果设置了退出标志,那么直接退出线程循环
}
}
3.ProcessDelayTask 函数专门处理延迟执行任务
添加了 ProcessDelayTask 函数 专门处理延迟执行任务。这里要重点关注它的执行时机,处理完消息队列中的一个任务后就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务然后依次执行。等到期的任务执行完成后再继续下一个循环过程。这样一个完整的定时器就实现了。
设置一个定时器,JS 引擎会返回一个定时器的 ID。通常当一个定时器的任务还没有被执行时是可以取消的:clearTimeout(timer_id) 。浏览器内部实现取消定时器的操作也非常简单:直接从 delayed_incoming_queue 延迟队列中,通过 ID 找到对应任务,将其从队列中删除掉就可以了。
使用 setTimeout 的注意事项
有很多因素会导致回调函数执行比设定的预期值要久,定时器的回调函数执行时间比实际设定值要延后:
1. 如果当前任务执行时间过久,导致定时器设置的任务被延后执行
function bar() {
console.log('bar')
}
function foo() {
setTimeout(bar, 0);
for (let i = 0; i < 5000; i++) {
let i = 5+8+8+8
console.log(i)
}
}
foo()
执行 foo 函数时设置一个 0 延时的回调任务后,foo 函数会继续执行 5000 次 for 循环,回调任务被放入了消息队列中并等待下一次执行,并不是立即执行。要执行消息队列中的下个任务,需等待当前的任务执行完成,由于当前代码要执行 5000 次的 for 循环,执行时间会比较久,这势必会影响到下个任务的执行时间。
打开 Performance 查看其执行过程:
长任务导致定时器被延后执行
图中看到,执行 foo 函数消耗了 500 毫秒,意味着通过 setTimeout 设置的任务会被推迟到 500 毫秒后再执行,而设置 setTimeout 的回调延迟时间是 0。
2. 如果 setTimeout 存在嵌套调用,系统会设置最短时间间隔为 4 毫秒
在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间:
function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);
通过 Performance 来记录下代码执行过程:
循环嵌套调用 setTimeout
图中的竖线就是定时器的函数回调过程,可以看出前面五次调用的时间间隔比较小,嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒。
因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,浏览器会将每次调用的时间间隔设置为 4 毫秒,所以一些实时性较高的需求(如实现 JS 动画)就不太适合使用 setTimeout 了 。Chromium 实现 4 毫秒延迟的代码:
static const int kMaxTimerNestingLevel = 5;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static const expr base::TimeDelta kMinimumInterval = base::TimeDelta::FromMilliseconds(4);
base::TimeDelta interval_milliseconds =
std::max(base::TimeDelta::FromMilliseconds(1), interval);
if (interval_milliseconds < kMinimumInterval &&
nesting_level_ >= kMaxTimerNestingLevel)
interval_milliseconds = kMinimumInterval;
if (single_shot)
StartOneShot(interval_milliseconds, FROM_HERE);
else
StartRepeating(interval_milliseconds, FROM_HERE);
3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
未激活的页面中定时器最小值大于 1000 毫秒,即如果标签不是当前的激活标签,那定时器最小的时间间隔是 1000 毫秒,目的是优化后台页面的加载损耗以及降低耗电量。这一点在使用定时器时要注意。
4. 延时执行时间有最大值
Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大存放数字是 2147483647 毫秒,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)就会溢出,相当于延时值被设置为 0 了,导致定时器会被立即执行。
function showName(){
console.log("极客时间")
}
var timerID = setTimeout(showName,2147483648);//会被理解调用执行
这段代码是立即被执行的。如果将延时值修改为小于 2147483647 毫秒的某个值,执行就没有问题了。
5. 使用 setTimeout 设置的回调函数中的 this 不符合直觉
如果被 setTimeout 推迟执行的回调函数是某个对象的方法,该方法中的 this 关键字将指向全局环境,而非定义时所在的对象。这点前面介绍 this 时也提过:
js
var name= 1;
var MyObj = {
name: 2,
showName: function(){
console.log(this.name);
}
}
setTimeout(MyObj.showName,1000)
输出 1,因为代码在编译时执行上下文中的 this 会被设置为全局 window,严格模式,会被设为 undefined。
通常有两种方法解决:
1.将MyObj.showName放在匿名函数中执行:
js
//箭头函数或function函数
setTimeout(() => {
MyObj.showName()
}, 1000);
setTimeout(function() {
MyObj.showName();
}, 1000)
2.使用 bind 将 showName 绑定在 MyObj 上:
setTimeout(MyObj.showName.bind(MyObj), 1000)
总结
- 为了支持定时器的实现,浏览器增加了延时队列。
- 由于消息队列排队和一些系统级别的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行,这样就不能满足一些实时性要求较高的需求了。
- 在定时器使用过程中还存在一些陷阱,需多加注意。
setTimeout 在时效性上有很多先天不足,对于一些时间精度要求较高的需求,应针对性地采取其他方案。
思考:requestAnimationFrame 实现的动画效果比 setTimeout 好的原因
setTimeout 设置的回调任务实时性并不是太好,很多场景并不适合使用 setTimeout。比如使用 JS 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。
今天留给你的作业是:了解下 requestAnimationFrame 的工作机制,对比 setTimeout,分析 requestAnimationFrame 实现的动画效果比 setTimeout 好的原因。