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