Fetch进阶指南

目录
  1. 导读
  2. Fetch
    1. 尝试一个fetch
    2. Promise特性
    3. response type
    4. mode
    5. header
    6. post
    7. credentials
    8. catch
    9. cache
    10. async/await
      1. 为什么是async/await
      2. async/await语法
      3. 如何试运行async/await
  3. 如何弥补Fetch的不足
    1. fetch-jsonp
    2. abort
    3. timeout
    4. progress

导读

对于前端来说,Axios应该不陌生,自从尤大推荐后,Axios几乎成了前端必备工具库,Axios的体积也与日俱增,当前最新版本已经达到了14k的size,这样的大小,在sdk中引用是不太合适的,而XMLHttpRequest又过于原始,还不支持promise,需要进一步封装。那么有没有简单便捷的ajax API呢?它就是Fetch。

Fetch 是 web异步通信的未来. 从chrome42, Firefox39, Opera29, EdgeHTML14(并非Edge版本)起, fetch就已经被支持了. 其中chrome42~45版本, fetch对中文支持有问题, 建议从chrome46起使用fetch. 传送门: fetch中文乱码 .

Fetch

先过一遍Fetch原生支持率.


以下是2020年7月4日更新的 Fetch 兼容性统计,除了 IE 系列,Fetch 兼容性基本没什么大问题。

可见要想在IE8/9/10/11中使用fetch还是有些犯难的,毕竟它连 Promise 都不支持, 更别说fetch了. 别急, 这里有polyfill(垫片).

由于IE8基于ES3, IE9、IE10、IE11基于ES5,但支持不完全. 因此IE8+浏览器, 建议依次装载上述垫片.

尝试一个fetch

先来看一个简单的fetch.

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
fetch(url,{mode: "no-cors"}).then(function(response) {
  return response;
}).then(function(data) {
  console.log(data);
}).catch(function(e) {
  console.log("Oops, error");
});

fetch执行后返回一个 Promise 对象, 执行成功后, 成功打印出 Response 对象.

response headers

该fetch可以在任何域名的网站直接运行, 且能正常返回百度搜索的建议词条. 以下是常规输入时的是界面截图.

response headers

以下是刚才fetch到的部分数据. 其中key name 为”s”的字段的value就是以上的建议词条.(由于有高亮词条”12306”, 最后一条数据”12366”被顶下去了, 故上面截图上看不到)

response headers

看完栗子过后, 就要动真格了. 下面就来扒下 Fetch.

Promise特性

fetch方法返回一个Promise对象, 根据 Promise Api 的特性, fetch可以方便地使用then方法将各个处理逻辑串起来, 使用 Promise.resolve() 或 Promise.reject() 方法将分别返会肯定结果的Promise或否定结果的Promise, 从而调用下一个then 或者 catch. 一但then中的语句出现错误, 也将跳到catch中.

Promise若有疑问, 请阅读 Promises .

① 我们不妨在 https://sp0.baidu.com 域名的网页控制台运行以下代码.

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
fetch(url).then(function(response){
  console.log('第一次进入then...');
  if(response.status>=200 && response.status<300){
    console.log('Content-Type: ' + response.headers.get('Content-Type'));
    console.log('Date: ' + response.headers.get('Date'));
    console.log('status: ' + response.status);
    console.log('statusText: ' + response.statusText);
    console.log('type: ' + response.type);
    console.log('url: ' + response.url);
    return Promise.resolve(response);
  }else{
    return Promise.reject(new Error(response.statusText));
  }
}).then(function(data){
  console.log('第二次进入then...');
  console.log(data);
}).catch(function(e){
  console.log('抛出的错误如下:');
  console.log(e);
});

运行截图如下:

fetch then

② 我们不妨在非 https://sp0.baidu.com 域名的网页控制台再次运行以上代码.(别忘了给fetch的第二参数传递{mode: “no-cors”})

运行截图如下:

fetch catch

由于第一次进入then分支后, 返回了否定结果的 Promise.reject 对象. 因此代码进入到catch分支, 抛出了错误. 此时, 上述 response.typeopaque .

response type

一个fetch请求的响应类型(response.type)为如下三种之一:

  • basic
  • cors
  • opaque

如上情景①, 同域下, 响应类型为 “basic”.

如上情景②中, 跨域下, 服务器没有返回CORS响应头, 响应类型为 “opaque”. 此时我们几乎不能查看任何有价值的信息, 比如不能查看response, status, url等等等等.

fetch type

同样是跨域下, 如果服务器返回了CORS响应头, 那么响应类型将为 “cors”. 此时响应头中除 Cache-Control , Content-Language , Content-Type , Expores , Last-ModifiedProgma 之外的字段都不可见.

注意: 无论是同域还是跨域, 以上 fetch 请求都到达了服务器.

mode

fetch可以设置不同的模式使得请求有效. 模式可在fetch方法的第二个参数对象中定义.

fetch(url, {mode: 'cors'});

可定义的模式如下:

  • same-origin: 表示同域下可请求成功; 反之, 浏览器将拒绝发送本次fetch, 同时抛出错误 “TypeError: Failed to fetch(…)”.
  • cors: 表示同域和带有CORS响应头的跨域下可请求成功. 其他请求将被拒绝.
  • cors-with-forced-preflight: 表示在发出请求前, 将执行preflight检查.
  • no-cors: 常用于跨域请求不带CORS响应头场景, 此时响应类型为 “opaque”.

除此之外, 还有两种不太常用的mode类型, 分别是 navigate , websocket , 它们是 HTML标准 中特殊的值, 这里不做详细介绍.

fetch获取http响应头非常easy. 如下:

fetch(url).then(function(response) { 
    console.log(response.headers.get('Content-Type'));
});

设置http请求头也一样简单.

var headers = new Headers();
headers.append("Content-Type", "text/html");
fetch(url,{
  headers: headers
});

header的内容也是可以被检索的.

var header = new Headers({
  "Content-Type": "text/plain"
});
console.log(header.has("Content-Type")); //true
console.log(header.has("Content-Length")); //false

post

在fetch中发送post请求, 同样可以在fetch方法的第二个参数对象中设置.

var headers = new Headers();
headers.append("Content-Type", "application/json;charset=UTF-8");
fetch(url, {
  method: 'post',
  headers: headers,
  body: JSON.stringify({
    date: '2016-10-08',
    time: '15:16:00'
  })
});

credentials

跨域请求中需要带有cookie时, 可在fetch方法的第二个参数对象中添加credentials属性, 并将值设置为”include”.

fetch(url,{
  credentials: 'include'
});

除此之外, credentials 还可以取以下值:

  • omit: 缺省值, 默认为该值.
  • same-origin: 同源, 表示同域请求才发送cookie.

catch

同 XMLHttpRequest 一样, 无论服务器返回什么样的状态码(chrome中除407之外的其他状态码), 它们都不会进入到错误捕获里. 也就是说, 此时, XMLHttpRequest 实例不会触发 onerror 事件回调, fetch 不会触发 reject. 通常只在网络出现问题时或者ERR_CONNECTION_RESET时, 它们才会进入到相应的错误捕获里. (其中, 请求返回状态码为407时, chrome浏览器会触发onerror或者reject掉fetch.)

cache

cache表示如何处理缓存, 遵守http规范, 拥有如下几种值:

  • default: 表示fetch请求之前将检查下http的缓存.
  • no-store: 表示fetch请求将完全忽略http缓存的存在. 这意味着请求之前将不再检查下http的缓存, 拿到响应后, 它也不会更新http缓存.
  • no-cache: 如果存在缓存, 那么fetch将发送一个条件查询request和一个正常的request, 拿到响应后, 它会更新http缓存.
  • reload: 表示fetch请求之前将忽略http缓存的存在, 但是请求拿到响应后, 它将主动更新http缓存.
  • force-cache: 表示fetch请求不顾一切的依赖缓存, 即使缓存过期了, 它依然从缓存中读取. 除非没有任何缓存, 那么它将发送一个正常的request.
  • only-if-cached: 表示fetch请求不顾一切的依赖缓存, 即使缓存过期了, 它依然从缓存中读取. 如果没有缓存, 它将抛出网络错误(该设置只在mode为”same-origin”时有效).

如果fetch请求的header里包含 If-Modified-Since, If-None-Match, If-Unmodified-Since, If-Match, 或者 If-Range 之一, 且cache的值为 default , 那么fetch将自动把 cache的值设置为 "no-store" .

async/await

为什么是async/await

回调深渊一直是jser的一块心病, 虽然ES6提供了 Promise, 将嵌套平铺, 但使用起来依然不便.

要说ES6也提供了generator/yield, 它将一个函数执行暂停, 保存上下文, 再次调用时恢复当时的状态.(学习可参考 Generator 函数的含义与用法 - 阮一峰的网络日志) 无论如何, 总感觉别扭. 如下摘自推库的一张图.

我们不难看出其中的差距, callback简单粗暴, 层层回调, 回调越深入, 越不容易捋清楚逻辑. Promise 将异步操作规范化.使用then连接, 使用catch捕获错误, 堪称完美, 美中不足的是, then和catch中传递的依然是回调函数, 与心目中的同步代码不是一个套路.

为此, ES7 提供了更标准的解决方案 — async/await. async/await 几乎没有引入新的语法, 表面上看起来, 它就和alert一样易用, 虽然它尚处于ES7的草案中, 不过这并不影响我们提前使用它.

async/await语法

async 用于声明一个异步函数, 该函数需返回一个 Promise 对象. 而 await 通常后接一个 Promise对象, 需等待该 Promise 对象的 resolve() 方法执行并且返回值后才能继续执行. (如果await后接的是其他对象, 便会立即执行)

因此, async/await 天生可用于处理 fetch请求(毫无违和感). 如下:

var word = '123',
    url = 'https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd='+word+'&json=1&p=3';
(async ()=>{
  try {
    let res = await fetch(url, {mode: 'no-cors'});//等待fetch被resolve()后才能继续执行
    console.log(res);
  } catch(e) {
    console.log(e);
  }
})();

自然, async/await 也可处理 Promise 对象.

let wait = function(ts){
  return new Promise(function(resolve, reject){
    setTimeout(resolve,ts,'Copy that!');
  });
};
(async function(){
  try {
    let res = await wait(1000);//① 等待1s后返回结果
    console.log(res);
    res = await wait(1000);//② 重复执行一次
    console.log(res);
  } catch(e) {
    console.log(e);
  }
})();
//"Copy that!"

可见使用await后, 可以直接得到返回值, 不必写 .then(callback) , 也不必写 .catch(error) 了, 更可以使用 try catch 标准语法捕获错误.

由于await采用的是同步的写法, 看起来它就和alert函数一样, 可以自动阻塞上下文. 因此它可以重复执行多次, 就像上述代码②一样.

可以看到, await/async 同步阻塞式的写法解决了完全使用 Promise 的一大痛点——不同Promise之间共享数据问题. Promise 需要设置上层变量从而实现数据共享, 而 await/async 就不存在这样的问题, 只需要像写alert一样书写就可以了.

值得注意的是, await 只能用于 async 声明的函数上下文中. 如下 forEach 中, 是不能直接使用await的.

let array = [0,1,2,3,4,5];
(async ()=>{
  array.forEach(function(item){
    await wait(1000);//这是错误的写法, 因await不在async声明的函数上下文中
    console.log(item);
  });
})();

如果是试图将async声明的函数作为回调传给forEach,该回调将同时触发多次,回调内部await依然有效,只是多次的await随着回调一起同步执行了,这便不符合我们阻塞循环的初衷。如下:

const fn = async (item)=>{
  await wait(1000); // 循环中的多个await同时执行,因此等待1s后将同时输出数组各个元素
  console.log(item);
};
array.forEach(fn);

正确的写法如下:

(async ()=>{
  for(let i=0,len=array.length;i<len;i++){
    await wait(1000);
    console.log(array[i]);
  }
})();
如何试运行async/await

鉴于目前只有Edge支持 async/await, 我们可以使用以下方法之一运行我们的代码.

  1. 随着node7.0的发布, node中可以使用如下方式直接运行:

    node --harmony-async-await test.js
    
  2. babel在线编译并运行 Babel · The compiler for writing next generation JavaScript .

  3. 本地使用babel编译es6或更高版本es.

    1) 安装.

    由于Babel5默认自带各种转换插件, 不需要手动安装. 然而从Babel6开始, 插件需要手动下载, 因此以下安装babel后需要再顺便安装两个插件.

    npm i babel-cli -g    # babel已更名为babel-cli
    npm install babel-preset-es2015 --save-dev
    npm install babel-preset-stage-0 --save-dev
    

    2) 书写.babelrc配置文件.

    {
        "presets": [
            "es2015",
            "stage-0"
        ],
        "plugins": []
    }
    

    3) 如果不配置.babelrc. 也可在命令行显式指定插件.

    babel es6.js -o es5.js --presets es2015 stage-0 # 指定使用插件es2015和stage-0编译js
    

    4) 编译.

    babel es6.js -o es5.js  # 编译源文件es6.js,输出为es5.js,编译规则在上述.babelrc中指定
    babel es6.js --out-file es5.js # 或者将-o写全为--out-file也行
    bable es6.js # 如果不指定输出文件路径,babel会将编译生成的文本标准输出到控制台
    

    5) 实时编译

    babel es6.js -w -o es5.js # 实时watch es6.js的变化,一旦改变就重新编译
    babel es6.js -watch -o es5.js # -w也可写全为--watch
    

    6) 编译目录输出到其他目录

    babel src -d build # 编译src目录下所有js,并输出到build目录
    babel src --out-dir build # -d也可写全为--out-dir
    

    7) 编译目录输出到单个文件

    babel src -o es5.js # 编译src目录所有js,合并输出为es5.js
    

    8) 想要直接运行es6.js, 可使用babel-node.

    npm i babel-node -g # 全局安装babel-node
    babel-node es6.js # 直接运行js文件
    

    9) 如需在代码中使用fetch, 且使用babel-node运行, 需引入 node-fetch 模块.

    npm i node-fetch --save-dev
    

    然后在es6.js中require node-fetch 模块.

    var fetch = require('node-fetch');
    
  4. 本地使用traceur编译es6或更高版本es.请参考 在项目开发中优雅地使用ES6:Traceur & Babel .

如何弥补Fetch的不足

fetch基于Promise, Promise受限, fetch也难幸免. ES6的Promise基于 Promises/A+ 规范 (对规范感兴趣的同学可选读 剖析源码理解Promises/A规范 ), 它只提供极简的api, 没有 timeout 机制, 没有 progress 提示, 没有 deferred 处理 (这个可以被async/await替代).

fetch-jsonp

除此之外, fetch还不支持jsonp请求. 不过办法总比问题多, 万能的开源作者提供了 fetch-jsonp 库, 解决了这个问题.

fetch-jsonp 使用起来非常简单. 如下是安装:

npm install fetch-jsonp --save-dev

如下是使用:

fetchJsonp(url, {
  timeout: 3000,
  jsonpCallback: 'callback'
}).then(function(response) {
  console.log(response.json());
}).catch(function(e) {
  console.log(e)
});

abort

由于Promise的限制, fetch 并不支持原生的abort机制, 但这并不妨碍我们使用 Promise.race() 实现一个.

Promise.race(iterable) 方法返回一个Promise对象, 只要 iterable 中任意一个Promise 被 resolve 或者 reject 后, 外部的Promise 就会以相同的值被 resolve 或者 reject.

支持性: 从 chrome33, Firefox29, Safari7.1, Opera20, EdgeHTML12(并非Edge版本) 起, Promise就被完整的支持. Promise.race()也随之可用. 下面我们来看下实现.

var _fetch = (function(fetch){
  return function(url,options){
    var abort = null;
    var abort_promise = new Promise((resolve, reject)=>{
      abort = () => {
        reject('abort.');
        console.info('abort done.');
      };
    });
    var promise = Promise.race([
      fetch(url,options),
      abort_promise
    ]);
    promise.abort = abort;
    return promise;
  };
})(fetch);

然后, 使用如下方法测试新的fetch.

var p = _fetch('https://www.baidu.com',{mode:'no-cors'});
p.then(function(res) {
    console.log('response:', res);
}, function(e) {
    console.log('error:', e);
});
p.abort();
//"abort done."
//"error: abort."

以上, fetch请求后, 立即调用abort方法, 该promise被拒绝, 符合预期. 细心的同学可能已经注意到了, “p.abort();” 该语句我是单独写一行的, 没有链式写在then方法之后. 为什么这么干呢? 这是因为then方法调用后, 返回的是新的promise对象. 该对象不具有abort方法, 因此使用时要注意绕开这个坑.

timeout

同上, 由于Promise的限制, fetch 并不支持原生的timeout机制, 但这并不妨碍我们使用 Promise.race() 实现一个.

下面是一个简易的版本.

function timer(t){
  return new Promise(resolve=>setTimeout(resolve, t))
  .then(function(res) {
    console.log('timeout');
  });
}
var p = fetch('https://www.baidu.com',{mode:'no-cors'});
Promise.race([p, timer(1000)]);
//"timeout"

实际上, 无论超时时间设置为多长, 控制台都将输出log “timeout”. 这是因为, 即使fetch执行成功, 外部的promise执行完毕, 此时 setTimeout 所在的那个promise也不会reject.

下面我们来看一个类似xhr版本的timeout.

var _fetch = (function(fetch){
  return function(url,options){
    var abort = null,
        timeout = 0;
    var abort_promise = new Promise((resolve, reject)=>{
      abort = () => {
        reject('timeout.');
        console.info('abort done.');
      };
    });
    var promise = Promise.race([
      fetch(url,options),
      abort_promise
    ]);
    promise.abort = abort;
    Object.defineProperty(promise, 'timeout',{
      set: function(ts){
        if((ts=+ts)){
          timeout = ts;
          setTimeout(abort,ts);
        }
      },
      get: function(){
        return timeout;
      }
    });
    return promise;
  };
})(fetch);

然后, 使用如下方法测试新的fetch.

var p = _fetch('https://www.baidu.com',{mode:'no-cors'});
p.then(function(res) {
    console.log('response:', res);
}, function(e) {
    console.log('error:', e);
});
p.timeout = 1;
//"abort done."
//"error: timeout."

progress

xhr的 onprogress 让我们可以掌控下载进度, fetch显然没有提供原生api 做类似的事情. 不过 Fetch中的Response.body 中实现了getReader()方法用于读取原始字节流, 该字节流可以循环读取, 直到body下载完成. 因此我们完全可以模拟fetch的progress.

以下是 stackoverflow 上的一段代码, 用于模拟fetch的progress事件. 为了方便测试, 请求url已改为本地服务.(原文请戳 javascript - Progress indicators for fetch? - Stack Overflow)

function consume(reader) {
  var total = 0
  return new Promise((resolve, reject) => {
    function pump() {
      reader.read().then(({done, value}) => {
        if (done) {
          resolve();
          return;
        }
        total += value.byteLength;
        console.log(`received ${value.byteLength} bytes (${total} bytes in total)`);
        pump();
      }).catch(reject)
    }
    pump();
  });
}
fetch('http://localhost:10101/notification/',{mode:'no-cors'})
  .then(res => consume(res.body.getReader()))
  .then(() => console.log("consumed the entire body without keeping the whole thing in memory!"))
  .catch(e => console.log("something went wrong: " + e));

以下是日志截图:

刚好github上有个fetch progress的demo, 感兴趣的小伙伴请参看这里: Fetch Progress DEMO .

我们不妨来对比下, 使用xhr的onprogress事件回调, 输出如下:

当适当增加响应body的size, 发现xhr的onprogress事件回调依然只执行两次. 通过多次测试发现其执行频率比较低, 远不及fetch progress.


本问就讨论这么多内容,大家有什么问题或好的想法欢迎在下方参与留言和评论.

本文作者: louis

本文链接: http://louiszhai.github.io/2016/10/19/fetch/

参考文章

Fork me on GitHub