介紹
Koa 是一個新的 web 框架,由 Express 幕后的原班人馬打造, 致力於成為 web 應用和 API 開發領域中的一個更小、更富有表現力、更健壯的基石。 通過利用 async 函數,Koa 幫你丟棄回調函數,並有力地增強錯誤處理。 Koa 並沒有捆綁任何中間件, 而是提供了一套優雅的方法,幫助您快速而愉快地編寫服務端應用程序。
學習koa-compose之前,先看一下這兩張圖


基本使用
const Koa = require('../../lib/application');
// const Koa = require('koa');
const app = new Koa();
// x-response-time
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
// logger
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});
// response
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);

- 創建一個跟蹤響應時間的日期
- 等待下一個中間件的控制
- 創建另一個日期跟蹤持續時間
- 等待下一個中間件的控制
- 將響應主體設置為“Hello World”
- 計算持續時間
- 輸出日志行
- 計算響應時間
- 設置 X-Response-Time 頭字段
- 交給 Koa 處理響應
看完這個gif圖,也可以思考下如何實現的。根據表現,可以猜測是next是一個函數,而且返回的可能是一個promise,被await調用。
閱讀koa-compose源碼
function compose(middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!');
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!');
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function(context, next) {
// last called middleware #
let index = -1;
// 取出第一個中間件函數執行
return dispatch(0);
// 遞歸函數
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
let fn = middleware[i];
// next的值為undefined,當沒有中間件的時候直接結束
// 其實這里可以去掉next參數,直接在下面fn = void 0,和之前的代碼效果一樣
// if (i === middleware.length) fn = void 0;
if (i === middleware.length) fn = next;
if (!fn) return Promise.resolve();
try {
// fn就是中間件函數, dipatch(i)調用的就是第i個中間件函數
// eg : app.use((ctx,next) => { next()})
// 第 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(() => {})));
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
上面的代碼等價於
// 這樣就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};
fnMiddleware(ctx).then(handleResponse).catch(onerror);
也就是說koa-compose返回的是一個Promise,Promise中取出第一個函數(app.use添加的中間件),傳入context和第一個next函數來執行。
第一個next函數里也是返回的是一個Promise,Promise中取出第二個函數(app.use添加的中間件),傳入context和第二個next函數來執行。
第二個next函數里也是返回的是一個Promise,Promise中取出第三個函數(app.use添加的中間件),傳入context和第三個next函數來執行。
第三個...
以此類推。最后一個中間件中有調用next函數,則返回Promise.resolve。如果沒有,則不執行next函數。
這樣就把所有中間件串聯起來了。這也就是我們常說的洋蔥模型。
模擬實現
同步實現
文件app.js
// 模擬 Koa 創建的實例
class app {
constructor(){
this.middlewares = []
}
use(fn){
this.middlewares.push(fn)
}
compose() {
// 遞歸函數
let self = this;
function dispatch(index) {
// 如果所有中間件都執行完跳出
if (index === self.middlewares.length) return;
// 取出第 index 個中間件並執行
const midFn = self.middlewares[index];
return midFn(() => dispatch(index + 1));
}
取出第一個中間件函數執行
dispatch(0);
}
};
module.exports = new app();
上面是同步的實現,通過遞歸函數 dispatch 的執行取出了數組中的第一個中間件函數並執行,在執行時傳入了一個函數,並遞歸執行了 dispatch,傳入的參數 +1,這樣就執行了下一個中間件函數,依次類推,直到所有中間件都執行完畢,不滿足中間件執行條件時,會跳出,這樣就按照上面案例中 1 3 5 6 4 2 的情況執行,測試例子如下(同步上、異步下)。
文件sync-test.js
const app = require("./app");
app.use(next => {
console.log(1);
next();
console.log(2);
});
app.use(next => {
console.log(3);
next();
console.log(4);
});
app.use(next => {
console.log(5);
next();
console.log(6);
});
app.compose();
// 1
// 3
// 5
// 6
// 4
// 2
文件async-test.js
const app = require("./app");
// 異步函數
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log("hello");
}, 3000);
});
}
app.use(async next => {
console.log(1);
await next();
console.log(2);
});
app.use(async next => {
console.log(3);
await fn(); // 調用異步函數
await next();
console.log(4);
});
app.use(async next => {
console.log(5);
await next();
console.log(6);
});
app.compose();
// 1
// 3
// hello
// 5
// 6
// 4
// 2
我們發現如果案例中按照 Koa 的推薦寫法,即使用 async 函數,都會通過,但是在給 use 傳參時可能會傳入普通函數或 async 函數,我們要將所有中間件的返回值都包裝成 Promise 來兼容兩種情況,其實在 Koa 中 compose 最后返回的也是 Promise,是為了后續的邏輯的編寫,但是現在並不支持,下面來解決這兩個問題。
注意:后面 compose 的其他實現方式中,都是使用 sync-test.js 和 async-test.js 驗證,所以后面就不再重復了。
升級為異步,其實就是koa-compose的實現(簡化版)
compose() {
// 遞歸函數
let self = this;
function dispatch(index) {
// 異步實現
// 如果所有中間件都執行完跳出,並返回一個 Promise
if (index === self.middlewares.length) return Promise.resolve();
// 取出第 index 個中間件並執行
const route = self.middlewares[index];
// 執行后返回成功態的 Promise
return Promise.resolve(route(() => dispatch(index + 1)));
}
// 取出第一個中間件函數執行
dispatch(0);
}
我們知道 async 函數中 await 后面執行的異步代碼要實現等待,待異步執行后繼續向下執行,需要等待 Promise,所以我們將每一個中間件函數在調用時最后都返回了一個成功態的 Promise,使用 async-test.js進行測試,發現結果為 1 3 hello(3s后) 5 6 4 2。
reduceRight實現(Redux舊版使用逆序歸並)
- 同步實現
compose () {
return self.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};
上面的代碼看起來不太好理解,我們不妨根據案例把這段代碼拆解開,假設 middlewares 中存儲的三個中間件函數分別為 fn1、fn2 和 fn3,
由於使用的是 reduceRight 方法,所以是逆序歸並,第一次 a 代表初始值(空函數),b代表fn3,而執行 fn3 返回了一個函數,這個函數再作為下一次歸並的 a,而 fn2作為b`,依次類推,過程如下:
// 第 1 次 reduceRight 的返回值,下一次將作為 a
() => fn3(() => {});
// 第 2 次 reduceRight 的返回值,下一次將作為 a
() => fn2(() => fn3(() => {}));
// 第 3 次 reduceRight 的返回值,下一次將作為 a
() => fn1(() => fn2(() => fn3(() => {})));
由上面的拆解過程可以看出,如果我們調用了這個函數會先執行 fn1,如果調用 next 則會執行 fn2,如果同樣調用 next 則會執行 fn3,fn3 已經是最后一個中間件函數了,再次調 next 會執行我們最初傳入的空函數,這也是為什么要將 reduceRight 的初始值設置成一個空函數,就是防止最后一個中間件調用 next 而報錯。經過測試上面的代碼不會出現順序錯亂的情況,但是在 compose 執行后,我們希望進行一些后續的操作,所以希望返回的是 Promise,而我們又希望傳入給 use 的中間件函數既可以是普通函數,又可以是 async 函數,這就要我們的 compose 完全支持異步。
- 異步實現
compose() {
// reduceRight, 逆序歸並
return Promise.resolve(
self.middlewares.reduceRight(
(a, b) => () => Promise.resolve(b(a)),
() => Promise.resolve()
)()
)
}
參考同步的分析過程,由於最后一個中間件執行后執行的空函數內一定沒有任何邏輯,但為遇到異步代碼可以繼續執行(比如執行 next 后又調用了 then),都處理成了 Promise,保證了 reduceRight 每一次歸並的時候返回的函數內都返回了一個 Promise,這樣就完全兼容了 async 和普通函數,當所有中間件執行完畢,也返回了一個 Promise,這樣 compose 就可以調用 then 方法執行后續邏輯。
reduce(Redux新版使用正序歸並)
- 同步實現
compose () {
return self.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};
Redux 新版本中將 compose 的邏輯做了些改動,將原本的 reduceRight 換成 reduce,也就是說將逆序歸並改為了正序,我們不一定和 Redux 源碼完全相同,
是根據相同的思路來實現串行中間件的需求。個人覺得改成正序歸並后更難理解,所以還是將上面代碼結合案例進行拆分,中間件依然是 fn1、fn2 和 fn3,由於reduce並沒有傳入初始值,所以此時 a 為 fn1,b 為 fn2。
// 第 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(() => {})));
所以在調用 reduce 最后返回的函數時,傳入了一個空函數作為參數,其實這個參數最后傳遞給了 fn3,也就是第三個中間件,這樣保證了在最后一個中間件調用 next 時不會報錯。
- 異步實現
compose() {
// reduce版本
return Promise.resolve(
self.middlewares.reduce((a, b) => arg =>
Promise.resolve(a(() => b(arg)))
)(() => Promise.resolve())
);
}
使用async函數實現(僅記錄)
compose() {
return (async function () {
// 定義默認的 next,最后一個中間件內執行的 next
let next = async () => Promise.resolve();
// middleware 為每一個中間件函數,oldNext 為每個中間件函數中的 next
// 函數返回一個 async 作為新的 next,async 執行返回 Promise,解決異步問題
function createNext(middleware, oldNext) {
return async () => {
await middleware(oldNext);
}
}
// 反向遍歷中間件數組,先把 next 傳給最后一個中間件函數
// 將新的中間件函數存入 next 變量
// 調用下一個中間件函數,將新生成的 next 傳入
for (let i = self.middlewares.length - 1; i >= 0; i--) {
next = createNext(self.middlewares[i], next);
}
await next();
})();
}
