Fluent Fetcher: 重構基於 Fetch 的 JavaScript 網絡請求庫從屬於筆者的 Web 開發基礎與工程實踐系列文章與項目,記述了筆者對內部使用的 Fetch 封裝庫的設計重構與實現過程。javascript
源代碼地址:這裏css
在初版本的 Fluent Fetcher 中,筆者但願將全部的功能包含在單一的 FluentFetcher 類內,結果發現整個文件冗長而醜陋;在團隊內部嘗試推廣時也無人願用,包括本身過了一段時間再拾起這個庫也以爲很棘手。在編寫 declarative-crawler 的時候,筆者又用到了 fluent-fetcher,看着如亂麻般的代碼,我不禁沉思,爲何當時會去封裝這個庫?爲何不直接使用 fetch,而是自找麻煩多造一層輪子。就如筆者在 2016-個人前端之路:工具化與工程化一文中介紹的,框架自己是對於複用代碼的提取或者功能的擴展,其會具備必定的內建複雜度。若是內建複雜度超過了業務應用自己的複雜度,那麼引入框架就難免畫蛇添足了。而網絡請求則是絕大部分客戶端應用不可或缺的一部分,縱觀多個項目,咱們也能夠提煉出不少的公共代碼;譬如公共的域名、請求頭、認證等配置代碼,有時候須要添加擴展功能:譬如重試、超時返回、緩存、Mock 等等。筆者構建 Fluent Fetcher 的初衷便是但願可以簡化網絡請求的步驟,將原生 fetch 中偏聲明式的構造流程以流式方法調用的方式提供出來,而且爲原有的執行函數添加部分功能擴展。前端
那麼以前框架的問題在於:java
模糊的文檔,不少參數的含義、用法包括可用的接口類型都未講清楚;node
接口的不一致與不直觀,默認參數,是使用對象解構(opt = {})仍是函數的默認參數(arg1, arg2 = 2);react
過多的潛在抽象漏洞,將 Error 對象封裝了起來,致使使用者很難直觀地發現錯誤,而且也不便於使用者進行個性化定製;git
模塊獨立性的缺少,不少的項目都但願能提供儘量多的功能,可是這自己也會帶來必定的風險,同時會致使最終打包生成的包體大小的增加。github
好的代碼,好的 API 設計確實應該如白居易的詩,淺顯易懂而又韻味悠長,沒有人有義務透過你邋遢的外表去發現你美麗的心靈。開源項目自己也意味着一種責任,若是是單純地爲了炫技而提高了代碼的複雜度倒是得不償失。筆者認爲最理想的狀況是使用任何第三方框架以前都能對其源代碼有所瞭解,像 React、Spring Boot、TensorFlow 這樣比較複雜的庫,咱們能夠慢慢地撥開它的面紗。而對於一些相對小巧的工具庫,出於對本身負責、對團隊負責的態度,在引入以前仍是要了解下它們的源碼組成,瞭解有哪些文檔中沒有說起的功能或者潛在風險。筆者在編寫 Fluent Fetcher 的過程當中也參考了 OkHttp、super-agent、request 等流行的網絡請求庫。ajax
V2 版本中的 Fluent Fetcher 中,最核心的設計變化在於將請求構建與請求執行剝離了開來。RequestBuilder 提供了構造器模式的接口,使用者首先經過 RequestBuilder 構建請求地址與配置,該配置也就是 fetch 支持的標準配置項;使用者也能夠複用 RequestBuilder 中定義的非請求體相關的公共配置信息。而 execute 函數則負責執行請求,而且返回通過擴展的 Promise 對象。直接使用 npm / yarn 安裝便可:npm
npm install fluent-fetcher or yarn add fluent-fetcher
基礎的 GET 請求構造方式以下:
import { RequestBuilder } from "../src/index.js"; test("構建完整跨域緩存請求", () => { let { url, option }: RequestType = new RequestBuilder({ scheme: "https", host: "api.com", encoding: "utf-8" }) .get("/user") .cors() .cookie("*") .cache("no-cache") .build({ queryParam: 1, b: "c" }); chaiExpect(url).to.equal("https://api.com/user?queryParam=1&b=c"); expect(option).toHaveProperty("cache", "no-cache"); expect(option).toHaveProperty("credentials", "include"); });
RequestBuilder 的構造函數支持傳入三個參數:
* @param scheme http 或者 https * @param host 請求的域名 * @param encoding 編碼方式,經常使用的爲 utf8 或者 gbk
而後咱們可使用 header 函數設置請求頭,使用 get / post / put / delete / del 等方法進行不一樣的請求方式與請求體設置;對於請求體的設置是放置在請求方法函數的第二與第三個參數中:
// 第二個參數傳入請求體 // 第三個參數傳入編碼方式,默認爲 raw json post("/user", { a: 1 }, "x-www-form-urlencoded")
最後咱們調用 build
函數進行請求構建,build
函數會返回請求地址與請求配置;此外 build
函數還會重置內部的請求路徑與請求體。鑑於 Fluent Fetch 底層使用了 node-fetch,所以 build
返回的 option
對象在 Node 環境下僅支持如下屬性與擴展屬性:
{ // Fetch 標準定義的支持屬性 method: 'GET', headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below) body: null, // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect // node-fetch 擴展支持屬性 follow: 20, // maximum redirect count. 0 to not follow redirect timeout: 0, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) compress: true, // support gzip/deflate content encoding. false to disable size: 0, // maximum response body size in bytes. 0 to disable agent: null // http(s).Agent instance, allows custom proxy, certificate etc. }
此外,node-fetch 默認請求頭設置:
Header | Value |
---|---|
Accept-Encoding |
gzip,deflate (when options.compress === true) |
Accept |
*/* |
Connection |
close (when no options.agent is present) |
Content-Length |
(automatically calculated, if possible) |
User-Agent |
node-fetch/1.0 (+https://github.com/bitinn/node-fetch) |
execute
函數的說明爲:
/** * Description 根據傳入的請求配置發起請求並進行預處理 * @param url * @param option * @param {*} acceptType json | text | blob * @param strategy */ export default function execute( url: string, option: any = {}, acceptType: "json" | "text" | "blob" = "json", strategy: strategyType = {} ): Promise<any>{} type strategyType = { // 是否須要添加進度監聽回調,經常使用於下載 onProgress: (progress: number) => {}, // 用於 await 狀況下的 timeout 參數 timeout: number };
默認的瀏覽器與 Node 環境下咱們直接從項目的根入口引入文件便可:
import {execute, RequestBuilder} from "../../src/index.js";
默認狀況下,其會執行 require("isomorphic-fetch");
,而在 React Native 狀況下,鑑於其自有 fetch 對象,所以就不須要動態注入。譬如筆者在CoderReader 中 獲取 HackerNews 數據時,就須要引入對應的入口文件
import { RequestBuilder, execute } from "fluent-fetcher/dist/index.rn";
而在部分狀況下咱們須要以 Jsonp 方式發起請求(僅支持 GET 請求),就須要引入對應的請求體:
import { RequestBuilder, execute } from "fluent-fetcher/dist/index.jsonp";
引入以後咱們便可以正常發起請求,對於不一樣的請求類型與請求體,請求執行的方式是一致的:
test("測試基本 GET 請求", async () => { const { url: getUrl, option: getOption } = requestBuilder .get("/posts") .build(); let posts = await execute(getUrl, getOption); expectChai(posts).to.have.length(100); });
須要注意的是,部分狀況下在 Node 中進行 HTTPS 請求時會報以下異常:
(node:33875) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): FetchError: request to https://test.api.truelore.cn/users?token=144d3e0a-7abb-4b21-9dcb-57d477a710bd failed, reason: unable to verify the first certificate (node:33875) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
咱們須要動態設置以下的環境變量:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
有時候咱們須要自動地獲取到腳本而後插入到界面中,此時就可使用 executeAndInject
函數,其每每用於異步加載腳本或者樣式類的狀況:
import { executeAndInject } from "../../src/index"; let texts = await executeAndInject([ "https://cdn.jsdelivr.net/fontawesome/4.7.0/css/font-awesome.min.css" ]);
筆者在 create-react-boilerplate 項目提供的性能優化模式中也應用了該函數,在 React 組件中咱們能夠在 componentDidMount
回調中使用該函數來動態加載外部腳本:
// @flow import React, { Component } from "react"; import { message, Spin } from "antd"; import { executeAndInject } from "fluent-fetcher"; /** * @function 執行外部腳本加載工做 */ export default class ExternalDependedComponent extends Component { state = { loaded: false }; async componentDidMount() { await executeAndInject([ "https://cdnjs.cloudflare.com/ajax/libs/Swiper/3.3.1/css/swiper.min.css", "https://cdnjs.cloudflare.com/ajax/libs/Swiper/3.3.1/js/swiper.min.js" ]); message.success("異步 Swiper 腳本加載完畢!"); this.setState({ loaded: true }); } render() { return ( <section className="ExternalDependedComponent__container"> {this.state.loaded ? <div style={{ color: "white" }}> <h1 style={{ position: "absolute" }}>Swiper</h1> <p style={{ position: "absolute", top: "50px" }}> Swiper 加載完畢,如今你能夠在全局對象中使用 Swiper! </p> <img height="504px" width="320px" src="http://img5.cache.netease.com/photo/0031/2014-09-20/A6K9J0G94UUJ0031.jpg" alt="" /> </div> : <div> <Spin size="large" /> </div>} </section> ); } }
有時候咱們須要動態設置以代理方式執行請求,這裏即動態地爲 RequestBuilder 生成的請求配置添加 agent
屬性便可:
const HttpsProxyAgent = require("https-proxy-agent"); const requestBuilder = new RequestBuilder({ scheme: "http", host: "jsonplaceholder.typicode.com" }); const { url: getUrl, option: getOption } = requestBuilder .get("/posts") .pathSegment("1") .build(); getOption.agent = new HttpsProxyAgent("http://114.232.81.95:35293"); let post = await execute(getUrl, getOption,"text");
execute
函數在執行基礎的請求以外還回爲 fetch 返回的 Promise 添加中斷與超時地功能,須要注意的是若是以 Async/Await 方式編寫異步代碼則須要將 timeout 超時參數以函數參數方式傳入;不然能夠以屬性方式設置:
describe("策略測試", () => { test("測試中斷", done => { let fnResolve = jest.fn(); let fnReject = jest.fn(); let promise = execute("https://jsonplaceholder.typicode.com"); promise.then(fnResolve, fnReject); // 撤銷該請求 promise.abort(); // 異步驗證 setTimeout(() => { // fn 不該該被調用 expect(fnResolve).not.toHaveBeenCalled(); expect(fnReject).toHaveBeenCalled(); done(); }, 500); }); test("測試超時", done => { let fnResolve = jest.fn(); let fnReject = jest.fn(); let promise = execute("https://jsonplaceholder.typicode.com"); promise.then(fnResolve, fnReject); // 設置超時 promise.timeout = 10; // 異步驗證 setTimeout(() => { // fn 不該該被調用 expect(fnResolve).not.toHaveBeenCalled(); expect(fnReject).toHaveBeenCalled(); done(); }, 500); }); test("使用 await 下測試超時", async done => { try { await execute("https://jsonplaceholder.typicode.com", {}, "json", { timeout: 10 }); } catch (e) { expectChai(e.message).to.equal("Abort or Timeout"); } finally { done(); } }); });
function consume(reader) { let total = 0; return new Promise((resolve, reject) => { function pump() { reader.read().then(({done, value}) => { if (done) { resolve(); return } total += value.byteLength; log(`received ${value.byteLength} bytes (${total} bytes in total)`); pump() }).catch(reject) } pump() }) } // 執行數據抓取操做 fetch("/music/pk/altes-kamuffel.flac") .then(res => consume(res.body.getReader())) .then(() => log("consumed the entire body without keeping the whole thing in memory!")) .catch(e => log("something went wrong: " + e))
execute
還支持動態地將抓取到的數據傳入到其餘處理管道中,譬如在 Node.js 中完成圖片抓取以後能夠將其保存到文件系統中;若是是瀏覽器環境下則須要動態傳入某個 img 標籤的 ID,execute
會在圖片抓取完畢後動態地設置圖片內容:
describe("Pipe 測試", () => { test("測試圖片下載", async () => { let promise = execute( "https://assets-cdn.github.com/images/modules/logos_page/Octocat.png", {}, "blob" ).pipe("/tmp/Octocat.png", require("fs")); }); });
若是咱們須要進行本地調試,則能夠在當前模塊目錄下使用 npm link
來建立本地連接:
$ cd package-name $ npm link
而後在使用該模塊的目錄下一樣使用 npm link
來關聯目標項目:
$ cd project $ npm link package-name