向服务器发起请求(XHR/CORS、Fetch、WebSocket)

2,139 阅读26分钟

XHR

Ajax推到历史舞台上的关键技术是XMLHttpRequest(XHR)对象。

XHR出现之前,Ajax风格的通信必须通过一些黑科技实现,主要是使用隐藏的窗格或内嵌窗格。

XHR为发送服务器请求和获取响应提供了合理的接口,这个接口可以实现异步从服务器获取额外数据。

最初,JS对服务器的请求可以通过中介(如Java小程序或Flash影片)来发送。后来XHR对象又为开发者提供了原生的浏览器通信能力,减少了实现这个目的的工作量

XHR的一个主要限制是同源策略,即通信只能在相同域名、相同端口和相同协议的前提下完成。除非使用了正式的跨域方案(CORS

XHR对象的API被普遍认为比较难用,而Fetch API自从诞生以后就迅速成为了XHR更现代的替代标准

注意,只能访问同源URL,也就是域名相同、端口相同、协议相同。如果请求的URL与发送请求的页面在任何方面有所不同,则会抛出安全错误。

使用XHR

// 所有现代浏览器都通过XMLHttpRequest构造函数原生支持XHR对象
let xhr = new XMLHttpRequest();
// 使用XHR对象首先要调用open()方法
// 这个方法接收3个参数:请求类型,请求URL,以及表示请求是否异步的布尔值
xhr.open('get', 'example.php', false);
// 调用open()不会实际发送请求,只是为发送请求做好准备
// 要发送定义好的请求,必须像下面这样调用send()方法,send方法接收一个参数,是作为请求体发送的数据
// 如果不需要发送请求体,则必须传null
xhr.send(null);

因为这个请求是同步的,所以JS代码会等待服务器响应之后再继续执行。

收到响应后,XHR对象的以下属性会被填充上数据:

  • responseText:作为响应体返回的文体

  • responseXML:如果响应的内容类型是“text/xml”或“application/xml”,那就是包含响应数据的XML DOM文档

  • status:响应的HTTP状态

  • statusText: 响应的HTTP状态描述

收到响应后,第一步要检查status属性以确保响应成功返回。

一般来说,HTTP状态码为2xx表示成功。此时,responseTextresponseXML(如果内容类型正确)属性中会有内容

如果HTTP状态码为304,则表示资源未修改过,是从浏览器缓存中直接拿取的

为确保收到正确的响应,应该检查这些状态

if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
    console.log(xhr.responseText);
} else {
    console.log("Request was unsuccessful:" + xhr.status);
}

XHR对象有一个readyState属性,表示当前处在请求/响应过程的哪个阶段。这个属性有如下可能的值

  • 0 未初始化,尚未调用open()方法

  • 1 已打开,已调用open()方法,尚未调用send()方法

  • 2 已发送,已调用send()方法,尚未收到响应

  • 3 接收中,已经收到部分响应

  • 4 完成,已经收到所有响应,可以使用了

每次readyState从一个值变成另一个值,都会触发readystatechange事件。可以借此机会检查readyState的值

为保证跨浏览器兼容,onreadystatechange事件处理程序应该在调用open()之前赋值

xhr.onreadystatechange = function () {
    if(xhr.readyState == 4) {
        if((xhr.status > 200 && xhr.status < 300) || xhr.status == 304) {
            // 请求成功,执行对应逻辑
        } else {
            console.log('err', xhr.status);
        }
    }
};
xhr.open('get', 'a.txt', true);
xhr.send(null);

在事件处理程序中,必须使用XHR对象本身来确定接下来该做什么

在收到响应之前如果想取消异步请求,可以调用abort()方法 xhr.abort();

调用这个方法后,XHR对象会停止触发事件,并阻止访问这个对象上任何与响应相关的属性。

中断请求后,应该取消对XHR对象的引用。

由于内存问题,不推荐重用XHR对象

XMLHttpRequest Level 2

  • FormData类型

    FormData类型便于表单序列化,也便于创建与表单类似格式的数据然后通过XHR发送

    let data = new FormData();
    data.append('name', 'val');
    // 通过直接给FormData构造函数传入一个表单元素,也可以将表单中的数据作为键/值对填充进去
    xhr.open('post', 'a.php', true);
    let form = document.getElementById('form-info');
    xhr.send(new FormData(form));
    // 或
    let data = new FormData(document.forms[0]);
    

    使用FormData的另一个方便之处是不再需要给XHR对象显示设置任何请求头部了。XHR对象能够识别作为FormData实例传入的数据类型并自动配置相应的头部

  • 超时

    IE8给XHR对象增加了一个timeout属性,用于表示发送请求后等待多少毫秒,如果响应不成功就中断请求,并触发timeout事件,调用ontimeout事件处理程序,readyState仍然会变成4,因此也会调用onreadystatechange事件处理程序。

    不过,如果在超时之后访问status属性则会发生错误。为做好防护,可以把检查status属性的代码封装在try/catch语句中。

    之后所有浏览器都在自己的XHR实现中增加了这个属性。

    ...
    xhr.open('get', 'a.php', true);
    xhr.timeout = 1000;
    xhr.ontimeout = function() {
        console.log('Request did not return in a second');
    };
    xhr.send(null);
    
  • overrideMimeType()

    Firefox首先引入了overrideMimeType()方法用于重写XHR响应的MIME类型。这个特性后来也被添加到了XMLHttpRequest Level 2

    因为响应返回的MIME类型决定了XHR对象如果处理响应,所以如果有办法覆盖服务器返回的类型,那么是有帮助的

    假设服务器实际发送了XML数据,但响应头设置的MIME类型是text/plain。结果就会导致虽然数据是XML,但respinseXML属性值是Null。此时调用overrideMimeType()可以保证将响应当成XML而不是纯文本来处理

    ...
    xhr.overrideMimeType('text/xml');
    xhr.send(null);
    

跨源资源共享(CORS)

通过XHR进行Ajax通信的一个主要限制是跨源安全策略

默认情况下,XHR只能访问与发起请求的页面在同一个域内的资源。这个安全限制可以防止某些恶意行为

跨源资源共享(CORS)定义了浏览器与服务器如何实现跨源通信。

其背后的基本思路就是使用自定义的HTTP头部允许浏览器和服务器相互了解,以确实请求和响应应该成功还是失败

对于简单的请求,比如GETPOST请求,没有自定义头部,而且请求体是text/plain类型,这样的请求在发送时会有一个额外的头部叫OriginOrigin头部包含发送请求的页面的源(协议、域名和端口),以便服务器确定是否为其提供响应

如果服务器决定响应请求,那么应该发送Access-Control-Allow-Origin头部,包含相同的源;或者如果资源是公开的,那就包含“*”。比如Access-Control-Allow-Origin: http://www.baidu.com

如果没有这个头部,或者有但源不匹配,则表明不会响应浏览器请求。

注意,无论请求还是响应都不会包含cookie信息

现代浏览器通过XMLHttpRequest对象原生支持CORS。在尝试访问不同源的资源时,这个行为会被自动触发

出于安全考虑,跨域XHR对象也施加了一些额外限制:

  • 不能使用setRequestHeader()设置自定义头部

  • 不能发送和接收cookie

  • getAllResponseHeaders()方法始终返回空字符串

因为无论同域还是跨域请求都使用同一个接口,所以最好在访问本地资源时使用相对URL,在访问远程资源时使用绝对URL

这样可以更明确地区分使用场景,同时避免出现访问本地资源时出现头部或cookie信息访问受限的问题

预检请求

CORS同归一种叫预检请求的服务器验证机制,允许使用自定义头部、除GETPOST之外的方法,以及不同请求体内容类型。

在要发送涉及上述某种高级选项的请求时,会先向服务器发送一个“预检”请求。这个请求使用options方法发送并包含以下头部:

  • Origin 与简单请求相同
  • Access-Control-Request-Method 请求希望使用的方法
  • Access-Control-Request-Headers (可选)要使用的逗号分隔的自定义头部列表

在这个请求发送后,服务器可以确定是否允许这种类型的请求。服务器会通过在响应中发送如下头部与浏览器沟通这些信息:

  • Access-Control-Allow-Origin 与简单请求相同

    Access-Control-Allow-Origin: http://www.baidu.com

  • Access-Control-Allow-Methods 允许的方法(逗号分割的列表)

    Access-Control-Allow-Methods: POST, GET

  • Access-Control-Allow-Headers 服务器允许的头部(逗号分割的列表)

    Access-Control-Allow-Headers: NCZ

  • Access-Control-Max-Age 缓存预检请求的秒数

    Access-Control-Max-Age: 1728000

预检请求返回后,结果会按响应指定的时间缓存一段时间。即,只有第一次发送这种类型的请求时才会多发送一次额外的HTTP请求

凭据请求

默认情况下,跨源请求不提供凭据(CookieHTTP认证和客户端SSL证书)。可以通过将withCredentials属性设置为true来表明请求会发送凭据。如果服务器允许带凭据的请求,那么可以在响应中包含如下HTTP头部:Access-Control-Allow-Credentials: true

如果发送了凭据请求而服务器返回的响应中没有这个头部,则浏览器不会把响应交给JS(responseText是空字符串,status是0,onerror()被调用)

服务器也可以在预检请求的响应中发送这个HTTP头部,以表明这个源允许发送凭据请求。

Fetch API

Fetch API是作为对XHR对象的一种端到端的替代方案而提出的。这个API提供了优秀的基于期约的结构、更直观的接口,以及对Stream API的最好支持

Fetch API能够执行XMLHttpRequest对象的所有任务,但更容易使用,接口也更现代化,能够在Web工作线程等现代Web工具中使用。

XMLHttpRequest可以选择异步,而Fetch API则必须是异步

Fetch API本身是使用JS请求资源的优秀工具,同时这个API也能够应用在服务线程(service worker)中,提供拦截、重定向和修改通过fetch()生成的请求接口

基本用法

fetch()方法是暴露在全局作用域中的,包括主页面执行线程、模块和工作线程

let r = fetch(url);
console.log(r); // Promise <pending>

请求完成、资源可用时,Promise会解决为一个Response对象

fetch('/getData').then(res => {
    console.log(res);
    // 读取响应内容的最简单方式是取得纯文本格式的内容,这要用到text()方法。
    res.text().then(data => {
        console.log(data);
    })
})
// 内容的结构通常是打平的
fetch('/getData').then(res => res.text()).then(data => console.log(data));

处理状态码和请求失败

Fetch API支持通过Responsestatus状态码 和 statusText状态文本 属性检查响应状态。

可以显式地设置fetch()在遇到重定向时的行为,不过默认行为是跟随重定向并返回状态码不是300-399的响应。跟随重定向时,响应对象的redirected属性会被设置为true,而状态码仍然是200

事实上,只要服务器返回了响应,fetch() Promise都会解决。因为服务器没有响应而导致浏览器超时,这样真正的fetch()失败会导致Promise被拒绝

违反CORS、无网络连接、HTTPS错配及其他浏览器/网络策略问题都会导致期约被拒绝

自定义选项

只使用URL时,fetch()会发送GET请求,只包含最低限度的请求头。要进一步配置如何发送请求,需要传入可选的第二个参数init对象:

  • body 指定使用请求体时请求体的内容

    必须是BlobBufferSourceFormDataURLSearchParamsReadableStreamString的实例

  • cache 用于控制浏览器与HTTP缓存的交互,要跟踪缓存的重定向,请求的redirect属性值必须是“follow”,而且必须符合同源策略限制。 默认为default

    必须是下列值之一:

    • Default fetch()

      返回命中的有效缓存,不发送请求

    • no-store

      浏览器不检查缓存,直接发送请求;不缓存响应

      不缓存响应,直接通过fetch()返回

    • reload

      浏览器不检查缓存,直接发送请求;缓存响应

    • no-cache

      无论命中有效缓存还是无效缓存都会发送条件式请求

      如果响应已经改变,则更新缓存的值。

    • force-cache

      无论命中有效缓存还是无效缓存都通过fetch()返回。不发送请求

    • only-if-cached

      只在请求模式为same-origin时使用缓存

      无论命中有效缓存还是无效缓存都通过fetch()返回。不发送请求

      未命中缓存返回状态码为504(网关超时)的响应

  • credentials 用于指定在外发请求中如何包含cookie。取值如下:(默认same-origin

    • omit 不发送cookie

    • same-origin 只在请求URL与发送fetch()请求的页面同源时发送cookie

    • include 无论同源还是跨源都包含cookie

  • headers 用于指定头部

    必须是Headers对象实例或包含字符串格式键/值对的常规操作

    默认值为不包含键/值对的Headers对象,不过浏览器仍然会随请求发送一些头部。虽然这些头部对JS不可见,但浏览器的网络检查器可以观察到

  • integrity 用于强制子资源完整性

    必须是包含子资源完整性标识符的字符串,默认为空字符串

  • keepalive 用于指示浏览器允许请求存在时间超出页面生命周期。默认为false

  • method 用于指定HTTP请求方法

  • mode 用于指定请求模式。这个模式决定来自跨源请求的响应是否有效,以及客户端可以读取多少响应

    违反这里指定模式的请求会抛出错误,必须是下列字符串值之一:

    • cors:允许遵守CORS协议的跨源请求。响应是“CORS过滤的响应”,意思是响应中可以访问的浏览器头部是经过浏览器强制白名单过滤的

    • no-cors 允许不需要发送预检请求的跨源请求。响应类型是opaque,意思是不能读取响应内容

    • same-origin 任何跨源请求都不允许发送

    • navigate 用于支持HTML导航,只在文档间导航时使用。基本用不到

      在通过构造函数手动创建Request实例时,默认为cors,否则,默认为no-cors

  • redirect 用于指定如何处理重定向响应(状态码为301302303307308)默认为follow

    • follow 跟踪重定向请求,以最终非重定向URL的响应作为最终响应
    • error 重定向请求会抛出错误
    • manual 不跟踪重定向请求,而是返回opaqueredirect类型的响应,同时仍然暴露期望的重定向URL。允许以手动方式跟踪重定向
  • referrer 用于指定HTTP的Referer头部的内容,取值如下:(默认为client/about:client

    • no-referrerno-referrer作为值
    • client/about:client 以当前URLno-referrer(取决于来源策略referrerPolicy)作为值
    • <URL> 以伪造URL作为值。伪造URL的源必须与执行脚本的源匹配
  • referrerPolicy 用于指定HTTPReferer头部

    • no-referrer 请求中不包含Referer头部

    • no-referrer-when-downgrade

      对于从安全HTTPS上下文发送到HTTP URL的请求,不包含Referer头部

      对于所有其他请求,将Referer设置为完整URL

    • origin 对于所有请求,将Referer设置为只包含源

    • same-origin

      对于跨源请求,不包含Referer头部

      对于同源请求,将Referer设置为完整URL

    • strict-origin

      对于从安全HTTPS上下文发送到HTTP URL的请求,不包含Referer头部

      对于所有其他请求,将Referer设置为只包含源

    • origin-when-cross-origin

      对于跨源请求,将Referer设置为只包含源

      对于同源请求,将Referer设置为完整URL

    • strict-origin-when-cross-origin

      对于从安全HTTPS上下文发送到HTTP URL的请求,不包含Referer头部

      对于所有其他跨源请求,将Referer设置为只包含源

      对于同源请求,将Referer设置为完整URL

    • unsafe-url 对于所有请求,将Referer设置为完整URL

  • signal 用于支持通过AbortController中断进行中的fetch()请求

常见Fetch请求模式

发送JSON数据

let data = JSON.stringify({
    foo: 'bar'
});
let header = new Headers({
    'Content-Type': 'application/json'
});
fetch('/send-json', {
    method: 'POST',
    body: data,
    headers: header
})

在请求体中发送参数

let data = 'foo=bar&baz=xx';
let headers = new Headers({
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
});
fetch('/send-params', {
    method: 'POST',
    body: data,
    headers: headers
})

发送文件

let formData = new FormData();
let imgInput = document.querySelector("input[type='file']");
formData.append('image', imgInput.files[0]);
fetch('/img-upload', {
    method: 'POST',
    body: formData
})

加载Blob文件

Blob可以兼容多种浏览器API。一种常见的做法是明确将图片文件加载到内存,然后将其添加到HTML图片元素。

为此,可以使用响应对象上暴露的blob()方法。这个方法返回一个期约,解决为一个Blob的实例。然后,可以将这个实例传给URL.createObjectUrl()以生成可以添加给图片元素src属性的值

const imgEle = document.querySelector('img');

fetch('img.png')
.then(response => response.blob())
.then(blob => {
    imgEle.src = URL.createObjectURL(blob);
})

发送跨源请求

从不同的源请求资源,响应要包含CORS头部才能保证浏览器收到响应。

没有这些头部,跨源请求会失败并抛出错误

fetch('//corss-origin.com');
// TypeError: Failed to fetch

如果代码不需要访问响应,也可以发送no-cors请求。此时响应的type属性值为opaque,因此无法读取响应内容。

这种方式适合发送探测请求或将响应缓存起来供以后使用

fetch('//cross-origin.com', { method: 'no-cors' })
.then(response => console.log(response.type));
// opaque

中断请求

Fetch API支持通过AbortController/AbortSignal对中断请求。

调用AbortController.abort()会中断所有网络传输,特别适合希望停止传输大型负载的情况。

中断进行中的fetch()请求会导致包含错误的拒绝

let abortController = new AbortController();
fetch('dia.zip', { signal: abortController.signal })
.catch(() => console.log('aborted!'));
// 10毫秒后中断请求
setTimeout(() => abortController.abort(), 10);

HTTP头部

每个HTTP请求和响应都会携带一些头部字段。XHR对象会通过一些方法暴露与请求和响应相关的头部字段

默认情况下,XHR请求会发送以下头部字段

  • Accept 浏览器可以处理的内容类型

  • Accept-Charset 浏览器可以显示的字符集

  • Accept-Encoding 浏览器可以处理的压缩编码类型

  • Accept-language 浏览器使用的语言

  • Connection 浏览器与服务器的连接类型

  • Cookie 页面中设置的Cookie

  • Host 发送请求的页面所在的域

  • Referer: 发送请求的页面URI

    注意,这个字段在HTTP规范中就拼错了,所以考虑到兼容性也必须将错就错(正确的拼写应该是Referrer

  • user-Agent 浏览器的用户代理字符串

虽然不同浏览器发送的确切头部字段可能各不相同,但这些通常都是会发送的。

方法:

  • setRequestHeader(name, val)

    如果需要发送额外的请求头部,可以使用setRequestHeader(name, val)方法。

    为保证请求头部被发送,必须在open()之后,send()之前调用setRequestHeader()

    let xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        ...
    };
    xhr.open('get', 'a.php', true);
    xhr.setRequestHeader('MyHeader', 'val');
    xhr.send(null);
    

    自定义头部一定要区别于浏览器正常发送的头部,否则可能影响服务器正常响应

  • getResponseHeader()

    可以使用getResponseHeader()方法从XHR对象获取响应头部

    let myHeader = xhr.getResponseHeader('MyHeader');
    
  • getAllResponseHeaders()

    如果想取得所有响应头部,可以使用getAllResponseHeaders()方法,这个方法会返回包含所有响应头部的字符串

    let allHeaders = xhr.getAllResponseHeaders();
    

Headers对象

Headers对象是所有外发请求和入站响应头部的容器。

每个外发的Request实例都包含一个空的Headers实例,可以通过Request.prototype.headers访问。每个入站Response实例也可以通过Response.prototype.headers访问包含着响应头部的Headers对象。

这两个属性都是可修改属性。

Headers对象与Map对象极为相似。HTTP头部本质上是序列化后的键/值对,他们的JS表示则是中间接口。

HeadersMap类型都有get()set()has()delete()等实例方法,可以使用一个可迭代对象来初始化,当然也具有keys()values()entries()迭代器接口。

let seed = [['foo', 'bar']];

let h = new Headers(seed);

console.log(h.get('foo')); // bar

不同的是,在初始化Headers对象时,也可以使用键/值对形式的对象,而Map则不可以

let seed = {foo: 'bar'};
let h = new Headers(seed);
console.log(h.get('foo')); // bar

let m = new Map(seed);
// TypeError: object is not iterable

一个HTTP头部字段可以有多个值,而Headers对象通过append()方法支持添加多个值。

Headers实例中还不存在的头部上调用append()方法相当于调用set()

let h = new Headers();
h.append('foo', 'bar')
console.log(h.get('foo')); // bar

h.append('foo', 'baz');
console.log(h.get('foo')); // 'bar, baz'

某些情况下,并非所有HTTP头部都可以被客户修改,而Headers对象使用护卫来防止不被允许的修改。违反护卫限制会抛出TypeError

JS可以决定Headers实例的护卫设置。以下列出了不同的护卫设置和每种设置对应的行为

护卫使用情形限制
none在通过构造函数创建Headers实例时激活
request在通过构造函数初始化Request对象,且mode值为非no-cors时激活不允许修改禁止修改的头部
request-no-cors在通过构造函数初始化Request对象,且mode值为no-cors时激活不允许修改非简单头部
response在通过构造函数初始化Response对象时激活不允许修改禁止修改的响应头部
immutable在通过error()或redirect()静态方法初始化Response对象时激活不允许修改任何头部

Response

读取响应状态信息

Response对象包含一组只读属性,描述了请求完成后的状态,如下所示

  • headers 响应包含的Headers对象

  • ok 布尔值,表示HTTP状态码的含义。200-299的状态码返回true,其他状态码返回false

  • redirected 布尔值,表示响应是否至少经过一次重定向

  • status 整数,表示响应的HTTP状态码

  • statusText 字符串,包含对HTTP状态码的正式描述。

  • type 字符串,包含响应类型。

    可能是下列字符串值之一

    • basic 表示标准的同源响应

    • cors 表示标准的跨源响应

    • error 表示响应对象是通过Response.error()创建的

    • opaque 表示no-corsfetch()返回的跨源响应

    • opaqueredirect 表示对redirect设置为manual的请求的响应

  • url 包含响应URL的字符串,对于重定向响应,这是最终的URL,非重定向响应就是它产生的

Request、Response及Body混入

RequestResponse都使用了Fetch APIBody混入,以实现两个承担有效载荷的能力。

通常,将RequestResponse主体作为流来使用主要有两个原因:

  • 有效载荷的大小可能会导致网络延迟

  • 流API本身在处理有效载荷方面是有优势的。

除此之外,最好是一次性获取资源主体

Body混入提供了5个方法,用于将ReadableStream转存到缓冲区的内存里,将缓冲区转换为某种JS对象类型,以及通过期约来产生结果。

Body.text()

此方法返回期约,解决为将缓冲区转存得到的UTF-8格式字符串。

fetch('http://foo.com').then(response => response.text()).then(console.log);

let request = new Request('https://foo.com', {method: 'POST', body: 'barbazqux'});

request.text().then(console.log);
// barbazqux

Body.json()

此方法返回期约,解决为将缓冲区转存得到的JSON

fetch('http://foo.com').then(response => response.json()).then(console.log);

let request = new Request('https://foo.com', {method: 'POST', body: JSON.stringify({bar: 'baz'})});
request.json().then(console.log);
// {bar: 'baz'}

Body.formData()

浏览器可以将formData对象序列化/反序列化为主体

let formData = new FormData();
formData.append('foo', 'bar');
let request = new Request('https://foo.com', {
    method: 'POST',
    body: myFormData
});
request.formData().then(data => console.log(data.get('foo')))

// 在Response对象上使用Body.formData();
fetch('https://foo.com/form-data').then(response => response.formData()).then(data => console.log(data.get('foo')))

Body.arrayBuffer()

有时候,可能需要以原始二进制格式查看和修改主体

let request = new Request('http://foo.com', {
    method: 'POST',
    body: 'abcd'
});
// 以整数形式打印二进制编码的字符串
request.arrayBuffer().then(buf => {
    console.log(new Int8Array(buf));
});
// Int8Array(7) [97, 98, 99, 100]

// 在Response对象上使用Body.arrayBuffer()
fetch('https://foo.com')
.then(response => response.arrayBuffer())
.then(console.log);

Body.blob()

有时候,可能需要以原始二进制格式使用主体,不用查看和修改

let request = new Request('http://foo.com', {
    method: 'POST',
    body: 'abcd'
});
request.blob().then(console.log);
// Blob(7) {size: 7, type: 'text/plain;charset=utf-8'}

因为Body混入是构建在ReadableStream之上的,所以主题流只能使用一次。

这意味着所有主体混入方法都只能调用一次,再次调用就会抛出错误

request.blob().then(() => request.blob());
// TypeError: Failed to execute 'blob' on 'Response': body stream is locked

fetch('http://foo.com')
.then(response => response.blob().then(() => response.blob()))
// TypeError: Failed to execute 'blob' on 'Response': body stream is locked

使用ReadableStream主体

JS编程逻辑很多时候会将访问网络作为原子操作,比如请求是同时创建和发送的,响应数据也是以统一的格式一次性暴露出来的。这种约定隐藏了底层的混乱,让涉及网络的代码变得很清晰

TCP/IP角度来看,传输的数据是以分块形式抵达端点的,而且速度受到网速的限制。接收端点会为此分配内存,并将收到的块写入内存。

Fetch API通过ReadableStream支持在这些块到达时就实时读取和操作这些数据

ReadableStream暴露了getReader()方法,用于产生ReadableStreamDefaultReader,这个读取器可以用于在数据到达时异步获取数据块,数据流的格式是Uint8Array

可以将read()方法直接封装到Iterable接口中,通过将异步逻辑包装到一个生成器函数中,通过支持只读取部分流让代码变得更文件。如果流因为耗尽或者错误而终止,读取器会释放,以允许不同的流读取器继续操作:

async function* streamGenerator(stream) {
    const reader = stream.getReader();

    try{
        while(true) {
            const {value, done} = await reader.read();
            if(done) {
                break;
            }
            yield value;
        }
    } finally {
        reader.releaseLock();
    }
}

fetch('https://fetch.spec.org/')
.then(res => res.body)
.then(async function(body) {
    for await(chunk of streamGenerator(body)) {
        console.log(chunk);
    }
})

当读取完Unit8Array块之后,浏览器会将其标记为可以被垃圾回收。对于需要在不连续的内存中连续检查大量数据的情况,这样可以节省很多内存空间

默认情况下,块是以Unit8Array格式抵达的。因为块的分割不会考虑编码,所以会出现某些值作为多字节字符被分散到两个连续块中的情况。手动处理这些情况是很麻烦的,但很多时候可以使用Encoding API的可插拔方案

要将Unit8Array转换为可读文本,可以将缓冲区传给TextDecoder,返回转换后的值

通过设置stream: true,可以将之前的缓冲区保留在内存,从而让跨越两个块的内容能够被正确解码

let decoder = new TextDecoder();
fetch('https://aa.cc.com')
.then(res => res.body)
.then(async function(body) {
    for await (chunk of streamGenerator(body) {
        console.log(decoder.decode(chunk, { stream: true }));
    })
})

TCP/IP角度来看,传输的数据是以分块形式抵达端点的,而且速度受到网速的限制。接收端点会为此分配内存,并将收到的块写入内存。Fetch API通过ReadableStream支持在这些块到达时就实时读取和操作这些数据

Get请求

用于向服务器查询信息

发送Get请求最常见的一个错误是查询字符串格式不对

查询字符串中的每个名和值都必须使用encodeURIComponent()编码,所有名/值对必须以(&)分隔,添加到现有URL末尾。

function addURLParams(url, name, val) {
    url += (url.indexOf('?') == -1 ? '?' : '&');
    url += `${encodeURIComponent(name)}=${encodeURIComponent(val)}`;
    return url;
}

POST请求

用于向服务器发送应该保存的数据

每个POST请求都应该在请求体中携带提交的数据。

POST请求的请求体可以包含非常多的数据,而且数据可以是任意格式

默认情况下,对服务器而言,POST请求与提交表单是不一样的。

进度事件

Progress Events是W3C的工作草案,定义了客户端--服务器端通信。

这些事件最初只针对XHR,现在也推广到了其他类似的API。有以下6个进度相关的事件:

  • loadstart 在接收到响应的第一个字节时触发

  • progress 在接收响应期间反复触发

    每次触发,onprogress事件处理程序都会收到event对象,其target属性是XHR对象,且包含3个额外属性:

    • lengthComputable 一个布尔值,表示进度信息是否可用
    • position 接收到的字节数
    • totalSize 是响应的Content-Length头部定义的总字节数

    有了这些信息,就可以给用户提供进度条了

    let xhr = new XMLHttpRequest();
    xhr.onload = function(event) {
        if((xhr.status > 200 && xhr.status < 300) || xhr.status == 304) {
            console.log(xhr.responseText);
        } else {
            console.log('err');
        }
    };
    xhr.onprogress = function(event) {
        let divStatus = document.getElementById('status');
        if(event.lengthComputable) {
            divStatus.innerHTML = 'Received' + event.position + ' of' + event.totalSize + ' bytes';
        }
    };
    xhr.open('get', 'a.php', true);
    xhr.send(null);
    
  • error 在请求出错时触发

  • abort 在调用abort()终止连接时触发

  • load 在成功接收完响应时触发

    Firefox最初在实现XHR的时候,曾致力于简化交互模式。最终增加了一个load事件用于替代readystatechange事件。

    load事件在响应接收完成后立即触发,这样就不用检查readyState属性了

    onload事件处理程序会收到一个event对象,其target属性设置为XHR实例,在这个实例上可以访问所有XHR对象属性和方法。不过,并不是所有浏览器都实现了这个事件的event对象。

    只要是从服务器收到响应,无论状态码是什么,都会触发load事件。这意味着还需要检查status属性才能确定数据是否有效

  • loadend 在通信完成时,且在errorabortload之后触发

Beacon API

当需要收集页面信息时,为了把尽量多的信息传到服务器,理想情况下是通过浏览器的unload事件发送网络请求。但是此时在unload事件处理程序中创建的任何异步请求都会被浏览器取消。

为此,W3C引入了补充性的Beacon API,这个APInavigator对象增加了一个sendBeacon()方法。

此方法接收一个URL和一个数据有效载荷参数,并会发送一个POST请求。可选的数据有效载荷参数有ArrayBufferViewBlobDOMStringFormData实例。如果请求成功进入了最终要发送的任务队列,则着这个方法返回true,否则返回false

// 例如
navigator.sendBeacon('https://ex.com/aa', '{foo: "bar"}')

这个方法虽然看起来只不过是POST请求的一个语法糖,但它有几个重要的特性

  • sendBeacon()并不是只能在页面生命周期末尾使用,而是任何时候都可以使用

  • 调用sendBeacon()后,浏览器会把请求添加到一个内部的请求队列。浏览器会主动地发送队列中的请求

  • 浏览器保证在原始页面已经关闭的情况下也会发送请求

  • 信标(beacon)请求会携带调用sendBeacon()时所有相关的cookie

Web Socket

Web Socket的目标是通过一个长时连接实现与服务器全双工、双向的通信。

Web Socket不能通过标准HTTP服务器实现,而必须使用支持该协议的专有服务器

因为Web Socket使用了自定义协议,所以URL不能再使用http://https://,而要使用ws://wss://。前者是不安全的连接,后者是安全连接

使用自定义协议的好处是,客户端与服务器之间可以发送非常少的数据,不会对HTTP造成任何负担;缺点是,定义协议的时间比定义JS API要长。

创建Web Socket

let socket = new WebSocket('wx://www.example.com/server.php');
// 同源策略不适用于Web Socker,因此可以打开到任意站点的连接

// 客户端向服务器发送数据
let stringData = 'hello world!';
let arrayBufferData = Unit8Array.form(['f', 'o', 'o']);
let blobData = new Blob(['f', 'o', 'o']);

socket.send(stringData);
socket.send(arrayBufferData.buffer);
socket.send(blobData);

// 服务器向客户端发送消息时,WebSocket对象上会触发message事件。
// 这个message事件与其他消息协议类似,可以通过event.data属性访问
socket.onmessage = function(event) {
    // event.data返回的数据格式由WebSocket对象的binaryType属性决定,可以是ArrayBuffer或Blob
    let data = event.data;
    ...
}

浏览器会在初始化WebSocket对象之后立即创建连接。WebSocket的状态值:

  • WebSocket.OPENING(0) 连接正在建立

  • WebSocket.OPEN(1) 连接已经建立

  • WebSocket.CLOSING(2) 连接正在关闭

  • WebSocket.CLOSE(3) 连接已经关闭

WebSocket对象没有readystatechange事件,而是有与上述不同状态对应的其他事件。readyState值从0开始

任何时候都可以调用close()方法关闭Web Scoket连接socket.close()

其他事件

WebSocket对象在连接生命周期中有可能触发3个其他事件

  • open 在连接成功建立时触发

    socket.onopen = function() {
        ...
    }
    
  • error 在发生错误时触发,连接无法存续

    socket.onerror = function() {
        ...
    }
    
  • close 在连接关闭时触发

    socket.onclose = function(event) {
        // event.wasClean 布尔值,表示连接是否干净的关闭
        // event.code 来自服务器的数值状态码
        // event.reason 字符串,包含服务器发来的消息
        ...
    }
    

安全

关于安全防护Ajax相关URL的一般理论认为,需要验证请求发送者拥有对资源的访问权限,可以通过如下方式实现:

  • 要求通过SSL访问能够被Ajax访问的资源

  • 要求每个请求都发送一个按约定算法计算好的令牌(token