WebAPI:setTimeout是如何实现的?
在上一篇文章中我们介绍了页面中的事件和消息队列,知道了**浏览器页面是由消息队列和事件循环系统来驱动的。**
那在接下来的两篇文章中,我会通过 setTimeout 和 XMLHttpRequest 这两个 WebAPI 来介绍事件循环的应用。这两个 WebAPI 是两种不同类型的应用,比较典型,并且在 JavaScript 中的使用频率非常高。你可能觉得它们太简单、太基础,但有时候恰恰是基础简单的东西才最重要,了解它们是如何工作的会有助于你写出更加高效的前端代码。
本篇文章主要介绍的是 setTimeout。其实说起 setTimeout 方法,从事开发的同学想必都不会陌生,它就是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。下面的示例代码就演示了定时器最基础的使用方式:
function showName(){
console.log("极客时间")
}
var timerID = setTimeout(showName,200);
执行上述代码,输出的结果也很明显,通过 setTimeout 指定在 200 毫秒之后调用 showName 函数,并输出“极客时间”四个字。
简单了解了 setTimeout 的使用方法后,那接下来我们就来看看浏览器是如何实现定时器的,然后再介绍下定时器在使用过程中的一些注意事项。
浏览器怎么实现 setTimeout
要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。下面我们来看看那些典型的事件:
当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM”事件添加到消息队列中,
当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中。
当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中。
同样,如果要执行一段异步 JavaScript 代码,也是需要将执行任务添加到消息队列中。
以上列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中的顺序来执行事件。
所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
如果感兴趣,你可以参考Chromium 中关于队列部分的源码。
源码中延迟执行队列的定义如下所示:
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 查找到对应的任务,然后再将其从队列中删除掉就可以了。
使用 setTimeout 的一些注意事项
现在你应该知道在浏览器内部定时器是如何工作的了。不过在使用定时器的过程中,如果你不了解定时器的一些细节,那么很有可能掉进定时器的一些陷阱里。所以接下来,我们就来讲解一下在使用定时器过程中存在的那些陷阱。
1. 如果当前任务执行时间过久,会影响定时器任务的执行
在使用 setTimeout 的时候,有很多因素会导致回调函数执行比设定的预期值要久,其中一个就是当前任务执行时间过久从而导致定时器设置的任务被延后执行。我们先看下面这段代码:
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 函数的时候使用 setTimeout 设置了一个 0 延时的回调任务,设置好回调任务后,foo 函数会继续执行 5000 次 for 循环。
通过 setTimeout 设置的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成,由于当前这段代码要执行 5000 次的 for 循环,所以当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间。
你也可以打开 Performance 来看看其执行过程,如下图所示:
从图中可以看到,执行 foo 函数所消耗的时长是 500 毫秒,这也就意味着通过 setTimeout 设置的任务会被推迟到 500 毫秒以后再去执行,而设置 setTimeout 的回调延迟时间是 0。
2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间,可以先看下面的这段代码:
function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);
上述这段代码你有没有看出存在什么问题?
你还是可以通过 Performance 来记录下这段代码的执行过程,如下图所示:
上图中的竖线就是定时器的函数回调过程,从图中可以看出,前面五次调用的时间间隔比较小,嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒。之所以出现这样的情况,是因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。下面是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 constexpr 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);
所以,一些实时性较高的需求就不太适合使用 setTimeout 了,比如你用 setTimeout 来实现 JavaScript 动画就不是一个很好的主意。
3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 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 的时候也提过,你可以看下面这段代码的执行结果:
var name= 1;
var MyObj = {
name: 2,
showName: function(){
console.log(this.name);
}
}
setTimeout(MyObj.showName,1000)
这里输出的是 1,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window,如果是严格模式,会被设置为 undefined。
那么该怎么解决这个问题呢?通常可以使用下面这两种方法。
第一种是将MyObj.showName放在匿名函数中执行,如下所示:
//箭头函数
setTimeout(() => {
MyObj.showName()
}, 1000);
//或者function函数
setTimeout(function() {
MyObj.showName();
}, 1000)
第二种是使用 bind 方法,将 showName 绑定在 MyObj 上面,代码如下所示:
setTimeout(MyObj.showName.bind(MyObj), 1000)
有了setTimeOut,为什么还要使用rAF?
我们都知道,要想利用 JavaScript 实现高性能的动画,那就得使用 requestAnimationFrame 这个 API,我们简称 rAF,那么为什么都推荐使用 rAF 而不是 setTimeOut 呢?
要解释清楚这个问题,就要从渲染进程的任务调度系统讲起,理解了渲染进程任务调度系统,你自然就明白了 rAF 和 setTimeOut 的区别。其次,如果你理解任务调度系统,那么你就能将渲染流水线和浏览器系统架构等知识串起来,理解了这些概念也有助于你理解 Performance 标签是如何工作的。
要想了解最新 Chrome 的任务调度系统是怎么工作的,我们得先来回顾下之前介绍的消息循环系统,我们知道了渲染进程内部的大多数任务都是在主线程上执行的,诸如 JavaScript 执行、DOM、CSS、计算布局、V8 的垃圾回收等任务。要让这些任务能够在主线程上有条不紊地运行,就需要引入消息队列。
在前面的《16 | WebAPI:setTimeout 是如何实现的?》这篇文章中,我们还介绍了,主线程维护了一个普通的消息队列和一个延迟消息队列,调度模块会按照规则依次取出这两个消息队列中的任务,并在主线程上执行。为了下文讲述方便,在这里我把普通的消息队列和延迟队列都当成一个消息队列。
新的任务都是被放进消息队列中去的,然后主线程再依次从消息队列中取出这些任务来顺序执行。这就是我们之前介绍的消息队列和事件循环系统。
单消息队列的队头阻塞问题
我们知道,渲染主线程会按照先进先出的顺序执行消息队列中的任务,具体地讲,当产生了新的任务,渲染进程会将其添加到消息队列尾部,在执行任务过程中,渲染进程会顺序地从消息队列头部取出任务并依次执行。
在最初,采用这种方式没有太大的问题,因为页面中的任务还不算太多,渲染主线程也不是太繁忙。不过浏览器是向前不停进化的,其进化路线体现在架构的调整、功能的增加以及更加精细的优化策略等方面,这些变化让渲染进程所需要处理的任务变多了,对应的渲染进程的主线程也变得越拥挤。下图所展示的仅仅是部分运行在主线程上的任务,你可以参考下:
你可以试想一下,在基于这种单消息队列的架构下,如果用户发出一个点击事件或者缩放页面的事件,而在此时,该任务前面可能还有很多不太重要的任务在排队等待着被执行,诸如 V8 的垃圾回收、DOM 定时器等任务,如果执行这些任务需要花费的时间过久的话,那么就会让用户产生卡顿的感觉。你可以参看下图:
因此,在单消息队列架构下,存在着低优先级任务会阻塞高优先级任务的情况,比如在一些性能不高的手机上,有时候滚动页面需要等待一秒以上。这像极了我们在介绍 HTTP 协议时所谈论的队头阻塞问题,那么我们也把这个问题称为消息队列的队头阻塞问题吧。
Chromium 是如何解决队头阻塞问题的?
为了解决由于单消息队列而造成的队头阻塞问题,Chromium 团队从 2013 年到现在,花了大量的精力在持续重构底层消息机制。在接下来的篇幅里,我会按照 Chromium 团队的重构消息系统的思路,来带你分析下他们是如何解决掉队头阻塞问题的。
1. 第一次迭代:引入一个高优先级队列
首先在最理想的情况下,我们希望能够快速跟踪高优先级任务,比如在交互阶段,下面几种任务都应该视为高优先级的任务:
通过鼠标触发的点击任务、滚动页面任务;
通过手势触发的页面缩放任务;
通过 CSS、JavaScript 等操作触发的动画特效等任务。
这些任务被触发后,用户想立即得到页面的反馈,所以我们需要让这些任务能够优先与其他的任务执行。要实现这种效果,我们可以增加一个高优级的消息队列,将高优先级的任务都添加到这个队列里面,然后优先执行该消息队列中的任务。最终效果如下图所示:
观察上图,我们使用了一个优先级高的消息队列和一个优先级低消息队列,渲染进程会将它认为是紧急的任务添加到高优先级队列中,不紧急的任务就添加到低优先级的队列中。然后我们再在渲染进程中引入一个任务调度器,负责从多个消息队列中选出合适的任务,通常实现的逻辑,先按照顺序从高优先级队列中取出任务,如果高优先级的队列为空,那么再按照顺序从低优级队列中取出任务。
我们还可以更进一步,将任务划分为多个不同的优先级,来实现更加细粒度的任务调度,比如可以划分为高优先级,普通优先级和低优先级,最终效果如下图所示:
观察上图,我们实现了三个不同优先级的消息队列,然后可以使用任务调度器来统一调度这三个不同消息队列中的任务。
好了,现在我们引入了多个消息队列,结合任务调度器我们就可以灵活地调度任务了,这样我们就可以让高优先级的任务提前执行,采用这种方式似乎解决了消息队列的队头阻塞问题。
不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或者合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,甚至有可能出现还未处理输入事件,就合成了该事件要显示的图片。因此我们需要让一些相同类型的任务保持其相对执行顺序。
2. 第二次迭代:根据消息类型来实现消息队列
要解决上述问题,我们可以为不同类型的任务创建不同优先级的消息队列,比如:
可以创建输入事件的消息队列,用来存放输入事件。
可以创建合成任务的消息队列,用来存放合成事件。
可以创建默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
还可以创建一个空闲消息队列,用来存放 V8 的垃圾自动垃圾回收这一类实时性不高的事件。
最终实现效果如下图所示:
通过迭代,这种策略已经相当实用了,但是它依然存在着问题,那就是这几种消息队列的优先级都是固定的,任务调度器会按照这种固定好的静态的优先级来分别调度任务。那么静态优先级会带来什么问题呢?
我们在《25 | 页面性能:如何系统地优化页面?》这节分析过页面的生存周期,页面大致的生存周期大体分为两个阶段,加载阶段和交互阶段。
虽然在交互阶段,采用上述这种静态优先级的策略没有什么太大问题的,但是在页面加载阶段,如果依然要优先执行用户输入事件和合成事件,那么页面的解析速度将会被拖慢。Chromium 团队曾测试过这种情况,使用静态优先级策略,网页的加载速度会被拖慢 14%。
3. 第三次迭代:动态调度策略
可以看出,我们所采用的优化策略像个跷跷板,虽然优化了高优先级任务,却拖慢低优先级任务,之所以会这样,是因为我们采取了静态的任务调度策略,对于各种不同的场景,这种静态策略就显得过于死板。
所以我们还得根据实际场景来继续平衡这个跷跷板,也就是说在不同的场景下,根据实际情况,动态调整消息队列的优先级。一图胜过千言,我们先看下图:
这张图展示了 Chromium 在不同的场景下,是如何调整消息队列优先级的。通过这种动态调度策略,就可以满足不同场景的核心诉求了,同时这也是 Chromium 当前所采用的任务调度策略。
上图列出了三个不同的场景,分别是加载过程,合成过程以及正常状态。下面我们就结合这三种场景,来分析下 Chromium 为何做这种调整。
首先我们来看看页面加载阶段的场景,在这个阶段,用户的最高诉求是在尽可能短的时间内看到页面,至于交互和合成并不是这个阶段的核心诉求,因此我们需要调整策略,在加载阶段将页面解析,JavaScript 脚本执行等任务调整为优先级最高的队列,降低交互合成这些队列的优先级。
页面加载完成之后就进入了交互阶段,在介绍 Chromium 是如何调整交互阶段的任务调度策略之前,我们还需要岔开一下,来回顾下页面的渲染过程。
在《06 | 渲染流程(下):HTML、CSS 和 JavaScript,是如何变成页面的?》和《24 | 分层和合成机制:为什么 CSS 动画比 JavaScript 高效?》这两节,我们分析了一个页面是如何渲染并显示出来的。
在显卡中有一块叫着前缓冲区的地方,这里存放着显示器要显示的图像,显示器会按照一定的频率来读取这块前缓冲区,并将前缓冲区中的图像显示在显示器上,不同的显示器读取的频率是不同的,通常情况下是 60HZ,也就是说显示器会每间隔 1/60 秒就读取一次前缓冲区。
如果浏览器要更新显示的图片,那么浏览器会将新生成的图片提交到显卡的后缓冲区中,提交完成之后,GPU 会将后缓冲区和前缓冲区互换位置,也就是前缓冲区变成了后缓冲区,后缓冲区变成了前缓冲区,这就保证了显示器下次能读取到 GPU 中最新的图片。
这时候我们会发现,显示器从前缓冲区读取图片,和浏览器生成新的图像到后缓冲区的过程是不同步的,如下图所示:
这种显示器读取图片和浏览器生成图片不同步,容易造成众多问题。
如果渲染进程生成的帧速比屏幕的刷新率慢,那么屏幕会在两帧中显示同一个画面,当这种断断续续的情况持续发生时,用户将会很明显地察觉到动画卡住了。
如果渲染进程生成的帧速率实际上比屏幕刷新率快,那么也会出现一些视觉上的问题,比如当帧速率在 100fps 而刷新率只有 60Hz 的时候,GPU 所渲染的图像并非全都被显示出来,这就会造成丢帧现象。
就算屏幕的刷新频率和 GPU 更新图片的频率一样,由于它们是两个不同的系统,所以屏幕生成帧的周期和 VSync 的周期也是很难同步起来的。
所以 VSync 和系统的时钟不同步就会造成掉帧、卡顿、不连贯等问题。
为了解决这些问题,就需要将显示器的时钟同步周期和浏览器生成页面的周期绑定起来,Chromium 也是这样实现,那么下面我们就来看看 Chromium 具体是怎么实现的?
**当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization)给 GPU,简称 VSync。**这时候浏览器就会充分利用好 VSync 信号。
具体地讲,当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再将其同步到对应的渲染进程,渲染进程接收到 VSync 信号之后,就可以准备绘制新的一帧了,具体流程你可以参考下图:
上面其实是非常粗略的介绍,实际实现过程也是非常复杂的,如果感兴趣,你可以参考这篇文章。
好了,我们花了很大篇幅介绍了 VSync 和页面中的一帧是怎么显示出来,有了这些知识,我们就可以回到主线了,来分析下渲染进程是如何优化交互阶段页面的任务调度策略的?
从上图可以看出,当渲染进程接收到用户交互的任务后,接下来大概率是要进行绘制合成操作,因此我们可以设置,当在执行用户交互的任务时,将合成任务的优先级调整到最高。
接下来,处理完成 DOM,计算好布局和绘制,就需要将信息提交给合成线程来合成最终图片了,然后合成线程进入工作状态。现在的场景是合成线程在工作了,那么我们就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升。
在合成完成之后,合成线程会提交给渲染主线程提交完成合成的消息,如果当前合成操作执行的非常快,比如从用户发出消息到完成合成操作只花了 8 毫秒,因为 VSync 同步周期是 16.66(1/60)毫秒,那么这个 VSync 时钟周期内就不需要再次生成新的页面了。那么从合成结束到下个 VSync 周期内,就进入了一个空闲时间阶段,那么就可以在这段空闲时间内执行一些不那么紧急的任务,比如 V8 的垃圾回收,或者通过 window.requestIdleCallback() 设置的回调任务等,都会在这段空闲时间内执行。
4. 第四次迭代:任务饿死
好了,以上方案看上去似乎非常完美了,不过依然存在一个问题,那就是在某个状态下,一直有新的高优先级的任务加入到队列中,这样就会导致其他低优先级的任务得不到执行,这称为任务饿死。
Chromium 为了解决任务饿死的问题,给每个队列设置了执行权重,也就是如果连续执行了一定个数的高优先级的任务,那么中间会执行一次低优先级的任务,这样就缓解了任务饿死的情况。
WebAPI:XMLHttpRequest是怎么实现的?
在上一篇文章中我们介绍了 setTimeout 是如何结合渲染进程的循环系统工作的,那本篇文章我们就继续介绍另外一种类型的 WebAPI——XMLHttpRequest。
自从网页中引入了 JavaScript,我们就可以操作 DOM 树中任意一个节点,例如隐藏 / 显示节点、改变颜色、获得或改变文本内容、为元素添加事件响应函数等等, 几乎可以“为所欲为”了。
不过在 XMLHttpRequest 出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。而 XMLHttpRequest 提供了从 Web 服务器获取数据的能力,如果你想要更新某条数据,只需要通过 XMLHttpRequest 请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容,整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。
关于 XMLHttpRequest,本来我是想一带而过的,后来发现这个 WebAPI 用于教学非常好。首先前面讲了那么网络内容,现在可以通过它把 HTTP 协议实践一遍;其次,XMLHttpRequest 是一个非常典型的 WebAPI,通过它来讲解浏览器是如何实现 WebAPI 的很合适,这对于你理解其他 WebAPI 也有非常大的帮助,同时在这个过程中我们还可以把一些安全问题给串起来。
但在深入讲解 XMLHttpRequest 之前,我们得先介绍下同步回调和异步回调这两个概念,这会帮助你更加深刻地理解 WebAPI 是怎么工作的。
回调函数 VS 系统调用栈
那什么是回调函数呢(Callback Function)?
将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。简化的代码如下所示:
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
cb()
console.log('end do work')
}
doWork(callback)
在上面示例代码中,我们将一个匿名函数赋值给变量 callback,同时将 callback 作为参数传递给了 doWork() 函数,这时在函数 doWork() 中 callback 就是回调函数。
上面的回调方法有个特点,就是回调函数 callback 是在主函数 doWork 返回之前执行的,我们把这个回调过程称为同步回调。
既然有同步回调,那肯定也有异步回调。下面我们再来看看异步回调的例子:
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
setTimeout(cb,1000)
console.log('end do work')
}
doWork(callback)
在这个例子中,我们使用了 setTimeout 函数让 callback 在 doWork 函数执行结束后,又延时了 1 秒再执行,这次 callback 并没有在主函数 doWork 内部被调用,我们把这种回调函数在主函数外部执行的过程称为异步回调。
现在你应该知道什么是同步回调和异步回调了,那下面我们再深入点,站在消息循环的视角来看看同步回调和异步回调的区别。理解了这些,可以让你从本质上理解什么是回调。
我们还是先来回顾下页面的事件循环系统,通过《15 | 消息队列和事件循环:页面是怎么“活”起来的?》的学习,你应该已经知道浏览器页面是通过事件循环机制来驱动的,每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行 JavaScript 事件、解析 DOM 事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到事件队列的尾部。所以可以说是消息队列和主线程循环机制保证了页面有条不紊地运行。
这里还需要补充一点,那就是当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。这个系统调用栈类似于 JavaScript 的调用栈,只不过系统调用栈是 Chromium 的开发语言 C++ 来维护的,其完整的调用栈信息你可以通过 chrome://tracing/ 来抓取。当然,你也可以通过 Performance 来抓取它核心的调用信息,如下图所示:
这幅图记录了一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。
通过该图你可以看出来,Parse HTML 任务在执行过程中会遇到一系列的子过程,比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。
需要说明的是,整个 Parse HTML 是一个完整的任务,在执行过程中的脚本解析、样式表解析都是该任务的子过程,其下拉的长条就是执行过程中调用栈的信息。
每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,这个没有太多可讲的。下面我们主要来看看异步回调过程,异步回调是指回调函数在主函数之外执行,一般有两种方式:
第一种是把异步函数做成一个任务,添加到信息队列尾部;
第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
XMLHttpRequest 运作机制
理解了什么是同步回调和异步回调,接下来我们就来分析 XMLHttpRequest 背后的实现机制,具体工作过程你可以参考下图:
这是 XMLHttpRequest 的总执行流程图,下面我们就来分析从发起请求到接收数据的完整流程。
我们先从 XMLHttpRequest 的用法开始,首先看下面这样一段请求代码:
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
xhr.ontimeout = function(e) { console.log('ontimeout') }
xhr.onerror = function(e) { console.log('onerror') }
/**
* 3:打开请求
*/
xhr.open('Get', URL, true);//创建一个Get请求,采用异步
/**
* 4:配置参数
*/
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
/**
* 5:发送请求
*/
xhr.send();
}
上面是一段利用了 XMLHttpRequest 来请求数据的代码,再结合上面的流程图,我们可以分析下这段代码是怎么执行的。
第一步:创建 XMLHttpRequest 对象。
当执行到let xhr = new XMLHttpRequest()后,JavaScript 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。
第二步:为 xhr 对象注册回调函数。
因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。
XMLHttpRequest 的回调函数主要有下面几种:
ontimeout,用来监控超时请求,如果后台请求超时了,该函数会被调用;
onerror,用来监控出错信息,如果后台请求出错了,该函数会被调用;
onreadystatechange,用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等。
第三步:配置基础的请求信息。
注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。
然后通过 xhr 内部属性类配置一些其他可选的请求信息,你可以参考文中示例代码,我们通过xhr.timeout = 3000来配置超时时间,也就是说如果请求超过 3000 毫秒还没有响应,那么这次请求就被判断为失败了。
我们还可以通过xhr.responseType = “text"来配置服务器返回的格式,将服务器返回的数据自动转换为自己想要的格式,如果将 responseType 的值设置为 json,那么系统会自动将服务器返回的数据转换为 JavaScript 对象格式。下面的图表是我列出的一些返回类型的描述:
假如你还需要添加自己专用的请求头属性,可以通过 xhr.setRequestHeader 来添加。
第四步:发起请求。
一切准备就绪之后,就可以调用xhr.send来发起网络请求了。你可以对照上面那张请求流程图,可以看到:渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
如果网络请求出错了,就会执行 xhr.onerror;
如果超时了,就会执行 xhr.ontimeout;
如果是正常的数据接收,就会执行 onreadystatechange 来反馈相应的状态。
这就是一个完整的 XMLHttpRequest 请求流程,如果你感兴趣,可以参考下 Chromium 对 XMLHttpRequest 的实现,点击这里查看代码。
XMLHttpRequest 使用过程中的“坑”
上述过程看似简单,但由于浏览器很多安全策略的限制,所以会导致你在使用过程中踩到非常多的“坑”。
浏览器安全问题是前端工程师避不开的一道坎,通常在使用过程中遇到的“坑”,很大一部分都是由安全策略引起的,不管你喜不喜欢,它都在这里。本来很完美的一个方案,正是由于加了安全限制,导致使用起来非常麻烦。
而你要做的就是去正视这各种的安全问题。也就是说要想更加完美地使用 XMLHttpRequest,你就要了解浏览器的安全策略。
下面我们就来看看在使用 XMLHttpRequest 的过程中所遇到的跨域问题和混合内容问题。
1. 跨域问题
比如在极客邦的官网使用 XMLHttpRequest 请求极客时间的页面内容,由于极客邦的官网是www.geekbang.org,极客时间的官网是time.geekbang.org,它们不是同一个源,所以就涉及到了跨域(在 A 站点中去访问不同源的 B 站点的内容)。默认情况下,跨域请求是不被允许的,你可以看下面的示例代码:
var xhr = new XMLHttpRequest()
var url = 'https://time.geekbang.org/'
function handler() {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
function callOtherDomain() {
if(xhr) {
xhr.open('GET', url, true)
xhr.onreadystatechange = handler
xhr.send();
}
}
callOtherDomain()
你可以在控制台测试下。首先通过浏览器打开www.geekbang.org,然后打开控制台,在控制台输入以上示例代码,再执行,会看到请求被 Block 了。控制台的提示信息如下:
Access to XMLHttpRequest at 'https://time.geekbang.org/' from origin 'https://www.geekbang.org' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
因为 www.geekbang.org 和 time.geekbang.com 不属于一个域,所以以上访问就属于跨域访问了,这次访问失败就是由于跨域问题导致的。
2. HTTPS 混合内容的问题
了解完跨域问题后,我们再来看看 HTTPS 的混合内容。HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。
通常,如果 HTTPS 请求页面中使用混合内容,浏览器会针对 HTTPS 混合内容显示警告,用来向用户表明此 HTTPS 页面包含不安全的资源。比如打开站点 https://www.iteye.com/groups ,可以通过控制台看到混合内容的警告,参考下图:
从上图可以看出,通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求。比如我通过浏览器打开地址 https://www.iteye.com/groups ,然后通过控制台,使用 XMLHttpRequest 来请求 http://img-ads.csdn.net/2018/201811150919211586.jpg ,这时候请求就会报错,出错信息如下图所示:
总结
首先,为了支持定时器的实现,浏览器增加了延时队列。
其次,由于消息队列排队和一些系统级别的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行,这样就不能满足一些实时性要求较高的需求了。
最后,在定时器中使用过程中,还存在一些陷阱,需要你多加注意。
通过分析和讲解,你会发现函数 setTimeout 在时效性上面有很多先天的不足,所以对于一些时间精度要求比较高的需求,应该有针对性地采取一些其他的方案。
关于rAF,首先我们分析了基于单消息队列会引起队头阻塞的问题,为了解决队头阻塞问题,我们引入了多个不同优级的消息队列,并将紧急的任务添加到高优先级队列,不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或者合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,所以我们又迭代了第二个版本。
在第二个版本中,按照不同的任务类型来划分任务优先级,不过由于采用的静态优先级策略,对于其他一些场景,这种静态调度的策略并不是太适合,所以接下来,我们又迭代了第三版。
第三个版本,基于不同的场景来动态调整消息队列的优先级,到了这里已经非常完美了,不过依然存在着任务饿死的问题,为了解决任务饿死的问题,我们给每个队列一个权重,如果连续执行了一定个数的高优先级的任务,那么中间会执行一次低优先级的任务,这样我们就完成了 Chromium 的任务改造。
对比setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。如果你搞懂了 setTimeout 和 XMLHttpRequest 的工作机制后,再来理解其他 WebAPI 就会轻松很多了,因为大部分 WebAPI 的工作逻辑都是类似的。
思考时间
今天我们介绍了 setTimeout,相信你现在也知道它是怎么工作的了,不过由于使用 setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。比如你要使用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。
那么今天留给你的作业是:你需要网上搜索了解下 requestAnimationFrame 的工作机制,并对比 setTimeout,然后分析出 requestAnimationFrame 实现的动画效果比 setTimeout 好的原因。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。