邊看邊寫:基於Fetch仿洋蔥模型寫一個Http構造類

首發於:我的博客:吃飯不洗碗javascript

洋蔥模型

學過或瞭解過 Node 服務框架 Koa 的,都或許聽過洋蔥模型和中間件。恩,就是吃的那個洋蔥,見下圖:
image
Koa 是經過洋蔥模型實現對 http 封裝,中間件就是一層一層的洋蔥,這裏推薦兩個 Koa 源碼解讀的文章,固然其源碼自己也很簡單,可讀性很是高。java

我這裏不過多講關於 Koa 的設計模式與源碼,理解 Koa 的中間件引擎源碼就好了。寫這篇文章的目的,是整理出我參照 Koa 設計一個 Http 構造類的思路,此構造類用於簡化及規範平常瀏覽器端請求的書寫:git

// Koa中間件引擎源碼
function compose(middlewares = []) {
  if (!Array.isArray(middlewares))
    throw new TypeError('Middleware stack must be an array!');

  for (const fn of middlewares) {
    if (typeof fn !== 'function')
      throw new TypeError('Middleware must be composed of functions!');
  }

  const { length } = middlewares;
  return function callback(ctx, next) {
    let index = -1;
    function dispatch(i) {
      let fn = middlewares[i];
      if (i <= index)
        return Promise.reject(new Error('next() called multiple times'));
      index = i;
      if (i === length) {
        fn = next;
      }
      if (!fn) {
        return Promise.resolve();
      }
      try {
        return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
      } catch (error) {
        return Promise.reject(error);
      }
    }
    return dispatch(0);
  };
}

Fetch

語法: Promise<Response> fetch(input[, init]);
  ** 如下代碼展現都是以input字段爲請求url的方式展現
  // get 請求
  fetch('http://server.closertb.site/client/api/user/getList?pn=1&ps=10')
   .then(response => {
     if(reponse.ok) {
       return data.json();
      } else {
       throw Error('服務器繁忙,請稍後再試;\r\nCode:' + response.status)
     }
  })
   .then((data) => { console.log(data); });

  // post 請求
  fetch('http://server.closertb.site/client/api/user/getList',
    {
      method: 'POST',
      body: 'pn=1&ps=10',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    }
  ).then(response => {
     if(reponse.ok) {
       return data.json();
      } else {
       throw Error('服務器繁忙,請稍後再試;\r\nCode:' + response.status)
     }
  })
   .then((data) => { console.log(data); })

從上面的示例,咱們能夠感受到,每個請求發起,都須要用完整的 url,遇到 post 請求,設置 Request Header 是一個比較大的工做,接收響應都須要判斷 respones.ok 是否爲 true(若是不清楚,請參見 mdn 連接),而後 response.json()獲得返回值,有可能返回值中還包含了 status 與 message,因此要拿到最終的內容,咱們還得多碼兩行代碼。若是某一天,咱們須要爲每一個請求加上憑證或版本號,那代碼更改量將直接 Double, 因此但願設計一個基於 fetch 封裝的,支持中間件的 Http 構造類來簡化規範平常先後端的交互,好比像下面這樣:github

// 在一個config.js 配置全站Http共有信息, eg:
  import Http from '@doddle/http';

  const servers = {
    admin: 'server.closertb.site/client',
    permission: 'auth.closertb.site',
  }
  export default Http.create({
    servers,
    contentKey: 'content',
    query() {
      const token = cookie.get('token');
      return token ? { token: `token:${token}` } : {};
    },
    ...
  });

  // 在services.js中這樣使用
  import http from '../configs.js';

  const { get, post } = http.create('admin');
  const params = { pn: 1, ps: 10 };

  get('/api/user/getList', params)
    .then((data) => { console.log(data); });


  post('/api/user/getList', params, { contentType: 'form' })
    .then((data) => { console.log(data); });

上面的代碼,看起來是否是更直觀,明瞭。shell

設計分析

從上面的分析,這個 Http 構造類須要包含如下特色:npm

  • 服務 Url 地址的拼接,支持多個後端服務
  • 請求地址帶憑證或其餘統一標識
  • 請求狀態判斷
  • 請求目標內容獲取
  • 錯誤處理
  • 請求語義化,即 get, post, put 這種直接標識請求類型
  • 請求參數格式統一化

Talk is Cheap

Http類

參照上面的理想化示例,首先嚐試去實現 Http.create:json

export default class Http {
  constructor(options) {
    const { query, servers = {}, contentKey = '', beforeRequest = [], beforeResponse = [],
      errorHandle } = options;
    this.servers = servers;
    this.key = contentKey;
    this.before = beforeRequest;
    this.after = beforeResponse;
    this.query = query;
    this.errorHandle = errorHandle;
    this.create = this.create.bind(this);
    this._middlewareInit();
  }
  // 靜態方法, 語義化實例構造
  static create(options) {
    return new Http(options);
  }
  // 中間件初始化方法,內部調用
  _middlewareInit() {
    const defaultBeforeMidd = [addRequestDomain, addRequestQuery];
    const defaultAfterMidd = [responseStatusHandle, responseContentHandle];

    this._middleWares = this._middleWares || defaultBeforeMidd
      .concat(this.before)
      .concat(fetchRequest)
      .concat(defaultAfterMidd)
      .concat(this.after);
      this._handlers = compose(this._middleWares); // compose即爲開頭提到的koa核心代碼
    }
  }
  // 中間件擴展, like Koa
  use() {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    let _order = order || 0;
    // 插入位置不對,自動糾正
    if (typeof _order !== 'number' || _order > this._middleWares.length) {
      _order = this._middleWares.length;
    }
    this._middleware.spicle(order || this._middleWares.length, 0, fn);
    this._middlewareInit();
  }
  // 請求實例構造方法
  create(service) {
    this._instance = new Instance({
      domain: this.servers[service], // 服務地址
      key: this.key,
      query: this.query,
      errorHandle: this.errorHandle,
      handlers: this._handlers,
    });
    return requestMethods(this._instance.fetch);  // requestMethods = { get, post, put };
  }
}

直接貼代碼,也是一種無賴之舉。每一個方法功能都很是簡單,但從use和_middlewareInit方法, 能夠看出和koa的中間件有所區別,這裏採用的中間件是一種尾觸發方式(中間件按事先排好的順序調用),在後面會進一步體現。後端

requestMethods

關於requestMethods,其相似於一種策略模式,這裏將每一種請示類型,抽象成一個具體的策略,在實例化某個服務的請求時,將獲得一系列策略,將resetful語義函數化:設計模式

// 關於genHeader函數,請查看源碼,這裏的fetch是中間件包裝後的;
export const requestMethods = fetch => ({
  get(url, params, options = {}) {
    return fetch(`${url}?${qs.stringify(params)}`, params, options);
  },
  post(url, params, options = {}) {
    const { type } = options;
    return fetch(`${url}`, genHeader(type, params), options);
  },
});

Instance類

關於Instance, 每一個實例的服務域名是一致的,因此其做用更可能是每一個服務建立一個執行上下文,用於存儲request, response, 並作錯誤處理, 實現也很是簡單:api

export default class Instance {
  // configs 包括domain, key, query
  constructor({ handlers, errorHandle, ...configs }) {
    this.configs = configs;
    this.errorHandle = errorHandle;
    this.handlers = handlers;
    this.fetch = this.fetch.bind(this);
    this.onError = this.onError.bind(this);
  }

  fetch(url, params, options) {
    const configs = this.configs;
    const ctx = Object.assign({}, configs, { url, options, params });
    return this.handlers(ctx)
      .then(() => ctx.data)
      .catch(this._onError);
  }

  _onError(error) {
    if (this.errorHandle) {
      this.errorHandle(error);
    } else {
      defaultErrorHandler(error);
    }
    return Promise.reject({});
  }
}

關於Object.assign建立ctx, 是爲了同一個服務多個請求發起時,上下文不相互影響。

默認中間件實現

正如設計分析時提到的,默認中間件包含了請求地址服務域名拼接,憑證攜帶,狀態判斷,內容提取,中間件可採用async/await,也可用常規函數,見示例代碼:

export function addRequestDomain(ctx, next) {
  const { domain } = ctx;
  ctx.url = `${domain}${ctx.url}`;
  return next();
}

export function addRequestQuery(ctx, next) {
  const {
    query,
    options: { ignoreQuery = false },
  } = ctx;
  const queryParams = query && query();
  // ignoreQuery 確認忽略,或者queryParams爲空或壓根不存在;
  ctx.url =
    ignoreQuery || !queryParams
      ? ctx.url
      : `${ctx.url}?${qs.stringify(queryParams)}`;
  return next();
}

export async function fetchRequest(ctx, next) {
  const { url, params } = ctx;
  try {
    ctx.response = await fetch(url, params);
    return next();
  } catch (error) {
    return Promise.reject(error);
  }
}

export async function responseStatusHandle(ctx, next) {
  const { response = {} } = ctx;
  if (response.ok) {
    ctx.data = await response.json();
    ctx._response = ctx.data;
    return next();
  } else {
    return Promise.reject(response);
  }
}

export function responseContentHandle(ctx, next) {
  const { key, _response } = ctx;
  ctx.data = key ? _response[key] : _response;
  return next();
}

每一箇中間件代碼都很是簡單易懂,這也是爲何要採用中間件的設計模型,由於將功能解耦,易於擴展。同時也能看到,next做爲每一箇中間件的最後執行步驟,這種模式就是傳說中的中間件尾調用模式。

寫在最後

感謝你讀到了這裏,開始想寫的很是多,但高考語文89分,不是偶然出現的。在實現一個用於平常生產的Http構造類,過程並不像這裏寫出來的這麼簡單,須要考慮和權衡的東西很是多,錯誤處理是關鍵。這裏留了本身踩過的兩個坑(更可能是由於本身菜),這裏沒展開來說,思考:

  • 爲何每一箇中間件最後要return next();
  • query爲何是在中間件中執行,而不是在fetch前執行,而後傳參過來;

本文的源碼可在此github地址下載,分支是http;
執行用例可在此github地址下載,分支是dva,或執行腳手架命令:

npx create-doddle dva projectname

若是你有興趣在你的項目嘗試,可查閱npm使用指南

npm i @doddle/dva --save
相關文章
相關標籤/搜索