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

原文链接:Why I won’t be using Fetch API in my apps
作者:Shahar Talmi

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

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

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

错误处理

在一些简单的 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 的标准行为。显然我们需要一种对开发者来说更合适的抽象。

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 时写的这段代码实在是太长了。然而接下来你会看到,我们还得写更多!

默认行为

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

  1. 我的服务器使用基于 cookie 的认证方式,而 fetch 默认情况下不会发送 cookie
  2. 我的服务器需要知道客户端是否可以处理 JSON 数据
  3. 我的服务器在另一个子域名下,而 fetch 默认不启用 CORS
  4. 为了防御 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 必须设计得更为通用,这就是为什么它不是完成这项工作的最佳工具。

总结

假设你不使用一个 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 的工具,那会更符合他们的需求。

编辑于 2017-07-31

文章被以下专栏收录