Fluent Fetcher: 重構基于 Fetch 的 JavaScript 網絡請求庫
源代碼地址:這里
在第一版本的 Fluent Fetcher 中,筆者希望將所有的功能包含在單一的 FluentFetcher 類內,結果發現整個文件冗長而丑陋;在團隊內部嘗試推廣時也無人愿用,包括自己過了一段時間再拾起這個庫也覺得很棘手。在編寫 declarative-crawler 的時候,筆者又用到了 fluent-fetcher,看著如亂麻般的代碼,我不由沉思,為什么當時會去封裝這個庫?為什么不直接使用 fetch,而是自找麻煩多造一層輪子。框架本身是對于復用代碼的提取或者功能的擴展,其會具有一定的內建復雜度。如果內建復雜度超過了業務應用本身的復雜度,那么引入框架就不免多此一舉了。而網絡請求則是絕大部分客戶端應用不可或缺的一部分,縱觀多個項目,我們也可以提煉出很多的公共代碼;譬如公共的域名、請求頭、認證等配置代碼,有時候需要添加擴展功能:譬如重試、超時返回、緩存、Mock 等等。筆者構建 Fluent Fetcher 的初衷即是希望能夠簡化網絡請求的步驟,將原生 fetch 中偏聲明式的構造流程以流式方法調用的方式提供出來,并且為原有的執行函數添加部分功能擴展。
那么之前框架的問題在于:
- 模糊的文檔,很多參數的含義、用法包括可用的接口類型都未講清楚;
- 接口的不一致與不直觀,默認參數,是使用對象解構(opt = {})還是函數的默認參數(arg1, arg2 = 2);
- 過多的潛在抽象漏洞,將 Error 對象封裝了起來,導致使用者很難直觀地發現錯誤,并且也不便于使用者進行個性化定制;
- 模塊獨立性的缺乏,很多的項目都希望能提供盡可能多的功能,但是這本身也會帶來一定的風險,同時會導致最終打包生成的包體大小的增長。
好的代碼,好的 API 設計確實應該如白居易的詩,淺顯易懂而又韻味悠長,沒有人有義務透過你邋遢的外表去發現你美麗的心靈。開源項目本身也意味著一種責任,如果是單純地為了炫技而提升了代碼的復雜度卻是得不償失。筆者認為最理想的情況是使用任何第三方框架之前都能對其源代碼有所了解,像 React、Spring Boot、TensorFlow 這樣比較復雜的庫,我們可以慢慢地撥開它的面紗。而對于一些相對小巧的工具庫,出于對自己負責、對團隊負責的態度,在引入之前還是要了解下它們的源碼組成,了解有哪些文檔中沒有提及的功能或者潛在風險。筆者在編寫 Fluent Fetcher 的過程中也參考了 OkHttp、super-agent、request 等流行的網絡請求庫。
基本使用
V2 版本中的 Fluent Fetcher 中,最核心的設計變化在于將請求構建與請求執行剝離了開來。RequestBuilder 提供了構造器模式的接口,使用者首先通過 RequestBuilder 構建請求地址與配置,該配置也就是 fetch 支持的標準配置項;使用者也可以復用 RequestBuilder 中定義的非請求體相關的公共配置信息。而 execute 函數則負責執行請求,并且返回經過擴展的 Promise 對象。直接使用 npm / yarn 安裝即可:
- 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 默認請求頭設置:
HeaderValueAccept-Encodinggzip,deflate (when options.compress === true)Accept*/*Connectionclose (when no options.agent is present)Content-Length(automatically calculated, if possible)User-Agentnode-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))
Pipe
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"));
- });
- });
【本文是51CTO專欄作者“張梓雄 ”的原創文章,如需轉載請通過51CTO與作者聯系】