前言:该篇说明:请见 说明 —— 浏览器工作原理与实践 目录
在上一篇文章中我们介绍了 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 的总执行流程图,下面我们就来分析从发起请求到接收数据的完整流程。
我们先从 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 对象注册回调函数
因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。
XMLHttpReques 的回调函数主要有下面几种:
- ontimeout,用来监控超市请求,如果后台请求超时了,该函数会被调用;
- onerror,用来监控出错信息,如果后台请求出错了,该函数会被调用;
- onreadystatechange,用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等。
第三步:配置基础的请求消息
注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post )和请求方式(同步还是异步请求)。
然后通过 xhr 内部属性类配置一些其他可选的请求信息,你可以参考文中示例代码,我们通过 xhr.timeour = 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 的过程中所遇到的跨域问题和混合内容问题。