從koa/redux看如何設(shè)計中間件
導語 本文學習優(yōu)秀庫koa/redux如何設(shè)計中間件
大廠技術(shù)??高級前端??Node進階
點擊上方?程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
或許你在學習koa/redux時,經(jīng)常會聽到中間件這個詞,大概也知道它們通過這種設(shè)計模式,使得自定義的中間件(函數(shù))能正確在插入到上下文環(huán)境中執(zhí)行。那它們究竟是怎么實現(xiàn)的呢?本文僅探討koa/redux是如何設(shè)計中間件。中間件是一種實現(xiàn)「關(guān)注點分離」的設(shè)計模式,該模式有兩個特點:
中間件middle是一個函數(shù)
middle有個next參數(shù),也是函數(shù),代表下個要執(zhí)行的中間件。
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執(zhí)行,打印結(jié)果為 m1->m2->m3->v2->v1。這種模式有個形象的名字,洋蔥模型。但現(xiàn)在我們暫時忘記這些名字,就想想如何實現(xiàn)中間件(函數(shù))的聯(lián)動吧。有兩種思路,第一是遞歸;第二是鏈式調(diào)用。
遞歸
設(shè)置一個數(shù)組按順序存儲函數(shù),根據(jù) index 值,按順序一個個執(zhí)行,如下:
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); // 遞歸執(zhí)行數(shù)組中下一個函數(shù)return route(next);}dispath(0);}compose(middles); // 打印m1 -> m2 -> m3 -> v2 -> v1
鏈式調(diào)用
將函數(shù)當作成參數(shù)傳給上一個中間件,這樣前一個中間件執(zhí)行完就可以執(zhí)行下一個中間件。
1、直接調(diào)用:
m1(() => m2(() => m3())); // 打印m1 -> m2 -> m3 -> v2 -> v1// m2的參數(shù)next是 () => m3(),// m1的參數(shù)next是 () => m2(() => m3())
此種方法雖然可行,但是 m1,m2,m3 都是寫死的,不是公共方法。
2、構(gòu)建next的函數(shù)createFn:
我們觀察到在傳遞參數(shù)時,m3 和 m2 都變成函數(shù)再傳入,那這個變成函數(shù)的過程是否能提取:如下,參數(shù) middle 是中間件,參數(shù) next 是接下來要執(zhí)行的函數(shù)。轉(zhuǎn)換后 next 變成 middle 的參數(shù)。
function createFn(middle, next) {return function() {middle(next);};}// 需要先將后面的中間件變成我們需要的 next 函數(shù):const fn3 = createFn(m3, null);const fn2 = createFn(m2, fn3);const fn1 = createFn(m1, fn2);fn1(); // 打印m1 -> m2 -> m3 -> v2 -> v1
這里 fn3/fn2/fn1 也是固定的,但我們看出這些中間狀態(tài)變量,可以隱藏掉,統(tǒng)一用 next 代替:
let next = () => {};next = createFn(m3, null);next = createFn(m2, next);next = createFn(m1, next);next(); // 打印m1 -> m2 -> m3 -> v2 -> v1
優(yōu)化如下:
let next = () => {};// 倒序for (let i = middles.length; i >= 0; i--) {next = createFn(middles[i], next);}next(); // 打印m1 -> m2 -> m3 -> v2 -> v1
3、redux 的 reduceRight
仔細觀察上面這種倒序,且每次拿上次的值進行計算的方法,是不是很像 reduceRight。(好吧,或許我們看不出來,但是早期 redux 就是這么實現(xiàn)的,我們直接拿過來研究):
const middles = [m1, m2, m3];function compose(arr) {return arr.reduceRight((a, b) => {// b是middle,a是next,return () => b(a); // 每次返回的是一個函數(shù),執(zhí)行這個函數(shù)為middle(next),即b(a)},() => {} // 初始化的a值,空函數(shù));}const mid = compose(middles);mid(); // 打印m1 -> m2 -> m3 -> v2 -> v1
4、redux 的 reduce
const middles = [m1, m2, m3];function compose(arr) {return arr.reduce((a, b) => {return (...arg) => a(() => b(...arg)); // a 是 next函數(shù),b是middle函數(shù)});}const mid = compose(middles);mid(); // 打印m1 -> m2 -> m3 -> v2 -> v1
改成這種正序的方式,反而不好理解。嘗試解釋一下:a 是 next 函數(shù),b 是 middle 函數(shù)。(...arg) => a(() => b(...arg)) 這簡直就是我們最初這種寫法 m1(() => m2(() => m3())) 的直接映射。摘抄一下這篇參考一作者的解釋,感興趣的同學可自行推導一下:
// 第 1 次 reduce 的返回值,下一次將作為 aarg => fn1(() => fn2(arg));// 第 2 次 reduce 的返回值,下一次將作為 aarg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));// 等價于...arg => fn1(() => fn2(() => fn3(arg)));// 執(zhí)行最后返回的函數(shù)連接中間件,返回值等價于...fn1(() => fn2(() => fn3(() => {})));
明白reduceRight到reduce轉(zhuǎn)換不是最關(guān)鍵的,關(guān)鍵的是明白上面幾種寫法讓我們能鏈式調(diào)用函數(shù)。
傳遞參數(shù)
設(shè)計一個中間件模式,怎么能少得了參數(shù)的傳遞。我們先想想如何組織我們中間件:很明顯,我們通過 next 執(zhí)行下個中間件,那么傳值給下個中間件就是給 next 添加參數(shù):
function m1(next) {console.log("m1");next("v2"); // 將'v2'傳給下個中間件m2}
那么 m2 該怎么獲取這個值呢?因為 next 代表 m2 執(zhí)行后的值,next 傳遞參數(shù)就是說 m2 需要返回函數(shù),該函數(shù)的參數(shù)就是傳遞的值,如下:
function m2(next) {return function(action) {// 這個action就是上一個函數(shù)傳來的'v2'next(action);};}
這種寫法等價于:
const m2 = next => action => {next(action);};
所以按照上面這種方式組織我們的中間件,我們就既能鏈式執(zhí)行又能傳遞參數(shù)。如下:
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);};
那我們?nèi)绾螌崿F(xiàn)呢?
1、直接調(diào)用:
m1(arg => m2(() => m3()(arg))(arg))("666");// 打印:m1,m2,m3都打印666
2、創(chuàng)建createFn函數(shù):
createFn返回的函數(shù)添加了參數(shù)action,代表了中間件之間的參數(shù)。
// 我們給返回的函數(shù)加上參數(shù)action并執(zhí)行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
3、 redux 的 reduceRight 與 reduce:
返回的結(jié)果直接執(zhí)行,因為我們加了一層返回函數(shù)
const middles = [m1, m2, m3];function compose(arr) {return arr.reduceRight((a, b) => b(a), // 注意這里,上個版本返回的是函數(shù)() => b(a);這個版本變成b(a),直接執(zhí)行了,原因是我們中間件返回函數(shù),所以這里需要將其執(zhí)行() => {});}const mid = compose(middles);mid("666"); // 打印:m1,m2,m3都打印666
共同的屬性
現(xiàn)在我們完成了中間件的鏈式調(diào)用和參數(shù)傳遞,已完成一個簡單的中間件。但是如果我們這里不是普通的中間價,而是 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);};
我們還是按照上面幾個步驟來實現(xiàn)一下,最后講講為什么能這么設(shè)計:
1、 直接調(diào)用
const store = { name: "redux" };// 基本寫法,我們將參數(shù)傳給每個中間件m1(arg => m2(() => m3()(arg))(arg))(store);
2. 中間件先執(zhí)行一遍將 store 傳入進去
const store = { name: "redux" };const middles = [m1, m2, m3];const middlesWithStore = middles.map(middle => middle(store)); // 這里執(zhí)行了第一遍,將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' }
3、 reduceRight 和 reduce :
const store = { name: "redux" };const middles = [m1, m2, m3];const middlesWithStore = middles.map(middle => middle(store)); // 這里執(zhí)行了第一遍,將store傳進來function compose(arr) {return arr.reduce((a, b) => (...args) => a(b(...args)));}const mid = compose(middlesWithStore)();mid(store); // 打印:store1,store2,store3 { name: 'redux' }
這里看起來簡單,就是先執(zhí)行一遍中間件,但為什么可以先執(zhí)行一次函數(shù)將數(shù)據(jù)(store)傳進去?而且這個數(shù)據(jù)在后來的調(diào)用中能被訪問到?這背后涉及到的基礎(chǔ)知識是函數(shù)柯里化和閉包:
柯里化與閉包
1、柯里化
柯里化是使用匿名單參數(shù)函數(shù)來實現(xiàn)多參數(shù)函數(shù)的方法。
const m1 = store => next => action => {console.log("store1", store);next(action);};
上面這種寫法,我們說是將中間件 m1 柯里化了,它的特點是每次只傳一個參數(shù),返回的是新的函數(shù)。返回新函數(shù)這個特點很重要,因為函數(shù)可以在其他地方再調(diào)用,所以本來一個連續(xù)的動作被打斷了,變成了可以延遲執(zhí)行,也可以稱為參數(shù)前置。當我們執(zhí)行:
const middlesWithStore = middles.map(middle => middle(store));相當于給每個中間件都添加了 store 屬性,而且返回的是函數(shù),可以等到你需要用它的時候再去使用。這就是柯里化的好處。
2、閉包
閉包:函數(shù)與其自由變量組成的環(huán)境,自由變量指不存在函數(shù)內(nèi)部的變量。當函數(shù)按照值傳遞的方式在其他地方被調(diào)用時,產(chǎn)生了閉包。
上面的 m1 可以寫成下面這種格式,可以知道柯里化中間函數(shù)處于同一閉包,所以盡管我們是在其他地方調(diào)用了 next(action),但還是保存了最開始初始化的作用域,實現(xiàn)了真正的函數(shù)分開執(zhí)行。
return function(next) {return function(action) {console.log("store1", store);next(action);};};}
總結(jié)
可以說我們整個中間件的設(shè)計就是建構(gòu)在返回函數(shù)形成閉包這種柯里化特性上。它讓我們緩存參數(shù),分開執(zhí)行,鏈式傳遞參數(shù)調(diào)用。所以 redux 中能提前注入 store,能有效傳遞 action。可以說koa/redux的中間件機制是閉包/柯里化的經(jīng)典的實例。
參考資料
https://juejin.im/post/5bbdcf05e51d450e6c750693
https://zhuanlan.zhihu.com/p/35040744
https://zhuanlan.zhihu.com/p/20597452
https://github.com/brickspert/blog/issues/22
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復(fù)「Node」即可。
???“分享、點贊、在看” 支持一波??
