关注小程序 找一找教程网-随时随地学编程

XML教程

你可能不知道的XMLHttpRequest

前言

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-Typecontent-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 headerAccess-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异常。

其次,对于GETHEAD请求,不管你传不传入data,最终都会被忽略掉。也就是说,通过XMLHttpRequest发送请求时,GETHEAD请求不会携带请求体,如果需要传参,需要通过URL拼接。

对于非GETHEAD请求,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自动判断而添加的头部。

设置响应数据的类型

设置响应数据返回的类型可以有两种方法,分别是level1overrideMimeType()方法以及level2xhr.responseType属性。

根据W3C的描述,overrideMimeType(mime)设置的mime跟HTTP头部Content-Type的mime是相似的。通过该方法可以修改xhr.response返回的数据类型。

xhr.responseType则是level2新增的一个属性,默认为空,可选值有arraybufferblobdocumentjsontext需要注意的是,如果在XHR的状态是loadingdone时再更改responseType的值,会抛出InvalidStateError异常。

鉴于responseType的兼容性得到改善,overrideMimeType()似乎已经很少再使用了。

获取响应数据

一般我们通过XHR获取响应数据都是通过xhr.responsexhr.responseText获取。

response

对于xhr.responseType为空或者值为text时:

  • 如果XHR状态不是loading或者done,都返回空字符串
  • 否则返回已经接收的数据文本(loading时是部分数据,done时是全部数据)

对于xhr.responseTypearraybuffterblobdocumentjson时:

  • 如果状态为完成或请求失败,返回null
  • 如果是arraybuffter,返回ArrayBuffer对象(如果转换失败则返回null)。
  • 如果是blob,返回Blob对象。
  • 如果是document,返回Document对象。
  • 如果是json,返回json对象(如果解析失败则返回null)。

responseText

xhr.responseText只有在xhr.responseType的值为空或者text时有效,其余情况会调用时会抛出InvalidStateError异常。

监控上传过程

通过xhr.upload可以访问XMLHttpRequestUpload对象,并且,每一个XMLHttpRequest对象都有一个相关联的XMLHttpRequestUpload对象。

XMLHttpRequestUploadXMLHttpRequest都继承自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必须为false
  • xhr.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状态,也就是openeddone时触发,而header_receivedloading时并不会触发。也就是说,同步请求并不能触发过程的回调,因为xhr.upload的事件都没有触发,而xhr的事件仅触发了oloadloadendreadystatechange(仅两次)。

同步请求的限制简单来说就是:不能设置超时、跨域时不能携带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,那么该跨域请求就会被浏览器拦截下来。

image

最后需要注意的一点时,当withCredentials=true时发起的跨域请求,服务到期端不能将Access-Control-Allow-Origin设置为*,必须为请求页面的具体域名,否则该跨域请求同样会被拦截。

image

PS: withCredentials属性对同域请求无任何影响

调用 send() 之后发生了什么(异步状态)

准备阶段

  1. 如果XHR的状态不是opened(也就是还没调用open()方法),或者重复调用send()方法,就抛出InvalidStateError的异常。
  2. 如果请求方法为GET或者HEAD,把请求body设置为null,即使你传入的数据,也会被忽略。
  3. 如果body不为空,那么XHR实例会根据body的类型自动填充请求头Content-Type的值,一般遵循这些规则:
    • Blob:通过该Blob对象的type属性获取MIME类型
    • FormData: multipart/form-data; boundary=
    • URLSearchParams: application/x-www-form-urlencoded;charset=UTF-8
    • USVString: text/plain;charset=UTF-8
    • HTML Document: Content-Type/text/html;charset=UTF-8
    • XML Document: application/xml;charset=UTF-8
  4. 如果body为空,设置upload complete flag(即xhr.upload中的事件不会触发)
  5. 触发loadstart时间,并且参数event.loaed = 0event.total = 0
  6. 如果upload complete flag未设置并且xhr.upload注册了事件侦听器,那么就触发upload.loadstart事件,同样参数event.loaed = 0event.total = 0

请求阶段

在整个过程中会不断地并行执行这两个任务:

  1. 只要请求未完成,就不断地计算timeout的剩余时间。(如果timeout=0就不执行)
  2. 当超出了timeout时间,就把请求标识为完成状态,并且标识为超时以及终止请求。

处理请求body

请求body的上传过程

只当有新的数据被传输,才会触发以下的处理步骤:

  1. 自带50ms的节流,如果上次触发至今还没有50ms,那就不走第二步了
  2. 如果xhr.upload.onprogress有事件处理器,那么就触发,参数event.loaded=已传输的字节数event.total=总字节数
请求body的上传结束
  1. 设置upload complete flag
  2. 如果xhr.upload没有设置事件处理器,那么下面的步骤就不进行了。
  3. transimitted = 已传输的字节数
  4. length = body总字节数
  5. 分别按顺序触发xhr.uploadprogressloadloadend事件处理回调,参数event.loaded = transimittedevent.total = length

响应阶段

响应头部信息的接收
  1. 如果有异常则进入异常处理,返回。
  2. 当头部信息接收完毕后,XHR实例状态进入到headers received
  3. 触发readystatechange事件
响应body的接收过程
  1. 如果有异常则进入异常处理,返回。
  2. 第一次执行时XHR实例状态从headers received转为loading
  3. 通过流接收数据,并且自带50ms节流。
  4. 触发readystatechange事件
  5. 触发progress事件
  6. 当接收完毕后进入“响应body的结束过程”,否则重复触发上面的步骤
  7. 当有异常时进入“异常处理过程”
响应body的结束过程
  1. 异常处理(网络异常、响应异常)
  2. 触发progress事件
  3. XHR实例的状态从loading转为done
  4. 触发readystatechange事件
  5. 触发load事件
  6. 触发loaded事件

异常处理

  1. 对于timeout的超时错误,触发TimeoutError的异常
  2. 对于网络错误,触发NetworkError的异常
  3. 对于主动调用abort()方法取消请求,触发AbortError异常

以上三种异常情况都会触发以下的异常处理步骤:

  1. 把XHR的实例状态设置为done
  2. response设置为一个NetworkError实例
  3. 触发readystatechange事件
  4. 如果上传过程还没完成:
    • 标识上传完成
    • 触发xhr.upload.progress事件,参数中event.loaded = event.total = 0
    • 触发loadend事件,参数同上
  5. 触发progress事件,参数同上
  6. 触发loadend事件,参数同上

小结

  1. 从整个流程可以看到各类事件的触发顺序,另外readystatechange事件触发的次数比XHR实例状态转变的次数要多,这里的原因从W3C描述是兼容性的问题。(Web compatibility is the reason readystatechange fires more often than state changes.)
  2. 对于progress类事件,都有自带至少50ms的节流,同时,只有在有新数据字节上传或下载后才会触发。(These steps are only invoked when new bytes are transmitted.)

最后

W3C文档确实隐藏着平时很多可能没有注意到的细节,整个文档我看了大概有两天,细节之处非常多,也了解了一些平时没有注意到的东西,也算是有所收获吧~

如果各位大佬喜欢,能点个赞就十分感谢啦~

最后,附上参考链接:xhr.spec.whatwg.org/