從koa/redux看如何設計中間件

中間件是一種實現「關注點分離」的設計模式,有多種實現方式,本文僅探討koa/redux是如何設計中間件。它們的模式有兩個特色,javascript

  • 中間件middle是一個函數
  • middle有個next參數,也是函數,表明下個要執行的中間件。
function m1(next) {
  console.log("m1");
  next();
  console.log("v1");
}

function m2(next) {
  console.log("m2");
  next();
  console.log("v2");
}

function m3() {
  console.log("m3");
}
複製代碼

如上所示:中間件 m1->m2->m3執行,打印結果爲 m1->m2->m3->v2->v1。這種模式有個形象的名字,洋蔥模型。但如今咱們暫時忘記這些名字,就想一想如何實現中間件(函數)的聯動吧。有兩種思路,第一是遞歸;第二是鏈式調用。java

遞歸

設置一個數組按順序存儲函數,根據 index 值,按順序一個個執行,以下:git

const middles = [m1, m2, m3];

function compose(arr) {
  function dispath(index) {
    if (index === arr.length) return;

    const route = arr[index];
    const next = () => dispath(index + 1); // 遞歸執行數組中下一個函數
    return route(next);
  }

  dispath(0);
}

compose(middles); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

鏈式調用

將函數看成成參數傳給上一個中間件,這樣前一箇中間件執行完就能夠執行下一個中間件。github

一、直接調用:

m1(() => m2(() => m3())); // 打印m1 -> m2 -> m3 -> v2 -> v1
// m2的參數next是 () => m3(),
// m1的參數next是 () => m2(() => m3())
複製代碼

此種方法雖然可行,可是 m1,m2,m3 都是寫死的,不是公共方法。redux

二、構建next的函數createFn:**

咱們觀察到在傳遞參數時,m3 和 m2 都變成函數再傳入,那這個變成函數的過程是否能提取:以下,參數 middle 是中間件,參數 next 是接下來要執行的函數。轉換後 next 變成 middle 的參數。設計模式

function createFn(middle, next) {
  return function() {
    middle(next);
  };
}

// 須要先將後面的中間件變成咱們須要的 next 函數:
const fn3 = createFn(m3, null);
const fn2 = createFn(m2, fn3);
const fn1 = createFn(m1, fn2);

fn1(); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

這裏 fn3/fn2/fn1 也是固定的,但咱們看出這些中間狀態變量,能夠隱藏掉,統一用 next 代替:數組

let next = () => {};

next = createFn(m3, null);
next = createFn(m2, next);
next = createFn(m1, next);

next(); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

優化以下:緩存

let next = () => {};
// 倒序
for (let i = middles.length; i >= 0; i--) {
  next = createFn(middles[i], next);
}

next(); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

三、redux 的 reduceRight

仔細觀察上面這種倒序,且每次拿上次的值進行計算的方法,是否是很像 reduceRight。(好吧,或許咱們看不出來,可是早期 redux 就是這麼實現的,咱們直接拿過來研究):閉包

const middles = [m1, m2, m3];

function compose(arr) {
  return arr.reduceRight(
    (a, b) => {
      // b是middle,a是next,
      return () => b(a); // 每次返回的是一個函數,執行這個函數爲middle(next),即b(a)
    },
    () => {} // 初始化的a值,空函數
  );
}

const mid = compose(middles);
mid(); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

四、redux 的 reduce

const middles = [m1, m2, m3];

function compose(arr) {
  return arr.reduce((a, b) => {
    return (...arg) => a(() => b(...arg)); // a 是 next函數,b是middle函數
  });
}

const mid = compose(middles);
mid(); // 打印m1 -> m2 -> m3 -> v2 -> v1
複製代碼

改爲這種正序的方式,反而很差理解。嘗試解釋一下:a 是 next 函數,b 是 middle 函數。(...arg) => a(() => b(...arg)) 這簡直就是咱們最初這種寫法 m1(() => m2(() => m3())) 的直接映射。摘抄一下這篇文章做者的解釋,感興趣的同窗可自行推導一下:koa

// 第 1 次 reduce 的返回值,下一次將做爲 a
arg => fn1(() => fn2(arg));

// 第 2 次 reduce 的返回值,下一次將做爲 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));

// 等價於...
arg => fn1(() => fn2(() => fn3(arg)));

// 執行最後返回的函數鏈接中間件,返回值等價於...
fn1(() => fn2(() => fn3(() => {})));
複製代碼

明白reduceRight到reduce轉換不是最關鍵的,關鍵的是明白上面幾種寫法讓咱們能鏈式調用函數。

傳遞參數

設計一箇中間件模式,怎麼能少得了參數的傳遞。咱們先想一想如何組織咱們中間件:很明顯,咱們經過 next 執行下箇中間件,那麼傳值給下箇中間件就是給 next 添加參數:

function m1(next) {
  console.log("m1");
  next("v2"); // 將'v2'傳給下箇中間件m2
}
複製代碼

那麼 m2 該怎麼獲取這個值呢?由於 next 表明 m2 執行後的值,next 傳遞參數就是說 m2 須要返回函數,該函數的參數就是傳遞的值,以下:

function m2(next) {
  return function(action) {
    // 這個action就是上一個函數傳來的'v2'
    next(action);
  };
}
複製代碼

這種寫法等價於:

const m2 = next => action => {
  next(action);
};
複製代碼

因此按照上面這種方式組織咱們的中間件,咱們就既能鏈式執行又能傳遞參數。以下:

const m1 = next => action => {
  console.log("m1", action);
  next(action);
};

const m2 = next => action => {
  console.log("m2", action);
  next(action);
};

const m3 = next => action => {
  console.log("m3", action);
};
複製代碼

那咱們如何實現呢?

一、直接調用:

m1(arg => m2(() => m3()(arg))(arg))("666");
// 打印:m1,m2,m3都打印666
複製代碼

二、建立createFn函數:

createFn返回的函數添加了參數action,表明了中間件之間的參數。

// 咱們給返回的函數加上參數action並執行
function createFn(middle, next) {
  return function (action) {
    middle(next)(action);
  }
}

const middles = [m1, m2, m3];

let next = () => {};
for (let i = middles.length - 1; i >= 0; i--) {
  next = createFn(middles[i], next);
}

next("666"); // 打印:m1,m2,m3都打印666
複製代碼

三、 redux 的 reduceRight 與 reduce:

返回的結果直接執行,由於咱們加了一層返回函數

const middles = [m1, m2, m3];

function compose(arr) {
  return arr.reduceRight(
    (a, b) => b(a), // 注意這裏,上個版本返回的是函數() => b(a);這個版本變成b(a),直接執行了,緣由是咱們中間件返回函數,因此這裏須要將其執行
    () => {}
  );
}

const mid = compose(middles);
mid("666"); // 打印:m1,m2,m3都打印666
複製代碼
const middles = [m1, m2, m3];

function compose(arr) {
  return arr.reduce((a, b) => (...arg) => a(b(...arg))); // 這邊也是,從() => b(..arg)變成b(..arg)
}

const mid = compose(middles);
mid("666"); // 打印:m1,m2,m3都打印666
複製代碼

共同的屬性

如今咱們完成了中間件的鏈式調用和參數傳遞,已完成一個簡單的中間件。可是若是咱們這裏不是普通的中間價,而是 redux 的中間件。咱們想要這些中間件都擁有一個初始化的 store,該如何處理呢?熟悉 redux 的朋友確定知道中間件最後寫成這樣:

const m1 = store => next => action => {
  console.log("store1", store);
  next(action);
};

const m2 = store => next => action => {
  console.log("store2", store);
  next(action);
};

const m3 = store => next => action => {
  console.log("store3", store);
};
複製代碼

咱們仍是按照上面幾個步驟來實現一下,最後講講爲何能這麼設計:

一、 直接調用

const store = { name: "redux" };

// 基本寫法,咱們將參數傳給每一箇中間件
m1(arg => m2(() => m3()(arg))(arg))(store);
複製代碼

2. 中間件先執行一遍將 store 傳入進去

const store = { name: "redux" };

const middles = [m1, m2, m3];

const middlesWithStore = middles.map(middle => middle(store)); // 這裏執行了第一遍,將store傳進來

function createFn(middle, next) {
  return action => middle(next)(action);
}

let next = () => () => {};
for (let i = middlesWithStore.length - 1; i >= 0; i--) {
  next = createFn(middlesWithStore[i], next);
}

next(store); // 打印:store1,store2,store3 { name: 'redux' }
複製代碼

三、 reduceRight 和 reduce :

const store = { name: "redux" };

const middles = [m1, m2, m3];

const middlesWithStore = middles.map(middle => middle(store)); // 這裏執行了第一遍,將store傳進來

function compose(arr) {
  return arr.reduce((a, b) => (...args) => a(b(...args)));
}

const mid = compose(middlesWithStore)();
mid(store); // 打印:store1,store2,store3 { name: 'redux' }
複製代碼

這裏看起來簡單,就是先執行一遍中間件,**但爲何能夠先執行一次函數將數據(store)傳進去?並且這個數據在後來的調用中能被訪問到?**這背後涉及到的基礎知識是函數柯里化和閉包:

柯里化與閉包

一、柯里化

柯里化是使用匿名單參數函數來實現多參數函數的方法。

const m1 = store => next => action => {
  console.log("store1", store);
  next(action);
};
複製代碼

上面這種寫法,咱們說是將中間件 m1 柯里化了,它的特色是每次只傳一個參數,返回的是新的函數。返回新函數這個特色很重要,由於函數能夠在其餘地方再調用,因此原本一個連續的動做被打斷了,變成了能夠延遲執行,也能夠稱爲參數前置。當咱們執行:

const middlesWithStore = middles.map(middle => middle(store));
複製代碼

至關於給每一箇中間件都添加了 store 屬性,並且返回的是函數,能夠等到你須要用它的時候再去使用。這就是柯里化的好處。

二、閉包

閉包:函數與其自由變量組成的環境,自由變量指不存在函數內部的變量。當函數按照值傳遞的方式在其餘地方被調用時,產生了閉包。

上面的 m1 能夠寫成下面這種格式,能夠知道柯里化中間函數處於同一閉包,因此儘管咱們是在其餘地方調用了 next(action),但仍是保存了最開始初始化的做用域,實現了真正的函數分開執行。

function m1(store) {
  return function(next) {
    return function(action) {
      console.log("store1", store);
      next(action);
    };
  };
}
複製代碼

總結

能夠說咱們整個中間件的設計就是建構在返回函數造成閉包這種柯里化特性上。它讓咱們緩存參數,分開執行,鏈式傳遞參數調用。因此 redux 中能提早注入 store,能有效傳遞 action。能夠說koa/redux的中間件機制是閉包/柯里化的經典的實例。寒風捲起,落葉抱冬日,感謝閱讀。

參考資料

juejin.im/post/5bbdcf… zhuanlan.zhihu.com/p/35040744 zhuanlan.zhihu.com/p/20597452 github.com/brickspert/…

相關文章
相關標籤/搜索