前言
XMLHttpRequest可能大家都知道,但是直接使用不多(一般都是用Axios啦)。在看完Axios的源码之后,就想着从W3C标准上了解一下XMLHttpRequest,说不定能收获一些平时没有注意到的内容。若不期然,以下是一篇以笔记为主的记录,主要是通过阅读W3C文档理解的,如果我理解上有任何误解(英文有点烂),欢迎各位大佬指出,谢谢~
如何使用
const xhr = new XMLHttpRequest();
xhr.open('get', '/get', true);
xhr.onreadystatechange = function() {
console.log('readystate: ', xhr.readyState);
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log(xhr.response);
}
}
};
xhr.send('1234');
事件
| 事件处理器 | 事件名(类型) |
|---|---|
| onloadstart | loadstart |
| onprogress | progress |
| onabort | abort |
| onerror | error |
| onload | load |
| ontimeout | timeout |
| onloadend | loadend |
值得一提的是,XMLHttpRequest和XMLHttpRequestUpload都继承自XMLHttpRequestEventTarget,所有都拥有上述的事件处理器。
但只有XMLHttpRequest才拥有onreadystatechange事件处理器。
XHR的状态
XMLHttpRequest实例的状态如下图所示:
| 状态名 | 状态值 | 描述 |
|---|---|---|
| unsent | 0 | 实例对象被创建,但open()方法未调用 |
| opened | 1 | open()方法被成功调用,在这个状态可以通过setRequestHeader()设置请求头,并且可以通过调用send()方法发起请求。 |
| headers received | 2 | send()方法被调用之后,并且接收到响应的所有HTTP头部信息 |
| loading | 3 | 正在接收请求体 |
| done | 4 | 数据全部传输完成或者中途出现了错误 |
对于要跟踪XHR的状态,我们可以通过onreadystatechange事件获取:
xhr.onreadystatechange = function(x) {
console.log(xhr.readyState);
};
XHR的头部信息
默认情况下,XHR在发送请求的同时会发送以下头部信息:
- Accept
- Accept-Charset
- Accept-Encoding
- Accept-Language
- Connection
- Cookie
- Host
- Referer
- User-Agent
不同浏览器实际发送的头部信息会有所不同,但以上这些基本上是所有浏览器都会发送的。
添加请求Header
对于其他自定义的头部信息,可以通过xhr.setRequestHeader进行添加。
注意:
setRequestHeader方法必须在open()方法调用之后,send()方法调用之前调用,否则会抛出InvalidStateError的错误。
const xhr = new XMLHttpRequest();
xhr.open('post', '/post', true);
xhr.onreadystatechange = function(x) {
console.log(xhr.readyState);
};
xhr.setRequestHeader('X-A', 'aaa');
xhr.setRequestHeader('X-A', 'bbb');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Content-Type', 'application/json2');
xhr.setRequestHeader('Content-Type', 'application/json3');
xhr.send('1234');
// 最终的头部是:
// X-A: aaa, bbb
// Content-Type: application/json, application/json2, application/json3
需要注意的是,通过setRequestHeader进行添加的头部,不会进行覆盖,而是会拼接在一起。
获取响应Header
获取响应头部信息可以通过getAllResponseHeaders()方法,它返回一个字符串,并且每一条头部信息都是通过\r\n分隔。
const xhr = new XMLHttpRequest();
xhr.open('post', '/post', true);
xhr.onreadystatechange = function(x) {
if (xhr.readyState === xhr.HEADERS_RECEIVED) {
console.log(xhr.getAllResponseHeaders());
}
};
xhr.send(null);
// connection: keep-alive
// content-length: 11
// content-type: text/html; charset=utf-8
// date: Thu, 14 May 2020 13:19:08 GMT
// etag: W/"b-SeRn+P0S5Cv7Z2+z+paQB3qapuc"
// x-powered-by: Express
还可以通过getResponseHeader()方法获取指定的头部值,并且传入的值是不区分大小写的,你可以写Content-Type、content-type甚至是ConTent-TYPE。
const xhr = new XMLHttpRequest();
xhr.open('post', '/post', true);
xhr.onreadystatechange = function(x) {
if (xhr.readyState === xhr.HEADERS_RECEIVED) {
console.log(xhr.getResponseHeader('Content-Type'));
console.log(xhr.getResponseHeader('content-type'));
console.log(xhr.getResponseHeader('Content-type'));
}
};
xhr.send(null);
// text/html; charset=utf-8
// text/html; charset=utf-8
// text/html; charset=utf-8
但是需要注意的是,getResponseHeader()以及getAllResponseHeaders()都并不是允许获取所有头部信息。
W3C对XMLHttpRequest Level1进行了如下限制:
- 客户端无法获取
Set-Cookie以及Set-Cookie2这两个字段
由于XMLHttpRequest Level1并不支持跨域请求,因此,XMLHttpRequest Level2对于跨域请求也做了限制:对于跨域请求,客户端只能获取response headers中属于simple response header或Access-Control-Expose-Headers中的头部字段。
其中simple response header指的是:
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
const xhr = new XMLHttpRequest();
xhr.open('get', '/get', true);
xhr.onreadystatechange = function(x) {
if (xhr.readyState === xhr.HEADERS_RECEIVED) {
console.log(xhr.getResponseHeader('Set-Cookie'));
}
};
xhr.send(null);
// 报错:Refused to get unsafe header "Set-Cookie"
因此,getAllResponseHeaders()只能获取结合上述限制之内的头部信息的集合,而getResponseHeader(headerName)中的HeaderName必须是以上限制以内的字段,否则就会报错:Refused to get unsafe header "HeaderName"。
设置超时时限
xhr.timeout = 10000;
// 10 秒后未请求完成的话就超时
timeout属性以毫秒为单位,可以设置一个非0的值作为”经过多少毫秒后结束请求“的限制。
timeout其实很好理解,当执行xhr.send()方法时开始计时,直到loadend事件触发时计时结束。如果时间超出后并未触发loadend事件,那么就会触发timeout事件。
需要注意的是,即使我们在请求的途中设置timeout,例如在onprogress事件处理程序中设置,它的基准都是基于loadstart事件触发时的时间基准计算的。
| 时间 | 过程1 | 过程2 |
|---|---|---|
| 0s | send() | send() |
| 5s | timeout=6000 | timeout=12000 |
| 6s | 超时,触发ontimeout |
|
| 10s | 响应成功 | |
| 12s | 这里才超时,但已经响应成功了,不会触发ontimeout |
此外,timeout只适用于异步请求,如果当前XHR是同步的(并且全局对象为Window),那么就会抛出InvalidAccessError的错误。
发送请求体
通过XHR发送请求时,我们都知道,发送请求体是通过send()方法:
xhr.send(data);
首先,send()方法调用前XHR实例的状态必须是opened,也就是说,必须先调用open()方法才可以最终调用send()方法发出请求。当状态不是opened或者重复调用send()方法,都会抛出InvalidStateError异常。
其次,对于GET和HEAD请求,不管你传不传入data,最终都会被忽略掉。也就是说,通过XMLHttpRequest发送请求时,GET和HEAD请求不会携带请求体,如果需要传参,需要通过URL拼接。
对于非GET和HEAD请求,send(data)方法接收一个参数作为请求体传入。并且XHR会根据传入的数据的类型来更改请求头的Content-Type字段:
- 如果是
HTML Document类型,那么会设置为text/html;charset=UTF-8。 - 如果是
XML document类型,那么会设置为application/xml;charset=UTF-8。 - 如果是
FormData类型,那么会设置为multipart/form-data; boundary=xxxxxxx。 - 如果是
DOMString类型,那么会设置为text/plain;charset=UTF-8。 - 如果是
URLSearchParams,那么会设置为application/x-www-form-urlencoded;charset=UTF-8。 - 如果是其他类型,那么不会添加
Content-Type字段。
另外,我们也可以通过xhr.setRequestHeader()设置Content-Type,并且它会覆盖上述由XHR自动判断而添加的头部。
设置响应数据的类型
设置响应数据返回的类型可以有两种方法,分别是level1的overrideMimeType()方法以及level2的xhr.responseType属性。
根据W3C的描述,overrideMimeType(mime)设置的mime跟HTTP头部Content-Type的mime是相似的。通过该方法可以修改xhr.response返回的数据类型。
而xhr.responseType则是level2新增的一个属性,默认为空,可选值有arraybuffer、blob、document、json、text。需要注意的是,如果在XHR的状态是loading或done时再更改responseType的值,会抛出InvalidStateError异常。
鉴于responseType的兼容性得到改善,overrideMimeType()似乎已经很少再使用了。
获取响应数据
一般我们通过XHR获取响应数据都是通过xhr.response、xhr.responseText获取。
response
对于xhr.responseType为空或者值为text时:
- 如果XHR状态不是loading或者done,都返回空字符串
- 否则返回已经接收的数据文本(loading时是部分数据,done时是全部数据)
对于xhr.responseType为arraybuffter、blob、document、json时:
- 如果状态为完成或请求失败,返回
null。 - 如果是
arraybuffter,返回ArrayBuffer对象(如果转换失败则返回null)。 - 如果是
blob,返回Blob对象。 - 如果是
document,返回Document对象。 - 如果是
json,返回json对象(如果解析失败则返回null)。
responseText
xhr.responseText只有在xhr.responseType的值为空或者text时有效,其余情况会调用时会抛出InvalidStateError异常。
监控上传过程
通过xhr.upload可以访问XMLHttpRequestUpload对象,并且,每一个XMLHttpRequest对象都有一个相关联的XMLHttpRequestUpload对象。
XMLHttpRequestUpload和XMLHttpRequest都继承自XMLHttpRequestEventTarget,根据XMLHttpRequestEventTarget的接口描述可以知道,他们两个都有以下的事件处理程序:
interface XMLHttpRequestEventTarget : EventTarget {
// event handlers
attribute EventHandler onloadstart;
attribute EventHandler onprogress;
attribute EventHandler onabort;
attribute EventHandler onerror;
attribute EventHandler onload;
attribute EventHandler ontimeout;
attribute EventHandler onloadend;
};
[Exposed=(Window,DedicatedWorker,SharedWorker)]
interface XMLHttpRequestUpload : XMLHttpRequestEventTarget {
};
也就是说,通过xhr.upload.onprogress可以监控整个上传的进度过程,这常用于日常业务当中。
xhr.upload.onprogress = (e) => {
console.log(`upload: ${e.loaded / e.total * 100}%`)
};
上传与下载的事件触发顺序
const xhr = new XMLHttpRequest();
xhr.open('post', '/post', true);
xhr.upload.onloadstart = () => { console.log('upload loadstart') };
xhr.upload.onloadend = () => { console.log('upload loadend') };
xhr.upload.onload = () => { console.log('upload load') };
xhr.upload.onprogress = (e) => {
console.log(`upload: ${e.loaded / e.total * 100}%`)
};
xhr.onloadstart = () => { console.log('xhr loadstart') };
xhr.onloadend = () => { console.log('xhr loadend') };
xhr.onload = () => { console.log('xhr load') };
xhr.onprogress = (e) => {
console.log(`xhr: ${e.loaded / e.total * 100}%`)
};
xhr.send(new URLSearchParams('a=1&b=2'));
// xhr loadstart
// upload loadstart
// upload: 100%
// upload load
// upload loadend
// xhr: 100%
// xhr load
// xhr loadend
可以看到,XHR的progress事件是代表下载过程的进度,而上传过程则交给xhr.upload对象中的progress事件。
同步请求
如果你认为同步请求和异步请求的区别只在于阻塞和非阻塞,那么就错了。
当使用XHR发送同步请求时会有如下的限制:
xhr.timeout必须为0(或者不设置,默认为0)xhr.withCredentials必须为falsexhr.responseType必须为空字符串
如果上述任意条件不满足,都会抛出InvalidAccessError异常。
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
console.log(xhr.readyState);
};
xhr.open('post', '/post', false);
xhr.upload.onloadstart = () => { console.log('upload loadstart') };
xhr.upload.onloadend = () => { console.log('upload loadend') };
xhr.upload.onload = () => { console.log('upload load') };
xhr.upload.onprogress = (e) => {
console.log(`upload: ${e.loaded / e.total * 100}%`)
};
xhr.onloadstart = () => { console.log('xhr loadstart') };
xhr.onloadend = () => { console.log('xhr loadend') };
xhr.onload = () => { console.log('xhr load') };
xhr.onprogress = (e) => {
console.log(`xhr: ${e.loaded / e.total * 100}%`)
};
xhr.send(new URLSearchParams('a=1&b=2'));
// 1
// 4
// xhr load
// xhr loadend
此外,从上述代码的输出可以看到,同步请求的readystatechange只会触发1和4状态,也就是opened和done时触发,而header_received和loading时并不会触发。也就是说,同步请求并不能触发过程的回调,因为xhr.upload的事件都没有触发,而xhr的事件仅触发了oload、loadend、readystatechange(仅两次)。
同步请求的限制简单来说就是:不能设置超时、跨域时不能携带Cookie、不能设置返回类型、无法监控上传和下载的过程、阻塞主线程。
withCredentials
在进行CORS跨域的时候,大家都知道如果想要在跨域上携带Cookie,那么就需要把XHR实例的withCredentials设置为true。
xhr.withCredentials = true;
如果不设置,withCredentials默认为false。当withCredentials=false时,在跨域请求下,请求头不会携带Cookie并且浏览器会忽略响应头部的Set-Cookie字段,也就是即使是响应中拥有Set-Cookie字段,该cookie不会保留到浏览器,而是会被忽略。
此外,当withCredentials=true时,响应头部必须有一个Access-Control-Allow-Credentials: true的字段,并且值为true。当值为false,那么该跨域请求就会被浏览器拦截下来。
最后需要注意的一点时,当withCredentials=true时发起的跨域请求,服务到期端不能将Access-Control-Allow-Origin设置为*,必须为请求页面的具体域名,否则该跨域请求同样会被拦截。
PS: withCredentials属性对同域请求无任何影响
调用 send() 之后发生了什么(异步状态)
准备阶段
- 如果XHR的状态不是
opened(也就是还没调用open()方法),或者重复调用send()方法,就抛出InvalidStateError的异常。 - 如果请求方法为
GET或者HEAD,把请求body设置为null,即使你传入的数据,也会被忽略。 - 如果
body不为空,那么XHR实例会根据body的类型自动填充请求头Content-Type的值,一般遵循这些规则:Blob:通过该Blob对象的type属性获取MIME类型FormData:multipart/form-data; boundary=URLSearchParams:application/x-www-form-urlencoded;charset=UTF-8USVString:text/plain;charset=UTF-8HTML Document:Content-Type/text/html;charset=UTF-8XML Document:application/xml;charset=UTF-8
- 如果
body为空,设置upload complete flag(即xhr.upload中的事件不会触发) - 触发
loadstart时间,并且参数event.loaed = 0且event.total = 0 - 如果
upload complete flag未设置并且xhr.upload注册了事件侦听器,那么就触发upload.loadstart事件,同样参数event.loaed = 0且event.total = 0。
请求阶段
在整个过程中会不断地并行执行这两个任务:
- 只要请求未完成,就不断地计算
timeout的剩余时间。(如果timeout=0就不执行) - 当超出了
timeout时间,就把请求标识为完成状态,并且标识为超时以及终止请求。
处理请求body
请求body的上传过程
只当有新的数据被传输,才会触发以下的处理步骤:
- 自带50ms的节流,如果上次触发至今还没有50ms,那就不走第二步了
- 如果
xhr.upload.onprogress有事件处理器,那么就触发,参数event.loaded=已传输的字节数,event.total=总字节数。
请求body的上传结束
- 设置
upload complete flag - 如果
xhr.upload没有设置事件处理器,那么下面的步骤就不进行了。 - 设
transimitted = 已传输的字节数 - 设
length = body总字节数 - 分别按顺序触发
xhr.upload的progress、load、loadend事件处理回调,参数event.loaded = transimitted、event.total = length。
响应阶段
响应头部信息的接收
- 如果有异常则进入异常处理,返回。
- 当头部信息接收完毕后,XHR实例状态进入到
headers received。 - 触发
readystatechange事件
响应body的接收过程
- 如果有异常则进入异常处理,返回。
- 第一次执行时XHR实例状态从
headers received转为loading - 通过流接收数据,并且自带50ms节流。
- 触发
readystatechange事件 - 触发
progress事件 - 当接收完毕后进入“响应
body的结束过程”,否则重复触发上面的步骤 - 当有异常时进入“异常处理过程”
响应body的结束过程
- 异常处理(网络异常、响应异常)
- 触发
progress事件 - XHR实例的状态从
loading转为done - 触发
readystatechange事件 - 触发
load事件 - 触发
loaded事件
异常处理
- 对于
timeout的超时错误,触发TimeoutError的异常 - 对于网络错误,触发
NetworkError的异常 - 对于主动调用
abort()方法取消请求,触发AbortError异常
以上三种异常情况都会触发以下的异常处理步骤:
- 把XHR的实例状态设置为
done - 把
response设置为一个NetworkError实例 - 触发
readystatechange事件 - 如果上传过程还没完成:
- 标识上传完成
- 触发
xhr.upload.progress事件,参数中event.loaded = event.total = 0 - 触发
loadend事件,参数同上
- 触发
progress事件,参数同上 - 触发
loadend事件,参数同上
小结
- 从整个流程可以看到各类事件的触发顺序,另外
readystatechange事件触发的次数比XHR实例状态转变的次数要多,这里的原因从W3C描述是兼容性的问题。(Web compatibility is the reason readystatechange fires more often than state changes.) - 对于
progress类事件,都有自带至少50ms的节流,同时,只有在有新数据字节上传或下载后才会触发。(These steps are only invoked when new bytes are transmitted.)
最后
W3C文档确实隐藏着平时很多可能没有注意到的细节,整个文档我看了大概有两天,细节之处非常多,也了解了一些平时没有注意到的东西,也算是有所收获吧~
如果各位大佬喜欢,能点个赞就十分感谢啦~
最后,附上参考链接:xhr.spec.whatwg.org/