当我们谈到异步操作请求数据时,一般会提及 AJAX(Asynchronous Javascript And XML)。事实上,这种局部刷新交互式的开发技术,最早是由Adaptive Path公司的Jesse James Garrett在2005年2月提出。

在过去的十几年里,异步请求数据都是围绕 XMLHttpRequest 这个核心对象而展开的。我们要么自己封装一个原生的AJAX库,要么使用 jQuery 或者 Zepto 这种工具库里封装好的 $.ajax 方法。

针对多个回调的情况,为了优雅的处理异步操作,我们还得用 promise 来包装下 ajax 方法。

而现在我们开发的项目,大多数基于 React 或者 Vue 这种框架。所以,为一个ajax方法而去引入一个工具库,这种做法显然不合适。另外,用 Promise 去包装原生的ajax,再去处理兼容的做法,也有点过时了。

除了 AJAX,到现在,终于有了异步请求数据的替代方案,那就是W3C所推出的标准API-Fetch,这个API是挂载于 BOM 的 window 下。在写本篇文章时,chrome和firefox的最新版本都支持它。

一、一个简单的 Fetch

先来一个最简单的 fetch 请求,使用的接口数据来源于 JSONPlaceholder,它是一个在线模拟和仿照API的站点,我之前在 使用 JSON Server 构建数据接口 一文中有介绍。

首先,我们打开 JSONPlaceholder,开启控制台,输入以下内容:

fetch('http://jsonplaceholder.typicode.com/')
.then(res => {
console.log(res);
return res.text();
})
.then(res => console.log(res));

返回的是:

fetch1

可以看到,fetch 执行后返回的是一个 Response 对象,并且采用的是 Promise 的链式写法。当对请求后的内容执行 res.text() 后,便可以得到网页的源码。

这样,就完成一个简单的 fetch 请求。当你将控制台切换到 Network 选项,选中 XHR,你会发现,请求的类型不再是 xhr,而变成了 fetch

fetch2

二、Fetch 用法

通过上面我们可以知道,要发起一个 fetch 请求,只需要:

fetch('https://jsonplaceholder.typicode.com/posts/1')

在这里,我们请求jsonplaceholder上第一篇内容的数据(json格式),执行fetch请求后,它返回结果为:

Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}

说明fetch执行后,返回的是一个 Promise 对象,这也是为什么我们在文章开头的例子中,可以在后面接 then 方法的原因。

接着,我们进一步来获取对应的请求内容:

fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(res => console.log(res))

代码中,then 函数中的参数(res)为一个 Response 对象,我们将它打印出来,会发现它包含了一系列属性:

fetch3

其中包含请求地址(url)、ok(请求是否成功)、状态码(status)、状态描述(statusText)、body、header、type 之类的,当然,我们最关心的是 ok, status 属性。当 ok 的值为 true 并且 status 的值是 200 ~ 299 时,表示fetch请求成功。

但是,我们在这个对象中并没有得到我们请求的业务数据。因此,我们进一步处理:

fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(res => {
console.log(res);
return res.json();
})
.then(res => console.log(res));

因为 res 是一个 Response 对象,在这里,我们在代码中使用了它隐式原型(__proto__)上的方法 json(),该方法将请求的内容转换为 json 格式,并且该方法仍然返回一个 Promise 对象。然后我们在第二个 then 函数里面获取返回的内容。即:

fetch4

如果你请求的是其他格式的数据,通过上面的截图,Response 对象还提供了其他的内容转换方法,这些方法都返回的是一个 Promise 对象,它们主要的有:

Body.text()

将请求后返回的内容解析为字符串格式

Body.json()

将请求后返回的内容解析为Json格式

Body.blob()

将请求后返回的内容解析为Blob格式

Body.arrayBuffer()

将请求后返回的内容解析为ArrayBuffer格式

Body.formData()

将请求后返回的内容解析为formData格式

更多关于Response 对象内容,可查看相关资料

2.1 捕获错误

我们在请求接口时,大部分请求都会成功返回。但是,也存在发送错误的情况,比如说网络错误,或者说将接口地址拼写错误,这个时候,我们可以像 Promise 一样,使用 catch 方法来捕获这个错误,用代码来说明下:

fetch('https://xx.typicode.com/posts/1')
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.log('错误:', err));

// 错误: TypeError: Failed to fetch

在上面代码中,将请求接口地址的主域名改为 xx,这显然是一个不存在的接口地址,执行fetch后在 catch 函数中里将会捕获到一个请求失败的错误。

因此,为了保证代码的健壮性,最后在后面加上 then 函数。

在早期的Fetch版本中,如果请求的是一个不存在的接口,可能代码中的第一个 then 函数还是会执行的,然后才会执行 catch 函数,但这显然是不对的。针对这种情况,我们早期得在第一个 then 函数这样处理:

fetch('https://xx.typicode.com/posts/1')
.then(res => {
if (res.ok) {
res.json();
} else {
throw new Error('something went wrong!');
}
})
.then(res => console.log(res))
.catch(err => console.log('错误:', err));

即通过判断 Response 对象的 ok 属性,如果该值为 false,则直接抛出错误,触发后面的 catch 函数。

2.2 get 请求

前面讲到的都是 get 请求,但是请求地址里都没涉及到参数。倘若,你的get请求里面带有参数,则只能在请求地址拼接,如:

fetch('https://jsonplaceholder.typicode.com/posts?_page=2&_limit=5')
.then(res => {
return res.json();
})
.then(res => console.log(res));

上面的代码表示请求第2页的5条数据。

2.3 post 请求

默认情况下,使用 fetch 发送 get 请求非常简单,你几乎不用作任何设置。但如果要进行其他方式的请求,则需要使用到 fetch 函数的第二个参数,它是一个对象,主要配置请求的相关选项。

比如发送一个 post 请求,代码如下:

fetch('http://jsonplaceholder.typicode.com/posts', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'title',
body: 'some text'
})
})
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.log('错误:', err));

// {title: "title", body: "some text", id: 101}

上面的代码,表示新增一篇文章。可以看到,第二参数中指定了请求类型为 post,并且在 header 中设置了向服务器发送的内容编码类型(json),最后,在 body 中通过 JSON.stringify 函数设置传入的参数。

这里要注意,对于post请求,如果不在 headers 中设置 'Content-Type': 'application/json',可能会导致不能正常请求到数据。因为此时在 Request Headers 中,content-type 的值默认为 content-type:text/plain;charset=UTF-8,但在 Response Headers 中则是 Content-Type:application/json; charset=utf-8,前后数据类型不匹配!

最后,对于get请求,还要注意的是,get 请求地址的参数只能拼接URL后面,强制将请求参数放在fetch函数中第二个参数的body里,会导致报错。

2.4 自定义Header

传统的XMLHttpRequest有两个主要缺点:

  • 对搜索引擎的支持比较弱
  • 不支持浏览器的history功能,即网页不能前进或后退

其中第二个问题可以使用 pjax 来解决,它的原理主要是用到了 pushState API,并且在发送 ajax 前,定义一个专属头部,以便服务端识别:

xhr.setRequestHeader('X-PJAX', true)
xhr.setRequestHeader('X-PJAX-container', htmlContainer)
...

github 中也大量使用了 pjax,你可以打开 github,打开控制台的 Network 选项,就能看到:

pjax

说这么多,我只是想表示,fetch API 也提供了自定义 Header 接口,在这个接口上,我们可以对请求头和响应头执行各种操作,其中包含添加,删除、检索。其实,你可以把Headers对象看成是一组键值对的集合。

来看下它们的具体操作:

var myHeaders = new Headers();

myHeaders.append('Content-Length', 'xxxxx'.length.toString());
myHeaders.append('Custom-Header', 'anything');

console.log(myHeaders.has('Content-Length')); // true
console.log(myHeaders.get('Custom-Header')); // "anything"

myHeaders.delete('Custom-Header');

console.log(myHeaders.get('Custom-Header')); // null

在 fetch 请求中获取 Header:

fetch('http://jsonplaceholder.typicode.com/posts', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'title',
body: 'some text'
}),
mode: 'cors'
})
.then(res => {
let {headers} = res;

console.log(headers.has('Content-Length')); // true
console.log(headers.get('Content-Type')); // application/json; charset=utf-8
console.log(headers.get('Date')); // Wed, 28 Feb 2018 13:07:59 GMT
})

2.5 第二参数的其他属性

除了 methodheadersbody 属性,fetch 的第二个参数还包含了其他属性,简述如下:

  • method:发送请求的方法,比如 get、post、put、DELETE 等。get 请求可省略第二参数
  • headers:请求的头信息
  • body:请求的 body 信息
  • mode:请求模式,值可以是 cors(支持跨域)、 no-cors(跨域请求,不需要服务端支持cors) 或 same-origin(不允许跨域)
  • credentials:请求证书,值可以是 omit(默认值,不发送cookie)、 same-origin(cookie只能同域发送,不可跨域)、 include(cookie同域,跨域都可发送)
  • cache:缓存设置,它的值有 default(默认值,fetch请求前进行http缓存) 、 no-store(完全不http缓存) 、 no-cache(有缓存时,fetch将发送请求,并更新缓存) 、 reload(忽略之前的缓存,请求后更新缓存) 、 force-cache(严重依赖缓存,即使缓存过期,也读取该缓存) 或者 only-if-cached(严重依赖缓存,无缓存时将抛出错误)
  • redirect:重定向设置,它的值有 follow (自动重定向), error (产生重定向时将自动终止并且抛出一个错误), 或者 manual (手动处理重定向)

三、相关问题

作为下一代异步通信的规范,Fetch API 虽然提供了一些强大的功能以及异步写法,但是也存在不少问题,这也是它至今未大规模使用的原因。

3.1 兼容性

对于不支持 fetch 的浏览器或者服务器请求,我们可以通过以下代码做兼容:

if(self.fetch) {
// fetch ...
} else {
// XMLHttpRequest
}

这里使用 self 主要考虑 Window 或 Worker 环境。如果你要针对不支持它的浏览器也使用 fetch,可使用它的语法糖 Fetch Polyfill

3.2 没有 timeout 特性

fetch 除了不能取消发送外,还有另外一点饱受诟病,那就是不支持 timeout。

用过 $.ajax 的人都知道,我们可以在其中的选项中设置 timeout 属性,它的值是一个时间(毫秒),它表示请求若超过该时间,我们便可以在 error 或者 complete 函数中判断状态值,粗略代码如下:

var getUserData = $.ajax({
url: 'xxx.com/user',
timeout: 5000,
complete: function(xhr, textStatus) {
if(textStatus === 'timeout') {
getUserData.abort();
alert('请求超时,请重试!');
}
}
});

这样,当发送ajax请求出现了问题,便能给用户良好的反馈。但很可惜,本文介绍的 fetch 却没有提供相关设置。

针对此情况,目前网上有两种替代方案,即 setTimeoutPromise.race

3.2.1 setTimeout

这种方式比较简单粗暴,主要是利用 Promise 内部状态发生改变后(fulfilled 或 rejected),就再也会发生变化了。即在一个 Promise 中设置一个超时 reject,以及将 resolve 传入 fetch 中。若它们两者之间只有其中一个执行了,便达到了我们的效果,代码如下:

function fetchTimeout1(promise, timeout) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('The request is timeout'));
}, timeout);

promise.then(resolve, reject);
}
}

fetchTimeout1(fetch('/xx'), 5000)
.then(res => {})
.catch(err => {});

3.2.2 Promise.race

我们知道,Promise.race 返回的结果取决于所监听的 Promise 列表中最先改变状态的(无论是 fulfilled 还是 rejected) 的那个 Promise,利用这点,再结合上面的 timeout,我们又可以这样处理:

function fetchTimeout2(promise, timeout) {
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('The request is timeout'));
}, timeout);
});

return Promise.race([
promise,
timeoutPromise
]);
}

fetchTimeout2(fetch('/xx'), 5000)
.then(res => {})
.catch(err => {});

其实,无论是 setTimeout 还是 Promise.race,它们的解决方案的原理都大同小异。

3.3 不支持取消(abort)发送的请求

以前的 ajax 请求中,在某些情况下,我们可以通过 abort 方法来停止 ajax 请求,终止一切网络活动。

但 fetch 不同,一旦你发起了一个 fetch 请求,便不能停止。但是,你可以像上面处理 setTimeout 一样,来模拟一个 abort 特性。

除了上面的做法,如果你实在非常喜欢 fetch 的语法,又想在某些情况下,能够中断其请求,延续传统ajax里的一些好的特性。那么,我建议你可以考虑 axios。它是一个基于 Promise 并将 XMLHttpRequest(浏览器端)、HTTP(node服务端)结合封装起来的网络请求库,也就是说在客户端和服务端都能使用,另外,听说它的实现方式非常优雅。

3.4 fetch 对一些状态码不会 reject

如果你用过 XMLHttpRequest,我们在发送请求后,首先会对这样判断状态码:

xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 0) {
//
} else {
alert('发生错误:' + xhr.status);
}
}
};

由于封装良好的原因,$.ajax() 方法我们则无需单独判断状态码。

但是,fetch 比较特殊,由于它返回的是一个 Promise 对象,所以不管请求结果的状态码是 4XX 还是 5XX ,它都不会被 reject 。且只有网络错误时,才会被 reject

所以,必要的时候,我们可以在第一个 then 函数里面进行状态码处理,即如果服务端返回的状态码是非 200 的情况,可以考虑抛出错误。

或者,通过在Fetch请求后的then函数中判断 response.ok 来确定请求是否成功。

四、参考资料