手把手教你玩轉Fetch | 掘金技術徵文

路易斯發表於2017-04-25

fetch雖好用,但也有缺陷。它先天缺失超時機制,終止機制,進度反饋等。本文在講解fetch的同時,手把手教你寫fetch的timeout,abort以及progress。

關於

導讀

Fetch 是 web非同步通訊的未來. 從chrome42, Firefox39, Opera29, EdgeHTML14(並非Edge版本)起, fetch就已經被支援了. 其中chrome42~45版本, fetch對中文支援有問題, 建議從chrome46起使用fetch. 傳送門: fetch中文亂碼 .

Fetch

先過一遍Fetch原生支援率.

手把手教你玩轉Fetch | 掘金技術徵文
手把手教你玩轉Fetch | 掘金技術徵文

可見要想在IE8/9/10/11中使用fetch還是有些犯難的,畢竟它連 Promise 都不支援, 更別說fetch了. 別急, 這裡有polyfill(墊片).

由於IE8基於ES3, IE9支援大部分ES5, IE11支援少量ES5, 其中只有IE10對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 物件.

手把手教你玩轉Fetch | 掘金技術徵文
response headers

該fetch可以在任何域名的網站直接執行, 且能正常返回百度搜尋的建議詞條. 以下是常規輸入時的是介面截圖.

手把手教你玩轉Fetch | 掘金技術徵文
response headers

以下是剛才fetch到的部分資料. 其中key name 為"s"的欄位的value就是以上的建議詞條.(由於有高亮詞條"12306", 最後一條資料"12366"被頂下去了, 故上面截圖上看不到)

手把手教你玩轉Fetch | 掘金技術徵文
response headers

看完栗子過後, 就要動真格了. 下面就來扒下 Fetch.

Promise特性

fetch方法返回一個Promise物件, 根據 Promise Api 的特性, fetch可以方便地使用then方法將各個處理邏輯串起來, 使用 Promise.resolve() 或 Promise.reject() 方法將分別返會肯定結果的Promise或否定結果的Promise, 從而呼叫下一個then 或者 catch. 一但then中的語句出現錯誤, 也將跳到catch中.

Promise若有疑問, 請閱讀 Promises .

① 我們不妨在 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 | 掘金技術徵文
fetch then

② 我們不妨在非 sp0.baidu.com 域名的網頁控制檯再次執行以上程式碼.(別忘了給fetch的第二引數傳遞{mode: "no-cors"})

執行截圖如下:

手把手教你玩轉Fetch | 掘金技術徵文
fetch catch

由於第一次進入then分支後, 返回了否定結果的 Promise.reject 物件. 因此程式碼進入到catch分支, 丟擲了錯誤. 此時, 上述 response.typeopaque .

response type

一個fetch請求的響應型別(response.type)為如下三種之一:

  • basic
  • cors
  • opaque

如上情景①, 同域下, 響應型別為 "basic".

如上情景②中, 跨域下, 伺服器沒有返回CORS響應頭, 響應型別為 "opaque". 此時我們幾乎不能檢視任何有價值的資訊, 比如不能檢視response, status, url等等等等.

手把手教你玩轉Fetch | 掘金技術徵文
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 函式的含義與用法 - 阮一峰的網路日誌) 無論如何, 總感覺彆扭. 如下摘自推庫的一張圖.

手把手教你玩轉Fetch | 掘金技術徵文

我們不難看出其中的差距, 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){
    console.log(item);
    await wait(1000);//這是錯誤的寫法
  });
})();
//因await只能用於 async 宣告的函式上下文中, 故不能寫在forEach內.下面我們來看正確的寫法
(async ()=>{
  for(let i=0,len=array.length;i<len;i++){
    console.log(array[i]);
    await wait(1000);
  }
})();複製程式碼
如何試執行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));複製程式碼

以下是日誌截圖:

手把手教你玩轉Fetch | 掘金技術徵文

剛好github上有個fetch progress的demo, 感興趣的小夥伴請參看這裡: Fetch Progress DEMO .

我們不妨來對比下, 使用xhr的onprogress事件回撥, 輸出如下:

手把手教你玩轉Fetch | 掘金技術徵文

我試著適當增加響應body的size, 發現xhr的onprogress事件回撥依然只執行兩次. 通過多次測試發現其執行頻率比較低, 遠不及fetch progress.


本次徵文活動的連結: juejin.im/post/58d8e9…
本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.

本文作者: louis

本文連結: louiszhai.github.io/2016/10/19/…

參考文章

相關文章