JS异步处理系列二 XHR Fetch

参考
AJAX 之 XHR, jQuery, Fetch 的对比
使用更优雅的异步请求API——fetch

一、原生 JS 实现 AJAX( Asynchronous JavaScript and XML)
1.请求发送

JS 实现 AJAX 主要基于浏览器提供的 XMLHttpRequest(XHR)类

//get
if (xhr) {
  xhr.open('GET', '/api?username=admin&password=root', true);
  xhr.send(null);
}
//post
if (xhr) {
  xhr.open('POST', '/api', true);
  // 设置 Content-Type 为 application/x-www-form-urlencoded
  // 以表单的形式传递数据
  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  xhr.send('username=admin&password=root');
}

open() 方法有三个参数:

  • open() 的第一个参数是 HTTP 请求方式 – GET,POST,HEAD 或任何服务器所支持的您想调用的方式。按照HTTP规范,该参数要大写;否则,某些浏览器(如Firefox)可能无法处理请求。

  • 第二个参数是请求页面的 URL。由于同源策略(Same origin policy)该页面不能为第三方域名的页面。同时一定要保证在所有的页面中都使用准确的域名,否则调用 open() 会得到 permission denied 的错误提示。

  • 第三个参数设置请求是否为异步模式。如果是 TRUE,JavaScript 函数将继续执行,而不等待服务器响应。这就是 AJAX 中的 A。

send()方法

  • 如果第一个参数是 GET,则可以直接将参数放在 url 后面

  • 如果第一个参数是 POST,则需要将参数写在 send() 方法里面。send() 方法的参数可以是任何想送给服务器的数据。这时数据要以字符串的形式送给服务器,如:name=admint&password=root。或者也可以传递 JSON 格式的数据:

// 设置 Content-Type 为 application/json
xhr.setRequestHeader('Content-Type', 'application/json');
// 传递 JSON 字符串
xhr.send(JSON.stringify({ username:'admin', password:'root' }));

如果不设置请求头,原生 AJAX 会默认使用 Content-Type 是 text/plain;charset=UTF-8 的方式发送数据。

2.处理响应
// onreadystatechange 方法
function onReadyStateChange() {
  // 该函数会被调用四次
  console.log(xhr.readyState);
  if (xhr.readyState === 4) {
    // everything is good, the response is received
    if (xhr.status === 200) {
      console.log(xhr.responseText);
    } else {
      console.log('There was a problem with the request.');
    }
  } else {
    // still not ready
    console.log('still not ready...');
  }
}
3.Ajax之readyState(状态值)和status(状态码)的研究

(a)Ajax:readyState(状态值)和status(状态码)的区别
readyState,是指运行Ajax所经历过的几种状态,无论访问是否成功都将响应的步骤,可以理解成为Ajax运行步骤,使用“ajax.readyState”获得。
status,是指无论Ajax访问是否成功,由http协议根据所提交的信息,服务器所返回的http头信息代码,使用“ajax.status”获得。

总体理解:可以简单的理解为state代表一个整体的状态。而status是这个大的state下面具体的小的状态。

(b)什么是readyState
readyState是XMLHttpRequest对象的一个属性,用来标识当前XMLHttpRequest对象处于什么状态。
readyState总共有5个状态值,分别为0~4,每个值代表了不同的含义。

  • 0:初始化,XMLHttpRequest对象还没有完成初始化
  • 1:载入,XMLHttpRequest对象开始发送请求
  • 2:载入完成,XMLHttpRequest对象的请求发送完成
  • 3:解析,XMLHttpRequest对象开始读取服务器的响应
  • 4:完成,XMLHttpRequest对象读取服务器响应结束

(c)什么是status
status是XMLHttpRequest对象的一个属性,表示响应的http状态码
在http1.1协议下,http状态码总共可分为5大类

  • 1xx:信息响应类,表示接收到请求并且继续处理

  • 2xx:处理成功响应类,表示动作被成功接收、理解和接受

  • 3xx:重定向响应类,为了完成指定的动作,必须接受进一步处理

  • 4xx:客户端错误,客户请求包含语法错误或者是不能正确执行

  • 5xx:服务端错误,服务器不能正确执行一个正确的请求

  • 100——客户必须继续发出请求

  • 101——客户要求服务器根据请求转换HTTP协议版本

  • 200——交易成功

  • 201——提示知道新文件的URL

  • 202——接受和处理、但处理未完成

  • 203——返回信息不确定或不完整

  • 204——请求收到,但返回信息为空

  • 205——服务器完成了请求,用户代理必须复位当前已经浏览过的文件

  • 206——服务器已经完成了部分用户的GET请求

  • 300——请求的资源可在多处得到

  • 301——删除请求数据

  • 302——在其他地址发现了请求数据

  • 303——建议客户访问其他URL或访问方式

  • 304——客户端已经执行了GET,但文件未变化

  • 305——请求的资源必须从服务器指定的地址得到

  • 306——前一版本HTTP中使用的代码,现行版本中不再使用

  • 307——申明请求的资源临时性删除

  • 400——错误请求,如语法错误

  • 401——请求授权失败

  • 402——保留有效ChargeTo头响应

  • 403——请求不允许

  • 404——没有发现文件、查询或URl

  • 405——用户在Request-Line字段定义的方法不允许

  • 406——根据用户发送的Accept拖,请求资源不可访问

  • 407——类似401,用户必须首先在代理服务器上得到授权

  • 408——客户端没有在用户指定的饿时间内完成请求

  • 409——对当前资源状态,请求不能完成

  • 410——服务器上不再有此资源且无进一步的参考地址

  • 411——服务器拒绝用户定义的Content-Length属性请求

  • 412——一个或多个请求头字段在当前请求中错误

  • 413——请求的资源大于服务器允许的大小

  • 414——请求的资源URL长于服务器允许的长度

  • 415——请求资源不支持请求项目格式

  • 416——请求中包含Range请求头字段,在当前请求资源范围内没有range指示值,请求也不包含If-Range请求头字段

  • 417——服务器不满足请求Expect头字段指定的期望值,如果是代理服务器,可能是下一级服务器不能满足请求

  • 500——服务器产生内部错误

  • 501——服务器不支持请求的函数

  • 502——服务器暂时不可用,有时是为了防止发生系统过载

  • 503——服务器过载或暂停维修

  • 504——关口过载,服务器使用另一个关口或服务来响应用户,等待时间设定值较长

  • 505——服务器不支持或拒绝支请求头中指定的HTTP版本

4.responseType

参考
你真的会使用XMLHttpRequest吗?
你不知道的 XMLHttpRequest
阮一峰 XMLHttpRequest Level 2 使用指南
Meida视频加密五-实现原理
XHR Level 2提供了responseType属性,可以指定为arraybuffer,接收二进制数据。

Value Data type of response property
“” DOMString (this is the default value)
“arraybuffer” ArrayBuffer
“blob” Blob
“document” Document
“json” JavaScript object, parsed from a JSON string returned by the server
“text” DOMString
“moz-blob” Used by Firefox to allow retrieving partial Blob data from progress events. This lets your progress event handler start processing data while it’s still being received.
“moz-chunked-text” Similar to “text”, but is streaming. This means that the value in response is only available during dispatch of the “progress” event and only contains the data received since the last “progress” event. When response is accessed during a “progress” event it contains a string with the data. Otherwise it returns null.This mode currently only works in Firefox.
“moz-chunked-arraybuffer” Similar to “arraybuffer”, but is streaming. This means that the value in response is only available during dispatch of the “progress” event and only contains the data received since the last “progress” event.When response is accessed during a “progress” event it contains a string with the data. Otherwise it returns null.This mode currently only works in Firefox.
“ms-stream” Indicates that the response is part of a streaming download. It is supported only for download requests. This mode is available only in Internet Explorer.
二、jQuery 实现 AJAX

jQuery 作为一个使用人数最多的库,其 AJAX 很好的封装了原生 AJAX 的代码,在兼容性和易用性方面都做了很大的提高,让 AJAX 的调用变得非常简单。下面便是一段简单的 jQuery 的 AJAX 代码:

// GET
$.get('/api', function(res) {
  // do something
});

// POST
var data = {
  username: 'admin',
  password: 'root'
};
$.post('/api', data, function(res) {
  // do something
});
三、Fetch API

使用 jQuery 虽然可以大大简化 XMLHttpRequest 的使用,但 XMLHttpRequest 本质上但并不是一个设计优良的 API: + 不符合关注分离(Separation of Concerns)的原则 + 配置和调用方式非常混乱 + 使用事件机制来跟踪状态变化 + 基于事件的异步模型没有现代的 Promise,generator/yield,async/await 友好

先看一个简单的 Fetch API 的例子:

fetch('/api').then(function(response) {
  return response.json();
}).then(function(data) {
  console.log(data);
}).catch(function(error) {
  console.log('Oops, error: ', error);
});

使用 ES6 的箭头函数后:

fetch('/api').then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.log('Oops, error: ', error))

可以看出使用Fetch后我们的代码更加简洁和语义化,链式调用的方式也使其更加流畅和清晰。但这种基于 Promise 的写法还是有 Callback 的影子,我们还可以用 async/await 来做最终优化:

async function() {
  try {
    let response = await fetch(url);
    let data = response.json();
    console.log(data);
  } catch (error) {
    console.log('Oops, error: ', error);
  }
}

使用 await 后,写代码就更跟同步代码一样。await 后面可以跟 Promise 对象,表示等待 Promise resolve() 才会继续向下执行,如果 Promise 被 reject() 或抛出异常则会被外面的 try...catch 捕获。

注:async/await 是非常新的 API,属于 ES7

接下来将上面基于 XMLHttpRequest 的 AJAX 用 Fetch 改写:

var options = {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ username: 'admin', password: 'root' }),
    credentials: 'include'
  };

fetch('/api', options).then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.log('Oops, error: ', error))

JS异步处理系列一 ES6 Promise中有用Promise对象实现的 Ajax 操作的例子

const getJSON = function (url) {
    const promise = new Promise(function (resolve, reject) {
        const handler = function () {
            if (this.readyState !== 4) {
                return;
            }
            if (this.status === 200) {
                resolve(this.response);
            } else {
                reject(new Error(this.statusText));
            }
        };
        const client = new XMLHttpRequest();
        client.open("GET", url);
        client.onreadystatechange = handler;
        client.responseType = "json";
        client.setRequestHeader("Accept", "application/json");
        client.send();

    });

    return promise;
};

getJSON("/package.json").then(function (json) {
    console.log('Contents: ' + json.name);
}, function (error) {
    console.error('出错了', error);
});

改为fetch后,可以这样做

fetch("/package.json").then(function (res) {
    if (res.ok) {
        res.json().then(function (data) {
            console.log(data.name);
        });
    }
}, function (error) {
    console.error('出错了', error);
});

对比可以看出,Fetch 是浏览器提供的原生 AJAX 接口

随着 React.js、Angular.js 和 Vue.js 这些前端框架的流行,很多单页面应用已经不再使用 jQuery 了,这意味着你要自己对 XMLHttpRequest 进行封装,而很多人选择封装一个跟 jQuery.ajax 差不多的接口。Fetch API 的出现,就是为了给类似的操作流程定一个接口规范。换句话说,就是浏览器帮你把 jQuery.ajax 给实现了,以后大家都是用 fetch 来发送异步请求就好了。

1.fetch(input, init)

这个方法接受两个参数:

  • input 定义要获取的资源。可以是一个 USVString 字符串,包含要获取资源的 URL。 也可以是一个 Request 对象。
  • init 这个参数是可选的,它传入一个配置项对象,可以用它来包括所对请求进行设置。

简单地说,input 参数就相当于我们使用 ajax 时传入的url。 那 ajax 的 type 参数在哪里配置呢?答案当然是在另一个参数 init 里。 init可以配置其他请求相关参数,包括:

  • method: 请求使用的方法,如 GET、POST。
  • headers: 请求的头信息,形式为 Headers 对象或 ByteString。
  • body: 请求的 body 信息,可能是一个 Blob、BufferSource、FormData、URLSearchParams 或者 USVString 对象。(如果是 GET 或 HEAD 方法,则不能包含 body 信息)
  • mode: 请求的模式,如 cors、 no-cors 或者 same-origin。
  • credentials: 请求的 credentials,如 omit、same-origin 或者 include。
  • cache: 请求的 cache 模式: default, no-store, reload, no-cache, force-cache, or only-if-cached。

现在我们配置好了请求,可是还有回调函数呢?ajax 有 success 字段,XMLHttpRequest 有 onstatechange 方法,fetch为我们实现了什么?从上面的 fetch 实例中,显而易见的,他的回调是在 then 中执行的。fetch方法返回一个Promise对象, 根据 Promise Api 的特性, fetch可以方便地使用then方法将各个处理逻辑串起来。

四、【译】为什么我不再使用Fetch API开发应用

当 fetch api 成为 web 标准时,我很激动,因为我再也不需要使用一些 http 工具库来做 http 请求了。XMLHttpRequest 太底层而且难以使用(它连名字都很诡异,为啥 XML 大写而 Http 不大写??)。你不得不自己封装它,或是从一大堆封装好的替代品中选择一个来使用,比如 jQuery 的$.ajax,Angualr 的 $http,superagent 以及我的最爱-- axios。然而,我们真的就此摆脱了 http 工具库吗?

有了 fetch,我再也不用从这一大堆工具库中做选择,也不用和同事争论到底哪一个是最好的了。我只需要引入一个 fetch polyfill,然后就可以愉快地使用标准的 api 了,它可是从众多用例和经验教训中总结设计出来的。

但是,当我们考察一些非常基本的实际场景时,会发现 http 工具库还是有用武之地的。fetch 是一个广受欢迎的新特性,能够帮助我们轻松地做一些底层操作,它就是为此而设计的。作为一个底层的 api,尽管抽象得更为合理,但在大多数应用中,我们不应该直接使用它。

1.错误处理
在一些简单的 fetch 示例中,fetch 看起来非常棒,它和我们习惯使用的 http 工具库很相似。比如这个使用 axios 的例子:

axios.get(url)
  .then(result => console.log('success:', result))
  .catch(error => console.log('error:', error));

我们可以用 fetch 改写成

fetch(url).then(response => response.json())
  .then(result => console.log('success:', result))
  .catch(error => console.log('error:', error));

很简单,对吧?细心的读者可能发现,我们需要加上一句 response.json() 来从 response 流对象中获取数据,但这只是一点很小的代价。我个人认为需要响应流只是一种特殊情况,通常在设计 api 时,我不会让特殊情况影响到通用情况,我会更倾向于允许用户提供一个标志,表明他们是否需要一个流,而不是硬塞给用户一个流对象。但总的来说,这不是什么大问题。

上例中真正重要的地方可能大家没有注意到(就像我第一次使用 fetch 时那样),那就是实际上这两段代码做的根本不是同一件事!之前我提到的所有 http 工具库会把状态码错误的响应(比如404,500等)当成一个错误来处理,而 fetch 与 XMLHttpRequest 一样,只会在网络错误的情况下(比如 IP 地址无法解析,服务器不可访问或是不允许 CORS)reject 这个 promise。

这意味着当服务器返回404的时候,第二段代码会打印出 'success'。如果想让上述代码的行为更符合直觉,在服务器返回错误码的时候,得到一个被 reject 的 promise,我们需要这样做:

fetch(url)
  .then(response => {
    return response.json().then(data => {
      if (response.ok) {
        return data;
      } else {
        return Promise.reject({status: response.status, data});
      }
    });
  })
  .then(result => console.log('success:', result))
  .catch(error => console.log('error:', error));

我敢肯定很多人要问了:“弄啥嘞?你向服务器发出请求并且得到了响应,管他是不是404呢,它确实是一个服务器返回的响应啊,为什么要和网络错误一样对待?”他们说得对,这只是一个视角的问题。我认为从一个开发者的角度,一个错误的响应应该和网络错误一样,被当做异常来对待。为了修复 fetch 的这种行为,我们只能这么做,因为没法改变 fetch 的标准行为。显然我们需要一种对开发者来说更合适的抽象。

2.POST 请求
另一种很常见的情形是向服务器发出一个 post 请求。借助于 axios,我们可以这样写:

axios.post('/user', {
  firstName: 'Fred',
  lastName: 'Flintstone'
});

当我刚开始使用 fetch api 时,我真是太乐观了,我当时想:太棒了,这个新 api 和我习惯使用的如此相似。然而,我最终浪费了几乎一个小时才成功发出一个 post 请求,因为这段代码并不能工作:

fetch('/user', {
  method: 'POST',
  body: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
});

我相信很多人和我一样,有过这样痛苦的经历后才能意识到,fetch 是一种底层的 api,它不会在我们处理这种一般情形时带来便利,你必须清楚明确地使用它。首先,JSON 必须先转换成字符串,然后还要设置 'Content-Type' 头部,指出实体的类型是 JSON,否则服务器会把它当做普通的字符串处理。我们应该这么写:

fetch('/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    firstName: 'Fred',
    lastName: 'Flintstone'
  })
});

好吧,对我来说,每次使用 fetch api 时写的这段代码实在是太长了。然而接下来你会看到,我们还得写更多!

3.默认行为
就像你看到的,你必须清楚明确地使用 fetch,如果你不写明你要什么,那么你什么也获取不到。举个栗子,上面提到的所有 fetch 调用都没法从我的服务器上获取到数据,因为:

  • 我的服务器使用基于 cookie 的认证方式,而 fetch 默认情况下不会发送 cookie
  • 我的服务器需要知道客户端是否可以处理 JSON 数据
  • 我的服务器在另一个子域名下,而 fetch 默认不启用 CORS
  • 为了防御 XSRF 攻击,我的服务器要求每一个请求都必须带上一个 X-XSRF-TOKEN 头部,来证明请求确实是从我自己的页面发出的

所以,我应该这么写:

fetch(url, {
  credentials: 'include',
  mode: 'cors',
  headers: {
    'Accept': 'application/json',
    'X-XSRF-TOKEN': getCookieValue('XSRF-TOKEN')
  }
});

不能说 fetch 的这种默认行为有问题,但如果我要在应用中多处发起请求,我需要一种能够改变这种默认行为的机制,使得 fetch 能在我的应用中正常工作。遗憾的是,fetch 并没有这种覆盖默认行为的机制。你可能已经猜到了,axios 里有:

axios.defaults.baseURL = 'https://api.example.com';
axios.defaults.headers.common['Accept'] = 'application/json';
axios.defaults.headers.post['Content-Type'] = 'application/json';

不过这只是为了演示,因为实际上上面提到包括 XSRF 防御在内的功能,都是 axios 默认提供的。axios 设计的目的是提供一种易用的向服务器发起请求的工具,而 fetch 必须设计得更为通用,这就是为什么它不是完成这项工作的最佳工具。

4.总结
假设你不使用一个 http 工具库,意味着相比于写这样一行代码:

function addUser(details) {
  return axios.post('https://api.example.com/user', details);
}

你得这么写:

function addUser(details) {
  return fetch('https://api.example.com/user', {
    mode: 'cors',
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify(details),
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-XSRF-TOKEN': getCookieValue('XSRF-TOKEN')
    }
  }).then(response => {
    return response.json().then(data => {
      if (response.ok) {
        return data;
      } else {
        return Promise.reject({status: response.status, data});
      }
    });
  });
}

每次 api 调用的时候都重复这么多代码显然不是个好主意。你可能会从中抽取出一个函数,交给项目中的同事使用,而不是直接使用 fetch。

当进行下一个项目时,你可能会将那个函数进一步封装成一个库。然后当更多的需求到来时,你会尝试精简 api、将它设计得更灵活、修复一些 bug,或是让你的 api 保持一致。你可能还会增加一些新特性,比如中断请求、自定义超时时间等。

你可能会完成一件非常棒的工作。但是你所做的只不过是创造了另一个 http 工具库,用它来代替 fetch api 在项目中使用。那还不如直接敲下 npm install --save axios,或是安装另一个你喜欢的 http 工具库,这会节约你大量的时间和精力。另外,仔细想想,你会在意这个 http 工具库在内部使用的是 fetch 还是 XMLHttpRequest 吗?

P.S.
我只是想再强调一下:我可不是说 fetch 有多糟糕!我认为上面提到的那些点并不是 fetch api 设计上的缺陷,对于一个底层 api 来说这些设计是完全合理的。我只是不推荐直接在应用中使用像 fetch 这样的底层 api。人们应该使用那些对底层进行了抽象,提供了高层 api 的工具,那会更符合他们的需求。

五、fetch 没有你想象的那么美

前端工程中发送 HTTP 请求从来都不是一件容易的事,前有骇人的 ActiveXObject,后有 API 设计十分别扭的 XMLHttpRequest,甚至这些原生 API 的用法至今仍是很多大公司前端校招的考点之一。

也正是如此,fetch 的出现在前端圈子里一石激起了千层浪,大家欢呼雀跃弹冠相庆恨不得马上把项目中的 $.ajax 全部干掉。然而,在新鲜感过后, fetch 真的有你想象的那么美好吗?

如果你还不了解 fetch,可以参考我的同事 @camsong 在 2015 年写的文章 《传统 Ajax 已死,Fetch 永生》

在开始「批斗」fetch之前,大家需要明确 fetch 的定位:fetch 是一个 low-level 的 API,它注定不会像你习惯的 $.ajax 或是 axios 等库帮你封装各种各样的功能或实现。也正是因为这个定位,在学习或使用 fetch API 时,你会遇到不少的挫折。

(对于没有耐心看完全文的同学,请先记住本文的主旨不在于批评 fetch,事实上 fetch 的出现绝对是前端领域的进步体现。在了解主旨的前提下,关注加黑部分即可。)

1.发请求,比你想象的要复杂
很多人看到 fetch 的第一眼肯定会被它简洁的 API 吸引:

fetch('http://abc.com/tiger.png');

原来需要 new XMLHttpRequest 等小十行代码才能实现的功能如今一行代码就能搞定,能不让人动心吗!

但是当你真正在项目中使用时,少不了需要向服务端发送数据的过程,那么使用 fetch 发送一个对象到服务端需要几行代码呢?(出于兼容性考虑,大部分的项目在发送 POST 请求时都会使用 application/x-www-form-urlencoded 这种 Content-Type)

先来看看使用 jQuery 如何实现:

$.post('/api/add', {name: 'test'});

然后再看看 fetch 如何处理:

fetch('/api/add', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
  },
  body: Object.keys({name: 'test'}).map((key) => {
    return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
  }).join('&')
});

等等,body 字段那一长串代码在干什么?因为 fetch 是一个 low-level 的 API,所以你需要自己 encode HTTP 请求的 payload,还要自己指定 HTTP Header 中的 Content-Type 字段。

这样就结束了吗?如果你在自己的项目中这样发送 POST 请求,很可能会得到一个 401 Unauthorized 的结果(视你的服务端如何处理无权限的情况而定)。如果你在仔细看一遍文档,会发现原来 fetch 在发送请求时默认不会带上 Cookie!

好,我们让 fetch 带上 Cookie:

fetch('/api/add', {
  method: 'POST',
  credentials: 'include',
  ...
});

这样,一个最基础的 POST 请求才算能够发出去。

同理,如果你需要 POST 一个 JSON 到服务端,你需要这样做:

fetch('/api/add', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  },
  body: JSON.stringify({name: 'test'})
});

相比于 $.ajax 的封装,是不是复杂的不是一点半点呢?

2.错误处理,比你想象的复杂
按理说,fetch 是基于 Promise 的 API,每个 fetch 请求会返回一个 Promise 对象,而 Promise 的异常处理且不论是否方便,起码大家是比较熟悉的了。然而 fetch 的异常处理,还是有不少门道。

假如我们用 fetch 请求一个不存在的资源:

fetch('xx.png')
.then(() => {
  console.log('ok');
})
.catch(() => {
  console.log('error');
});

按照我们的惯例 console 应该要打印出 「error」才对,可事实又如何呢?有图有真相:


image.png

为什么会打印出 「ok」呢?

按照 MDN 的说法,fetch 只有在遇到网络错误的时候才会 reject 这个 promise,比如用户断网或请求地址的域名无法解析等。只要服务器能够返回 HTTP 响应(甚至只是 CORS preflight 的 OPTIONS 响应),promise 一定是 resolved 的状态。

所以要怎么判断一个 fetch 请求是不是成功呢?你得用 response.ok 这个字段:

fetch('xx.png')
.then((response) => {
  if (response.ok) {
    console.log('ok');
  } else {
    console.log('error');
  }
})
.catch(() => {
  console.log('error');
});

再执行一次,终于看到了正确的日志:


image.png

3.Stream API,比你想象的复杂
当你的服务端返回的数据是 JSON 格式时,你肯定希望 fetch 返回给你的是一个普通 JavaScript 对象,然而你拿到的是一个 Response 对象,而真正的请求结果 —— 即 response.body —— 则是一个 ReadableStream。

// 服务端返回 {"name": "test", "age": 1} 字符串
fetch('/api/user.json?id=2').then((response) => {
    //这里拿到的 response 并不是一个 {name: 'test', age: 1} 对象
    //将 response.body 通过 JSON.parse 转换为 JS 对象
    return response.json();
}).then(data => {
    // {name: 'test', age: 1}
    console.log(data);
});

你可能觉得,这些写在规范里的技术细节使用 fetch 的人无需关心,然而在实际使用过程中你会遇到各种各样的问题迫使你不得不了解这些细节。

首先需要承认,fetch 将 response.body 设计成 ReadableStream 其实是非常有前瞻性的,这种设计让你在请求大体积文件时变得非常有用。然而,在我们的日常使用中,还是短小的 JSON 片段更加常见。而为了兼容不常见的设计,我们不得不多一次 response.json() 的调用。

不仅是调用变得麻烦,如果你的服务端采用了严格的 REST 风格,对于某些特殊情况并没有返回 JSON 字符串,而是用了 HTTP 状态码(如:204 No Content),那么在调用 response.json() 时则会抛出异常。

此外,Response 还限制了响应内容的重复读取和转换,例如如下代码:

var prevFetch = window.fetch;
window.fetch = function() {
  prevFetch.apply(this, arguments)
  .then(response => {
    return new Promise((resolve, reject) => {
      response.json().then(data => {
        if (data.hasError === true) {
          tracker.log('API Error');
        }
        resolve(response);
      });
    });
  });
}

fetch('/api/user.json?id=1')
.then(response => {
  return response.json();  // 先将结果转换为 JSON 对象
})
.then(data => {
  console.log(data);
});

是对 fetch 做了一个简单的 AOP,试图拦截所有的请求结果,并当返回的 JSON 对象中 hasError 字段如果为 true 的话,打点记录出错的接口。然而这样的代码会导致如下错误:Uncaught TypeError: Already read

调试一番后,你会发现是因为我们在切面中已经调用了 response.json(),这个时候重复调用该方法时就会报错。(实际上,再次调用其它任何转换方法,如 .text() 也会报错)因此,想要在 fetch 上实现 AOP 仍需另辟蹊径。

4.其它问题

  • fetch 不支持同步请求
    大家都知道同步请求阻塞页面交互,但事实上仍有不少项目在使用同步请求,可能是历史架构等等原因。如果你切换了 fetch 则无法实现这一点。

  • fetch 不支持取消一个请求
    使用 XMLHttpRequest 你可以用 xhr.abort() 方法取消一个请求(虽然这个方法也不是那么靠谱,同时是否真的「取消」还依赖于服务端的实现),但是使用 fetch 就无能为力了,至少目前是这样的。

  • fetch 无法查看请求的进度
    使用 XMLHttpRequest 你可以通过 xhr.onprogress 回调来动态更新请求的进度,而这一点目前 fetch 还没有原生支持。

5.小结
还是要再次明确,fetch API 的出现绝对是推动了前端在请求发送功能方面的进步。
然而,也需要意识到,fetch 是一个相当底层的 API,在实际项目使用中,需要做各种各样的封装和异常处理,而并非开箱即用,更做不到直接替换 $.ajax 或其他请求库。

六、流式传输在直播/点播中很重要

参考
译文 Fetch (XHR不可否认的局限性) ,英文原文在此:Fetch (or the undeniable limitations of XHR)
fetch 能做哪些 XHR(XMLHttpRequest) 不能做的事
举个例子,从一个很大的 utf-8 格式的 .txt 文件中,读取流式内容并显示至页面上一个 div 块上:

document.addEventListener('DOMContentLoaded', function (e) {
    var url = 'LargeFile.txt';

    var progress = 0;
    var contentLength = 0;

    fetch(url).then(function(response) {
        // get the size of the request via the headers of the response
        contentLength = response.headers.get('Content-Length');

        var pump = function(reader) {
            return reader.read().then(function(result) {
                // if we're done reading the stream, return
                if (result.done) {
                    return;
                }

                // retrieve the multi-byte chunk of data
                var chunk = result.value;

                var text = '';
                // since the chunk can be multiple bytes, iterate through
                // each byte while skipping the byte order mark
                // (assuming UTF-8 with single-byte chars)
                for (var i = 3; i < chunk.byteLength; i++) {
                    text += String.fromCharCode(chunk[i]);
                }

                // append the contents to the page
                document.getElementById('content').innerHTML += text;

                // report our current progress
                progress += chunk.byteLength;
                console.log(((progress / contentLength) * 100) + '%');

                // go to next chunk via recursion
                return pump(reader);
            });
        }

        // start reading the response stream
        return pump(response.body.getReader());
    })
    .catch(function(error) {
        console.log(error);
    });
});

现在我们可以当数据进来时就缓存下来,而且我们也不必等到数据全部读取完毕才显示内容。使响应体流式化减少了该站点的内存占用,并在网络连接很慢时为展示内容提供了更快的感知速度。而 XHR 只能缓存整个响应体,不能以小块的形式操作数据。虽然用 XHR 建立一个流是有可能的,然而这会导致 responseText 持续增长,而且你必须不断手动地从中获取数据。除此之外,当在流式传输时,Fetch APIs 还提供了访问数据的实际字节的方法,而 XHR 的 responseText 只有文本形式,这意味着在某些场景下它的作用可能非常有限。

现在,许多用户仍在使用尚不支持 Fetch 的浏览器,所以 web 开发者们继续使用 XHR 情有可原。然而,这并不意味着现在他们就无法利用 Fetch 了;GitHub’s Fetch polyfill ,基于 XHR 而构建(存在一些局限,例如缺乏真正的流式),给那些尚不支持 Fetch 的浏览器提供了一个伟大的起点。同时,对于支持 Fetch 的用户代理,开发者们可以利用 Fetch 引入的新能力(例如真正的流式)来渐进增强开发体验。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容