Fluent Fetcher: 重構(gòu)基于 Fetch 的 JavaScript 網(wǎng)絡(luò)請求庫
源代碼地址:這里
在第一版本的 Fluent Fetcher 中,筆者希望將所有的功能包含在單一的 FluentFetcher 類內(nèi),結(jié)果發(fā)現(xiàn)整個文件冗長而丑陋;在團(tuán)隊(duì)內(nèi)部嘗試推廣時也無人愿用,包括自己過了一段時間再拾起這個庫也覺得很棘手。在編寫 declarative-crawler 的時候,筆者又用到了 fluent-fetcher,看著如亂麻般的代碼,我不由沉思,為什么當(dāng)時會去封裝這個庫?為什么不直接使用 fetch,而是自找麻煩多造一層輪子??蚣鼙旧硎菍τ趶?fù)用代碼的提取或者功能的擴(kuò)展,其會具有一定的內(nèi)建復(fù)雜度。如果內(nèi)建復(fù)雜度超過了業(yè)務(wù)應(yīng)用本身的復(fù)雜度,那么引入框架就不免多此一舉了。而網(wǎng)絡(luò)請求則是絕大部分客戶端應(yīng)用不可或缺的一部分,縱觀多個項(xiàng)目,我們也可以提煉出很多的公共代碼;譬如公共的域名、請求頭、認(rèn)證等配置代碼,有時候需要添加擴(kuò)展功能:譬如重試、超時返回、緩存、Mock 等等。筆者構(gòu)建 Fluent Fetcher 的初衷即是希望能夠簡化網(wǎng)絡(luò)請求的步驟,將原生 fetch 中偏聲明式的構(gòu)造流程以流式方法調(diào)用的方式提供出來,并且為原有的執(zhí)行函數(shù)添加部分功能擴(kuò)展。
那么之前框架的問題在于:
- 模糊的文檔,很多參數(shù)的含義、用法包括可用的接口類型都未講清楚;
- 接口的不一致與不直觀,默認(rèn)參數(shù),是使用對象解構(gòu)(opt = {})還是函數(shù)的默認(rèn)參數(shù)(arg1, arg2 = 2);
- 過多的潛在抽象漏洞,將 Error 對象封裝了起來,導(dǎo)致使用者很難直觀地發(fā)現(xiàn)錯誤,并且也不便于使用者進(jìn)行個性化定制;
- 模塊獨(dú)立性的缺乏,很多的項(xiàng)目都希望能提供盡可能多的功能,但是這本身也會帶來一定的風(fēng)險(xiǎn),同時會導(dǎo)致最終打包生成的包體大小的增長。
好的代碼,好的 API 設(shè)計(jì)確實(shí)應(yīng)該如白居易的詩,淺顯易懂而又韻味悠長,沒有人有義務(wù)透過你邋遢的外表去發(fā)現(xiàn)你美麗的心靈。開源項(xiàng)目本身也意味著一種責(zé)任,如果是單純地為了炫技而提升了代碼的復(fù)雜度卻是得不償失。筆者認(rèn)為最理想的情況是使用任何第三方框架之前都能對其源代碼有所了解,像 React、Spring Boot、TensorFlow 這樣比較復(fù)雜的庫,我們可以慢慢地?fù)荛_它的面紗。而對于一些相對小巧的工具庫,出于對自己負(fù)責(zé)、對團(tuán)隊(duì)負(fù)責(zé)的態(tài)度,在引入之前還是要了解下它們的源碼組成,了解有哪些文檔中沒有提及的功能或者潛在風(fēng)險(xiǎn)。筆者在編寫 Fluent Fetcher 的過程中也參考了 OkHttp、super-agent、request 等流行的網(wǎng)絡(luò)請求庫。
基本使用
V2 版本中的 Fluent Fetcher 中,最核心的設(shè)計(jì)變化在于將請求構(gòu)建與請求執(zhí)行剝離了開來。RequestBuilder 提供了構(gòu)造器模式的接口,使用者首先通過 RequestBuilder 構(gòu)建請求地址與配置,該配置也就是 fetch 支持的標(biāo)準(zhǔn)配置項(xiàng);使用者也可以復(fù)用 RequestBuilder 中定義的非請求體相關(guān)的公共配置信息。而 execute 函數(shù)則負(fù)責(zé)執(zhí)行請求,并且返回經(jīng)過擴(kuò)展的 Promise 對象。直接使用 npm / yarn 安裝即可:
- npm install fluent-fetcher
- or
- yarn add fluent-fetcher
創(chuàng)建請求
基礎(chǔ)的 GET 請求構(gòu)造方式如下:
- import { RequestBuilder } from "../src/index.js";
- test("構(gòu)建完整跨域緩存請求", () => {
- 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 的構(gòu)造函數(shù)支持傳入三個參數(shù):
- * @param scheme http 或者 https
- * @param host 請求的域名
- * @param encoding 編碼方式,常用的為 utf8 或者 gbk
然后我們可以使用 header 函數(shù)設(shè)置請求頭,使用 get / post / put / delete / del 等方法進(jìn)行不同的請求方式與請求體設(shè)置;對于請求體的設(shè)置是放置在請求方法函數(shù)的第二與第三個參數(shù)中:
- // 第二個參數(shù)傳入請求體
- // 第三個參數(shù)傳入編碼方式,默認(rèn)為 raw json
- post("/user", { a: 1 }, "x-www-form-urlencoded")
最后我們調(diào)用 build 函數(shù)進(jìn)行請求構(gòu)建,build 函數(shù)會返回請求地址與請求配置;此外 build 函數(shù)還會重置內(nèi)部的請求路徑與請求體。鑒于 Fluent Fetch 底層使用了 node-fetch,因此 build 返回的 option 對象在 Node 環(huán)境下僅支持以下屬性與擴(kuò)展屬性:
- {
- // Fetch 標(biāo)準(zhǔn)定義的支持屬性
- 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 擴(kuò)展支持屬性
- 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 默認(rèn)請求頭設(shè)置:
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)
請求執(zhí)行
execute 函數(shù)的說明為:
- /**
- * Description 根據(jù)傳入的請求配置發(fā)起請求并進(jìn)行預(yù)處理
- * @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 = {
- // 是否需要添加進(jìn)度監(jiān)聽回調(diào),常用于下載
- onProgress: (progress: number) => {},
- // 用于 await 情況下的 timeout 參數(shù)
- timeout: number
- };
引入合適的請求體
默認(rèn)的瀏覽器與 Node 環(huán)境下我們直接從項(xiàng)目的根入口引入文件即可:
- import {execute, RequestBuilder} from "../../src/index.js";
默認(rèn)情況下,其會執(zhí)行 require("isomorphic-fetch"); ,而在 React Native 情況下,鑒于其自有 fetch 對象,因此就不需要動態(tài)注入。譬如筆者在CoderReader 中 獲取 HackerNews 數(shù)據(jù)時,就需要引入對應(yīng)的入口文件
- import { RequestBuilder, execute } from "fluent-fetcher/dist/index.rn";
而在部分情況下我們需要以 Jsonp 方式發(fā)起請求(僅支持 GET 請求),就需要引入對應(yīng)的請求體:
- import { RequestBuilder, execute } from "fluent-fetcher/dist/index.jsonp";
引入之后我們即可以正常發(fā)起請求,對于不同的請求類型與請求體,請求執(zhí)行的方式是一致的:
- 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 中進(jìn)行 HTTPS 請求時會報(bào)如下異常:
- (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.
我們需要動態(tài)設(shè)置如下的環(huán)境變量:
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
自動腳本插入
有時候我們需要自動地獲取到腳本然后插入到界面中,此時就可以使用 executeAndInject 函數(shù),其往往用于異步加載腳本或者樣式類的情況:
- 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 項(xiàng)目提供的性能優(yōu)化模式中也應(yīng)用了該函數(shù),在 React 組件中我們可以在 componentDidMount 回調(diào)中使用該函數(shù)來動態(tài)加載外部腳本:
- // @flow
- import React, { Component } from "react";
- import { message, Spin } from "antd";
- import { executeAndInject } from "fluent-fetcher";
- /**
- * @function 執(zhí)行外部腳本加載工作
- */
- 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 加載完畢,現(xiàn)在你可以在全局對象中使用 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>
- );
- }
- }
代理
有時候我們需要動態(tài)設(shè)置以代理方式執(zhí)行請求,這里即動態(tài)地為 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");
擴(kuò)展策略
中斷與超時
execute 函數(shù)在執(zhí)行基礎(chǔ)的請求之外還回為 fetch 返回的 Promise 添加中斷與超時地功能,需要注意的是如果以 Async/Await 方式編寫異步代碼則需要將 timeout 超時參數(shù)以函數(shù)參數(shù)方式傳入;否則可以以屬性方式設(shè)置:
- describe("策略測試", () => {
- test("測試中斷", done => {
- let fnResolve = jest.fn();
- let fnReject = jest.fn();
- let promise = execute("https://jsonplaceholder.typicode.com");
- promise.then(fnResolve, fnReject);
- // 撤銷該請求
- promise.abort();
- // 異步驗(yàn)證
- setTimeout(() => {
- // fn 不應(yīng)該被調(diào)用
- 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);
- // 設(shè)置超時
- promise.timeout = 10;
- // 異步驗(yàn)證
- setTimeout(() => {
- // fn 不應(yīng)該被調(diào)用
- 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();
- }
- });
- });
進(jìn)度反饋
- 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()
- })
- }
- // 執(zhí)行數(shù)據(jù)抓取操作
- 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 還支持動態(tài)地將抓取到的數(shù)據(jù)傳入到其他處理管道中,譬如在 Node.js 中完成圖片抓取之后可以將其保存到文件系統(tǒng)中;如果是瀏覽器環(huán)境下則需要動態(tài)傳入某個 img 標(biāo)簽的 ID,execute 會在圖片抓取完畢后動態(tài)地設(shè)置圖片內(nèi)容:
- 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專欄作者“張梓雄 ”的原創(chuàng)文章,如需轉(zhuǎn)載請通過51CTO與作者聯(lián)系】