Koa的洋蔥中間件,Redux的中間件,Axios的攔截器,一個精簡版的就徹底搞懂了。

  • 2020 年 4 月 11 日
  • 筆記

前言

前端中的庫很多,開發這些庫的作者會儘可能的覆蓋到大家在業務中千奇百怪的需求,但是總有無法預料到的,所以優秀的庫就需要提供一種機制,讓開發者可以干預插件中間的一些環節,從而完成自己的一些需求。

本文將從koaaxiosvuexredux的實現來教你怎麼編寫屬於自己的插件機制。

  • 對於新手來說: 本文能讓你搞明白神秘的插件和攔截器到底是什麼東西。
  • 對於老手來說: 在你寫的開源框架中也加入攔截器或者插件機制,讓它變得更加強大吧!

axios

首先我們模擬一個簡單的axios,這裡不涉及請求的邏輯,只是簡單的返回一個Promise,可以通過config中的error參數控制Promise的狀態。

axios的攔截器機制用流程圖來表示其實就是這樣的:

const axios = config => {    if (config.error) {      return Promise.reject({        error: 'error in axios',      });    } else {      return Promise.resolve({        ...config,        result: config.result,      });    }  };  複製代碼

如果傳入的config中有error參數,就返回一個rejected的promise,反之則返回resolved的promise。

先簡單看一下axios官方提供的攔截器示例:

axios.interceptors.request.use(function (config) {      // 在發送請求之前做些什麼      return config;    }, function (error) {      // 對請求錯誤做些什麼      return Promise.reject(error);    });    // 添加響應攔截器  axios.interceptors.response.use(function (response) {      // 對響應數據做點什麼      return response;    }, function (error) {      // 對響應錯誤做點什麼      return Promise.reject(error);    });  複製代碼

可以看出,不管是request還是response的攔截求,都會接受兩個函數作為參數,一個是用來處理正常流程,一個是處理失敗流程,這讓人想到了什麼?

沒錯,promise.then接受的同樣也是這兩個參數。

axios內部正是利用了promise的這個機制,把use傳入的兩個函數作為一個intercetpor,每一個intercetpor都有resolvedrejected兩個方法。

// 把  axios.interceptors.response.use(func1, func2)    // 在內部存儲為  {      resolved: func1,      rejected: func2  }  複製代碼

接下來簡單實現一下,這裡我們簡化一下,把axios.interceptor.request.use轉為axios.useRequestInterceptor來簡單實現:

// 先構造一個對象 存放攔截器  axios.interceptors = {    request: [],    response: [],  };    // 註冊請求攔截器  axios.useRequestInterceptor = (resolved, rejected) => {    axios.interceptors.request.push({ resolved, rejected });  };    // 註冊響應攔截器  axios.useResponseInterceptor = (resolved, rejected) => {    axios.interceptors.response.push({ resolved, rejected });  };    // 運行攔截器  axios.run = config => {    const chain = [      {        resolved: axios,        rejected: undefined,      },    ];      // 把請求攔截器往數組頭部推    axios.interceptors.request.forEach(interceptor => {      chain.unshift(interceptor);    });      // 把響應攔截器往數組尾部推    axios.interceptors.response.forEach(interceptor => {      chain.push(interceptor);    });      // 把config也包裝成一個promise    let promise = Promise.resolve(config);      // 暴力while循環解憂愁    // 利用promise.then的能力遞歸執行所有的攔截器    while (chain.length) {      const { resolved, rejected } = chain.shift();      promise = promise.then(resolved, rejected);    }      // 最後暴露給用戶的就是響應攔截器處理過後的promise    return promise;  };  複製代碼

axios.run這個函數看運行時的機制,首先構造一個chain作為promise鏈,並且把正常的請求也就是我們的請求參數axios也構造為一個攔截器的結構,接下來

  • 把request的interceptor給unshift到chain頂部
  • 把response的interceptor給push到chain尾部

以這樣一段調用代碼為例:

// 請求攔截器1  axios.useRequestInterceptor(resolved1, rejected1);  // 請求攔截器2  axios.useRequestInterceptor(resolved2, rejected2);  // 響應攔截器1  axios.useResponseInterceptor(resolved1, rejected1);  // 響應攔截器  axios.useResponseInterceptor(resolved2, rejected2);  複製代碼

這樣子構造出來的promise鏈就是這樣的chain結構:

[      請求攔截器2,// ↓config      請求攔截器1,// ↓config      axios請求核心方法, // ↓response      響應攔截器1, // ↓response      響應攔截器// ↓response  ]  複製代碼

至於為什麼requestInterceptor的順序是反過來的,仔細看看代碼就知道 XD。

有了這個chain之後,只需要一句簡短的代碼:

 let promise = Promise.resolve(config);      while (chain.length) {      const { resolved, rejected } = chain.shift();      promise = promise.then(resolved, rejected);    }      return promise;  複製代碼

promise就會把這個鏈從上而下的執行了。

以這樣的一段測試代碼為例:

axios.useRequestInterceptor(config => {    return {      ...config,      extraParams1: 'extraParams1',    };  });    axios.useRequestInterceptor(config => {    return {      ...config,      extraParams2: 'extraParams2',    };  });    axios.useResponseInterceptor(    resp => {      const {        extraParams1,        extraParams2,        result: { code, message },      } = resp;      return `${extraParams1} ${extraParams2} ${message}`;    },    error => {      console.log('error', error)    },  );  複製代碼
  1. 成功的調用

在成功的調用下輸出 result1: extraParams1 extraParams2 message1

(async function() {    const result = await axios.run({      message: 'message1',    });    console.log('result1: ', result);  })();  複製代碼
  1. 失敗的調用
(async function() {    const result = await axios.run({      error: true,    });    console.log('result3: ', result);  })();  複製代碼

在失敗的調用下,則進入響應攔截器的rejected分支:

首先打印出攔截器定義的錯誤日誌: error { error: 'error in axios' }

然後由於失敗的攔截器

error => {    console.log('error', error)  },  複製代碼

沒有返回任何東西,打印出result3: undefined

可以看出,axios的攔截器是非常靈活的,可以在請求階段任意的修改config,也可以在響應階段對response做各種處理,這也是因為用戶對於請求數據的需求就是非常靈活的,沒有必要干涉用戶的自由度。

vuex

vuex提供了一個api用來在action被調用前後插入一些邏輯:

vuex.vuejs.org/zh/api/#sub…

store.subscribeAction({    before: (action, state) => {      console.log(`before action ${action.type}`)    },    after: (action, state) => {      console.log(`after action ${action.type}`)    }  })  複製代碼

其實這有點像AOP(面向切面編程)的編程思想。

在調用store.dispatch({ type: 'add' })的時候,會在執行前後打印出日誌

before action add  add  after action add  複製代碼

來簡單實現一下:

import { Actions, ActionSubscribers, ActionSubscriber, ActionArguments } from './vuex.type';    class Vuex {    state = {};      action = {};      _actionSubscribers = [];      constructor({ state, action }) {      this.state = state;      this.action = action;      this._actionSubscribers = [];    }      dispatch(action) {      // action前置監聽器      this._actionSubscribers        .forEach(sub => sub.before(action, this.state));        const { type, payload } = action;        // 執行action      this.action[type](this.state, payload).then(() => {         // action後置監聽器        this._actionSubscribers          .forEach(sub => sub.after(action, this.state));      });    }      subscribeAction(subscriber) {      // 把監聽者推進數組      this._actionSubscribers.push(subscriber);    }  }    const store = new Vuex({    state: {      count: 0,    },    action: {      async add(state, payload) {        state.count += payload;      },    },  });    store.subscribeAction({    before: (action, state) => {      console.log(`before action ${action.type}, before count is ${state.count}`);    },    after: (action, state) => {      console.log(`after action ${action.type},  after count is ${state.count}`);    },  });    store.dispatch({    type: 'add',    payload: 2,  });  複製代碼

此時控制台會打印如下內容:

before action add, before count is 0  after action add, after count is 2  複製代碼

輕鬆實現了日誌功能。

當然Vuex在實現插件功能的時候,選擇性的將 type payload 和 state暴露給外部,而不再提供進一步的修改能力,這也是框架內部的一種權衡,當然我們可以對state進行直接修改,但是不可避免的會得到Vuex內部的警告,因為在Vuex中,所有state的修改都應該通過mutations來進行,但是Vuex沒有選擇把commit也暴露出來,這也約束了插件的能力。

redux

想要理解redux中的中間件機制,需要先理解一個方法:compose

function compose(...funcs: Function[]) {    return funcs.reduce((a, b) => (...args: any) => a(b(...args)))  }  複製代碼

簡單理解的話,就是compose(fn1, fn2, fn3) (...args) = > fn1(fn2(fn3(...args))) 它是一種高階聚合函數,相當於把fn3先執行,然後把結果傳給fn2再執行,再把結果交給fn1去執行。

有了這個前置知識,就可以很輕易的實現redux的中間件機制了。

雖然redux源碼里寫的很少,各種高階函數各種柯里化,但是抽絲剝繭以後,redux中間件的機制可以用一句話來解釋:

把dispatch這個方法不斷用高階函數包裝,最後返回一個強化過後的dispatch

以logMiddleware為例,這個middleware接受原始的redux dispatch,返回的是

const typeLogMiddleware = (dispatch) => {      // 返回的其實還是一個結構相同的dispatch,接受的參數也相同      // 只是把原始的dispatch包在裏面了而已。      return ({type, ...args}) => {          console.log(`type is ${type}`)          return dispatch({type, ...args})      }  }  複製代碼

有了這個思路,就來實現這個mini-redux吧:

function compose(...funcs) {      return funcs.reduce((a, b) => (...args) => a(b(...args)));  }    function createStore(reducer, middlewares) {      let currentState;        function dispatch(action) {          currentState = reducer(currentState, action);      }        function getState() {          return currentState;      }      // 初始化一個隨意的dispatch,要求外部在type匹配不到的時候返回初始狀態      // 在這個dispatch後 currentState就有值了。      dispatch({ type: 'INIT' });        let enhancedDispatch = dispatch;      // 如果第二個參數傳入了middlewares      if (middlewares) {          // 用compose把middlewares包裝成一個函數          // 讓dis          enhancedDispatch = compose(...middlewares)(dispatch);      }        return {          dispatch: enhancedDispatch,          getState,      };  }    複製代碼

接着寫兩個中間件

// 使用    const otherDummyMiddleware = (dispatch) => {      // 返回一個新的dispatch      return (action) => {          console.log(`type in dummy is ${type}`)          return dispatch(action)      }  }    // 這個dispatch其實是otherDummyMiddleware執行後返回otherDummyDispatch  const typeLogMiddleware = (dispatch) => {      // 返回一個新的dispatch      return ({type, ...args}) => {          console.log(`type is ${type}`)          return dispatch({type, ...args})      }  }    // 中間件從右往左執行。  const counterStore = createStore(counterReducer, [typeLogMiddleware, otherDummyMiddleware])    console.log(counterStore.getState().count)  counterStore.dispatch({type: 'add', payload: 2})  console.log(counterStore.getState().count)    // 輸出:  // 0  // type is add  // type in dummy is add  // 2  複製代碼

koa

koa的洋蔥模型想必各位都聽說過,這種靈活的中間件機制也讓koa變得非常強大,本文也會實現一個簡單的洋蔥中間件機制。參考(umi-request的中間件機制

對應這張圖來看,洋蔥的每一個圈就是一個中間件,它即可以掌管請求進入,也可以掌管響應返回。

它和redux的中間件機制有點類似,本質上都是高階函數的嵌套,外層的中間件嵌套着內層的中間件,這種機制的好處是可以自己控制中間件的能力(外層的中間件可以影響內層的請求和響應階段,內層的中間件只能影響外層的響應階段)

首先我們寫出Koa這個類

class Koa {      constructor() {          this.middlewares = [];      }      use(middleware) {          this.middlewares.push(middleware);      }      start({ req }) {          const composed = composeMiddlewares(this.middlewares);          const ctx = { req, res: undefined };          return composed(ctx);      }  }  複製代碼

這裡的use就是簡單的把中間件推入中間件隊列中,那核心就是怎樣去把這些中間件組合起來了,下面看composeMiddlewares方法:

function composeMiddlewares(middlewares) {      return function wrapMiddlewares(ctx) {          // 記錄當前運行的middleware的下標          let index = -1;          function dispatch(i) {              // index向後移動              index = i;                // 找出數組中存放的相應的中間件              const fn = middlewares[i];                // 最後一個中間件調用next 也不會報錯              if (!fn) {                  return Promise.resolve();              }                return Promise.resolve(                  fn(                      // 繼續傳遞ctx                      ctx,                      // next方法,允許進入下一個中間件。                      () => dispatch(i + 1)                  )              );          }          // 開始運行第一個中間件          return dispatch(0);      };  }  複製代碼

簡單來說 dispatch(n)對應着第n個中間件的執行,而dispatch(n)又擁有執行dispatch(n + 1)的權力,

所以在真正運行的時候,中間件並不是在平級的運行,而是嵌套的高階函數:

dispatch(0)包含着dispatch(1),而dispatch(1)又包含着dispatch(2) 在這個模式下,我們很容易聯想到try catch的機制,它可以catch住函數以及函數內部繼續調用的函數的所有error

那麼我們的第一個中間件就可以做一個錯誤處理中間件:

// 最外層 管控全局錯誤  app.use(async (ctx, next) => {      try {          // 這裡的next包含了第二層以及第三層的運行          await next();      }      catch (error) {          console.log(`[koa error]: ${error.message}`);      }  });  複製代碼

在這個錯誤處理中間件中,我們把next包裹在try catch中運行,調用了next後會進入第二層的中間件:

// 第二層 日誌中間件  app.use(async (ctx, next) => {      const { req } = ctx;      console.log(`req is ${JSON.stringify(req)}`);      await next();      // next過後已經能拿到第三層寫進ctx的數據了      console.log(`res is ${JSON.stringify(ctx.res)}`);  });  複製代碼

在第二層中間件的next調用後,進入第三層,業務邏輯處理中間件

// 第三層 核心服務中間件  // 在真實場景中 這一層一般用來構造真正需要返回的數據 寫入ctx中  app.use(async (ctx, next) => {      const { req } = ctx;      console.log(`calculating the res of ${req}...`);      const res = {          code: 200,          result: `req ${req} success`,      };      // 寫入ctx      ctx.res = res;      await next();  });  複製代碼

在這一層把res寫入ctx後,函數出棧,又會回到第二層中間件的await next()後面

 console.log(`req is ${JSON.stringify(req)}`);   await next();   // <- 回到這裡   console.log(`res is ${JSON.stringify(ctx.res)}`);  複製代碼

這時候日誌中間件就可以拿到ctx.res的值了。

想要測試錯誤處理中間件 就在最後加入這個中間件

// 用來測試全局錯誤中間件  // 注釋掉這一個中間件 服務才能正常響應  app.use(async (ctx, next) => {      throw new Error('oops! error!');  });  複製代碼

最後要調用啟動函數:

app.start({ req: 'ssh' });  複製代碼

控制台打印出結果:

req is "ssh"  calculating the res of ssh...  res is {"code":200,"result":"req ssh success"}  複製代碼

總結

  1. axios 把用戶註冊的每個攔截器構造成一個promise.then所接受的參數,在運行時把所有的攔截器按照一個promise鏈的形式以此執行。
  • 在發送到服務端之前,config已經是請求攔截器處理過後的結果
  • 服務器響應結果後,response會經過響應攔截器,最後用戶拿到的就是處理過後的結果了。
  1. vuex的實現最為簡單,就是提供了兩個回調函數,vuex內部在合適的時機去調用(我個人感覺大部分的庫提供這樣的機制也足夠了)。
  2. redux的源碼里寫的最複雜最繞,它的中間件機制本質上就是用高階函數不斷的把dispatch包裝再包裝,形成套娃。本文實現的已經是精簡了n倍以後的結果了,不過複雜的實現也是為了很多權衡和考量,Dan對於閉包和高階函數的運用已經爐火純青了,只是外人去看源碼有點頭禿…
  3. koa的洋蔥模型實現的很精妙,和redux有相似之處,但是在源碼理解和使用上個人感覺更優於redux的中間件。

中間件機制其實是非框架強相關的,請求庫一樣可以加入koa的洋蔥中間件機制(如umi-request),不同的框架可能適合不同的中間件機制,這還是取決於你編寫的框架想要解決什麼問題,想給用戶什麼樣的自由度。

希望看了這篇文章的你,能對於前端庫中的中間件機制有進一步的了解,進而為你自己的前端庫加入合適的中間件能力。

本文所寫的代碼都整理在這個倉庫里了: github.com/sl1673495/t…

代碼是使用ts編寫的,js版本的代碼在js文件夾內,各位可以按自己的需求來看。