fetch雖好用,但也有缺陷。它先天缺失超時機制,終止機制,進度反饋等。本文在講解fetch的同時,手把手教你寫fetch的timeout,abort以及progress。
關於
- 我的部落格:louis blog
- SF專欄:路易斯前端深度課
- 原文連結:Fetch進階指南
導讀
Fetch 是 web非同步通訊的未來. 從chrome42, Firefox39, Opera29, EdgeHTML14(並非Edge版本)起, fetch就已經被支援了. 其中chrome42~45版本, fetch對中文支援有問題, 建議從chrome46起使用fetch. 傳送門: fetch中文亂碼 .
Fetch
先過一遍Fetch原生支援率.
可見要想在IE8/9/10/11中使用fetch還是有些犯難的,畢竟它連 Promise 都不支援, 更別說fetch了. 別急, 這裡有polyfill(墊片).
- es5 的 polyfill —
es5-shim, es5-sham
. - Promise 的 polyfill —
es6-promise
. - fetch 的 polyfill —
fetch-ie8
.
由於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可以在任何域名的網站直接執行, 且能正常返回百度搜尋的建議詞條. 以下是常規輸入時的是介面截圖.
以下是剛才fetch到的部分資料. 其中key name 為"s"的欄位的value就是以上的建議詞條.(由於有高亮詞條"12306", 最後一條資料"12366"被頂下去了, 故上面截圖上看不到)
看完栗子過後, 就要動真格了. 下面就來扒下 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);
});複製程式碼
執行截圖如下:
② 我們不妨在非 sp0.baidu.com 域名的網頁控制檯再次執行以上程式碼.(別忘了給fetch的第二引數傳遞{mode: "no-cors"})
執行截圖如下:
由於第一次進入then分支後, 返回了否定結果的 Promise.reject 物件. 因此程式碼進入到catch分支, 丟擲了錯誤. 此時, 上述 response.type
為 opaque
.
response type
一個fetch請求的響應型別(response.type)為如下三種之一:
- basic
- cors
- opaque
如上情景①, 同域下, 響應型別為 "basic".
如上情景②中, 跨域下, 伺服器沒有返回CORS響應頭, 響應型別為 "opaque". 此時我們幾乎不能檢視任何有價值的資訊, 比如不能檢視response, status, url等等等等.
同樣是跨域下, 如果伺服器返回了CORS響應頭, 那麼響應型別將為 "cors". 此時響應頭中除 Cache-Control
, Content-Language
, Content-Type
, Expores
, Last-Modified
和 Progma
之外的欄位都不可見.
注意: 無論是同域還是跨域, 以上 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標準 中特殊的值, 這裡不做詳細介紹.
header
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){
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, 我們可以使用以下方法之一執行我們的程式碼.
隨著node7.0的釋出, node中可以使用如下方式直接執行:
node --harmony-async-await test.js複製程式碼
babel線上編譯並執行 Babel · The compiler for writing next generation JavaScript .
本地使用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');複製程式碼
本地使用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.
本次徵文活動的連結: juejin.im/post/58d8e9…
本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.
本文作者: louis
本文連結: louiszhai.github.io/2016/10/19/…
參考文章