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