手寫@koa/router源碼

上一篇文章咱們講了Koa的基本架構[1],能夠看到Koa的基本架構只有中間件內核,並無其餘功能,路由功能也沒有。要實現路由功能咱們必須引入第三方中間件,本文要講的路由中間件是@koa/router[2],這個中間件是掛在Koa官方名下的,他跟另外一箇中間件koa-router[3]名字很像。其實@koa/routerforkkoa-router,由於koa-router的做者不少年沒維護了,因此Koa官方將它fork到了本身名下進行維護。這篇文章咱們仍是老套路,先寫一個@koa/router的簡單例子,而後本身手寫@koa/router源碼來替換他。javascript

本文可運行代碼已經上傳GitHun,拿下來一邊玩代碼,一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter前端

簡單例子

咱們這裏的例子仍是使用以前Express文章中的例子[4]java

1.訪問跟路由返回Hello World2.get /api/users返回一個用戶列表,數據是隨便造的3.post /api/users寫入一個用戶信息,用一個文件來模擬數據庫git

這個例子以前寫過幾回了,用@koa/router寫出來就是這個樣子:github

const fs = require("fs");const path = require("path");const Koa = require("koa");const Router = require("@koa/router");const bodyParser = require("koa-bodyparser");
const app = new Koa();const router = new Router();
app.use(bodyParser());
router.get("/", (ctx) => { ctx.body = "Hello World";});
router.get("/api/users", (ctx) => { const resData = [ { id: 1, name: "小明", age: 18, }, { id: 2, name: "小紅", age: 19, }, ];
ctx.body = resData;});
router.post("/api/users", async (ctx) => { // 使用了koa-bodyparser才能從ctx.request拿到body const postData = ctx.request.body;
// 使用fs.promises模塊下的方法,返回值是promises await fs.promises.appendFile( path.join(__dirname, "db.txt"), JSON.stringify(postData) );
ctx.body = postData;});
app.use(router.routes());
const port = 3001;app.listen(port, () => { console.log(`Server is running on http://127.0.0.1:${port}/`);});

上述代碼中須要注意,Koa主要提倡的是promise的用法,因此若是像以前那樣使用回調方法可能會致使返回Not Found。好比在post /api/users這個路由中,咱們會去寫文件,若是咱們仍是像以前Express那樣使用回調函數:數據庫

fs.appendFile(path.join(__dirname, "db.txt"), postData, () => { ctx.body = postData;});

這會致使這個路由的處理方法並不知道這裏須要執行回調,而是直接將外層函數執行完就結束了。而外層函數執行完並無設置ctx的返回值,因此Koa會默認返回一個Not Found。爲了不這種狀況,咱們須要讓外層函數等待這裏執行完,因此咱們這裏使用fs.promises下面的方法,這下面的方法都會返回promise,咱們就可使用await來等待返回結果了。api

手寫源碼

本文手寫源碼所有參照官方源碼寫成,方法名和變量名儘量與官方代碼保持一致,你們能夠對照着看,寫到具體方法時我也會貼上官方源碼地址。手寫源碼前咱們先來看看有哪些API是咱們須要解決的:數組

1.Router類:咱們從@koa/router引入的就是這個類,經過new關鍵字生成一個實例router,後續使用的方法都掛載在這個實例下面。2.router.getrouter.postrouter的實例方法getpost是咱們定義路由的方法。3.router.routes:這個實例方法的返回值是做爲中間件傳給app.use的,因此這個方法極可能是生成具體的中間件給Koa調用。promise

@koa/router的這種使用方法跟咱們以前看過的Express.js的路由模塊[5]有點像,若是以前看過Express.js源碼解析的,看本文應該會有種似曾相識的感受。微信

先看看路由架構

Express.js源碼解析裏面[6]我講過他的路由架構,本文講的@koa/router的架構跟他有不少類似之處,可是也有一些改進。在進一步深刻@koa/router源碼前,咱們先來回顧下Express.js的路由架構,這樣咱們能夠有一個總體的認識,能夠更好的理解後面的源碼。對於咱們上面這個例子來講,他有兩個API:

1.get /api/users2.post /api/users

這兩個API的path是同樣的,都是/api/users,可是他們的method不同,一個是get,一個是postExpress裏面將path這一層提取出來單獨做爲了一個類----Layer。一個Layer對應一個path,可是同一個path可能對應多個method。因此Layer上還添加了一個屬性routeroute上也存了一個數組,數組的每一個項存了對應的method和回調函數handle。因此整個結構就是這個樣子:

const router = { stack: [ // 裏面不少layer { path: '/api/users' route: { stack: [ // 裏面存了多個method和回調函數 { method: 'get', handle: function1 }, { method: 'post', handle: function2 } ] } } ]}

整個路由的執行分爲了兩部分:註冊路由匹配路由

註冊路由就是構造上面這樣一個結構,主要是經過請求動詞對應的方法來實現,好比運行router.get('/api/users', function1)其實就會往router上添加一個layer,這個layerpath/api/users,同時還會在layer.route的數組上添加一個項:

{ method: 'get', handle: function1}

匹配路由就是當一個請求來了咱們就去遍歷router上的全部layer,找出path匹配的layer,再找出layermethod匹配的route,而後將對應的回調函數handle拿出來執行。

@koa/router有着相似的架構,他的代碼就是在實現這種架構,先帶着這種架構思惟,咱們能夠很容易讀懂他的代碼。

Router類

首先確定是Router類,他的構造函數也比較簡單,只須要初始化幾個屬性就行。因爲@koa/router模塊大量使用了面向對象的思想,若是你對JS的面向對象還不熟悉,能夠先看看這篇文章。[7]

module.exports = Router;
function Router() { // 支持無new直接調用 if (!(this instanceof Router)) return new Router();
this.stack = []; // 變量名字都跟Express.js的路由模塊同樣}

上面代碼有一行比較有意思

if (!(this instanceof Router)) return new Router();

這種使用方法我在其餘文章也提到過:支持無new調用。咱們知道要實例化一個類,通常要使用new關鍵字,好比new Router()。可是若是Router構造函數加了這行代碼,就能夠支持無new調用了,直接Router()能夠達到一樣的效果。這是由於若是你直接Router()調用,this instanceof Router返回爲false,會走到這個if裏面去,構造函數會幫你調用一下new Router()

因此這個構造函數的主要做用就是初始化了一個屬性stack,嗯,這個屬性名字都跟Express.js路由模塊同樣。前面的架構已經說了,這個屬性就是用來存放layer的。

Router構造函數官方源碼:https://github.com/koajs/router/blob/master/lib/router.js#L50

請求動詞函數

前面架構講了,做爲一個路由模塊,咱們主要解決兩個問題:註冊路由匹配路由

先來看看註冊路由,註冊路由主要是在請求動詞函數裏面進行的,好比router.getrouter.post這種函數。HTTP動詞有不少,有一個庫專門維護了這些動詞:methods[8]@koa/router也是用的這個庫,咱們這裏就簡化下,直接一個將getpost放到一個數組裏面吧。

// HTTP動詞函數const methods = ["get", "post"];for (let i = 0; i < methods.length; i++) { const method = methods[i];
Router.prototype[method] = function (path, middleware) { // 將middleware轉化爲一個數組,支持傳入多個回調函數 middleware = Array.prototype.slice.call(arguments, 1);
this.register(path, [method], middleware);
return this; };}

上面代碼直接循環methods數組,將裏面的每一個值都添加到Router.prototype上成爲一個實例方法。這個方法接收pathmiddleware兩個參數,這裏的middleware其實就是咱們路由的回調函數,由於代碼是取的arguments第二個開始到最後全部的參數,因此其實他是支持同時傳多個回調函數的。另外官方源碼實際上是三個參數,還有可選參數name,由於是可選的,跟核心邏輯無關,我這裏直接去掉了。

還須要注意這個實例方法最後返回了this,這種操做咱們在Koa源碼裏面也見過,目的是讓用戶能夠連續點點點,好比這樣:

router.get().post();

這些實例方法最後其實都是調this.register()去註冊路由的,下面咱們看看他是怎麼寫的。

請求動詞函數官方源碼:https://github.com/koajs/router/blob/master/lib/router.js#L189

router.register()

router.register()實例方法是真正註冊路由的方法,結合前面架構講的,註冊路由就是構建layer的數據結構可知,router.register()的主要做用就是構建這個數據結構:

Router.prototype.register = function (path, methods, middleware) { const stack = this.stack;
const route = new Layer(path, methods, middleware);
stack.push(route);
return route;};

代碼跟預期的同樣,就是用pathmethodmiddleware來建立一個layer實例,而後把它塞到stack數組裏面去。

router.register官方源碼:https://github.com/koajs/router/blob/master/lib/router.js#L553

Layer類

上面代碼出現了Layer這個類,咱們來看看他的構造函數吧:

const { pathToRegexp } = require("path-to-regexp");
module.exports = Layer;
function Layer(path, methods, middleware) { // 初始化methods和stack屬性 this.methods = []; // 注意這裏的stack存放的是咱們傳入的回調函數 this.stack = Array.isArray(middleware) ? middleware : [middleware];
// 將參數methods一個一個塞進this.methods裏面去 for (let i = 0; i < methods.length; i++) { this.methods.push(methods[i].toUpperCase()); // ctx.method是大寫,注意這裏轉換爲大寫 }
// 保存path屬性 this.path = path; // 使用path-to-regexp庫將path轉化爲正則 this.regexp = pathToRegexp(path);}

Layer的構造函數能夠看出,他的架構跟Express.js路由模塊已經有點區別了。Express.jsLayer上還有Route這個概念。而@koa/routerstack上存的直接是回調函數了,已經沒有route這一層了。我我的以爲這種層級結構是比Express的要清晰的,由於Expressroute.stack裏面存的又是layer,這種相互引用是有點繞的,這點我在Express源碼解析中也提出過[9]

另外咱們看到他也用到了path-to-regexp這個庫[10],這個庫我在不少處理路由的庫裏面都見到過,好比React-RouterExpress,真想去看看他的源碼,加到個人待寫文章列表裏面去,空了去看看~

Layer構造函數官方源碼:https://github.com/koajs/router/blob/master/lib/layer.js#L20

router.routes()

前面架構提到的還有件事情須要作,那就是路由匹配

對於Koa來講,一個請求來了會依次通過每一箇中間件,因此咱們的路由匹配其實也是在中間件裏面作的。而@koa/router的中間件是經過router.routes()返回的。因此router.routes()主要作兩件事:

1.他應該返回一個Koa中間件,以便Koa調用2.這個中間件的主要工做是遍歷router上的layer,找到匹配的路由,並拿出來執行。

Router.prototype.routes = function () { const router = this;
// 這個dispatch就是咱們要返回給Koa調用的中間件 let dispatch = function dispatch(ctx, next) { const path = ctx.path; const matched = router.match(path, ctx.method); // 獲取全部匹配的layer
let layerChain; // 定義一個變量來串聯全部匹配的layer
ctx.router = router; // 順手把router掛到ctx上,給其餘Koa中間件使用
if (!matched.route) return next(); // 若是一個layer都沒匹配上,直接返回,並執行下一個Koa中間件
const matchedLayers = matched.pathAndMethod; // 獲取全部path和method都匹配的layer // 下面這段代碼的做用是將全部layer上的stack,也就是layer的回調函數都合併到一個數組layerChain裏面去 layerChain = matchedLayers.reduce(function (memo, layer) { return memo.concat(layer.stack); }, []);
// 這裏的compose也是koa-compose這個庫,源碼在講Koa源碼的時候講過 // 使用compose將layerChain數組合併成一個可執行的方法,並拿來執行,傳入參數是Koa中間件參數ctx, next return compose(layerChain)(ctx, next); };
// 將中間件返回 return dispatch;};

上述代碼中主體返回的是一個Koa中間件,這個中間件裏面先是經過router.match方法將全部匹配的layer拿出來,而後將這些layer對應的回調函數經過reduce放到一個數組裏面,也就是layerChain。而後用koa-compose將這個數組合併成一個可執行方法,這裏就有問題了。以前在Koa源碼解析我講過koa-compose的源碼,這裏再大體貼一下:

function compose(middleware) { // 參數檢查,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!"); }
// 返回一個方法,這個方法就是compose的結果 // 外部能夠經過調用這個方法來開起中間件數組的遍歷 // 參數形式和普通中間件同樣,都是context和next return function (context, next) { return dispatch(0); // 開始中間件執行,從數組第一個開始
// 執行中間件的方法 function dispatch(i) { let fn = middleware[i]; // 取出須要執行的中間件
// 若是i等於數組長度,說明數組已經執行完了 if (i === middleware.length) { fn = next; // 這裏讓fn等於外部傳進來的next,實際上是進行收尾工做,好比返回404 }
// 若是外部沒有傳收尾的next,直接就resolve if (!fn) { return Promise.resolve(); }
// 執行中間件,注意傳給中間件接收的參數應該是context和next // 傳給中間件的next是dispatch.bind(null, i + 1) // 因此中間件裏面調用next的時候其實調用的是dispatch(i + 1),也就是執行下一個中間件 try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err); } } };}

這段代碼裏面fn是咱們傳入的中間件,在@koa/router這裏對應的實際上是layerChain裏面的一項,執行fn的時候是這樣的:

fn(context, dispatch.bind(null, i + 1))

這裏傳的參數符合咱們使用@koa/router的習慣,咱們使用@koa/router通常是這樣的:

router.get("/", (ctx, next) => { ctx.body = "Hello World";});

上面的fn就是咱們傳的回調函數,注意咱們執行fn時傳入的第二個參數dispatch.bind(null, i + 1),也就是router.get這裏的next。因此咱們上面回調函數裏面再執行下next

router.get("/", (ctx, next) => { ctx.body = "Hello World"; next(); // 注意這裏});

這個回調裏面執行next()其實就是把koa-compose裏面的dispatch.bind(null, i + 1)拿出來執行,也就是dispatch(i + 1),對應的就是執行layerChain裏面的下一個函數。在這個例子裏面並無什麼用,由於匹配的回調函數只有一個。可是若是/這個路徑匹配了多個回調函數,好比這樣:

router.get("/", (ctx, next) => { console.log("123");});
router.get("/", (ctx, next) => { ctx.body = "Hello World";});

這裏/就匹配了兩個回調函數,可是你若是這麼寫,你會獲得一個Not Found。爲何呢?由於你第一個回調裏面沒有調用next()!前面說了,這裏的next()dispatch(i + 1),會去調用layerChain裏面的下一個回調函數,換一句話說,你這裏不調next()就不會運行下一個回調函數了!要想讓/返回Hello World,咱們須要在第一個回調函數裏面調用next,像這樣:

router.get("/", (ctx, next) => { console.log("123"); next(); // 記得調用next});
router.get("/", (ctx, next) => { ctx.body = "Hello World";});

因此有朋友以爲@koa/router回調函數裏面的next沒什麼用,若是你一個路由只有一個匹配的回調函數,那確實沒什麼用,可是若是你一個路徑可能匹配多個回調函數,記得調用next

router.routes官方源碼:https://github.com/koajs/router/blob/master/lib/router.js#L335

router.match()

上面router.routes的源碼裏面咱們用到了router.match這個實例方法來查找全部匹配的layer,上面是這麼用的:

const matched = router.match(path, ctx.method);

因此咱們也須要寫一下這個函數,這個函數不復雜,經過傳入的pathmethodrouter.stack上找到全部匹配的layer就行:

Router.prototype.match = function (path, method) { const layers = this.stack; // 取出全部layer
let layer; // 構建一個結構來保存匹配結果,最後返回的也是這個matched const matched = { path: [], // path保存僅僅path匹配的layer pathAndMethod: [], // pathAndMethod保存path和method都匹配的layer route: false, // 只要有一個path和method都匹配的layer,就說明這個路由是匹配上的,這個變量置爲true };
// 循環layers來進行匹配 for (let i = 0; i < layers.length; i++) { layer = layers[i]; // 匹配的時候調用的是layer的實例方法match if (layer.match(path)) { matched.path.push(layer); // 只要path匹配就先放到matched.path上去
// 若是method也有匹配的,將layer放到pathAndMethod裏面去 if (~layer.methods.indexOf(method)) { matched.pathAndMethod.push(layer); if (layer.methods.length) matched.route = true; } } }
return matched;};

上面代碼只是循環了全部的layer,而後將匹配的layer放到一個對象matched裏面並返回給外面調用,match.path保存了全部path匹配,可是method並不必定匹配的layer,本文並無用到這個變量。具體匹配path其實仍是調用的layer的實例方法layer.match,咱們後面會來看看。

這段代碼還有個有意思的點是檢測layer.methods裏面是否包含method的時候,源碼是這樣寫的:

~layer.methods.indexOf(method)

而通常咱們多是這樣寫:

layer.methods.indexOf(method) > -1

這個源碼裏面的~是按位取反的意思,達到的效果與咱們後面這種寫法實際上是同樣的,由於:

~ -1; // 返回0,也就是false~ 0; // 返回-1, 注意-1轉換爲bool是true~ 1; // 返回-2,轉換爲bool也是true

這種用法能夠少寫幾個字母,又學會一招,你們具體使用的仍是根據本身的狀況來吧,選取喜歡的方式。

router.match官方源碼:https://github.com/koajs/router/blob/master/lib/router.js#L669

layer.match()

上面用到了layer.match這個方法,咱們也來寫一下吧。由於咱們在建立layer實例的時候,其實已經將path轉換爲了一個正則,咱們直接拿來用就行:

Layer.prototype.match = function (path) { return this.regexp.test(path);};

layer.match官方源碼:https://github.com/koajs/router/blob/master/lib/layer.js#L54

總結

到這裏,咱們本身的@koa/router就寫完了,使用他替換官方的源碼也能正常工做啦~

本文可運行代碼已經上傳到GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter

最後咱們再來總結下本文的要點吧:

1.@koa/router總體是做爲一個Koa中間件存在的。2.@koa/routerforkkoa-router繼續進行維護。3.@koa/router的總體思路跟Express.js路由模塊很像。4.@koa/router也能夠分爲註冊路由匹配路由兩部分。5.註冊路由主要是構建路由的數據結構,具體來講就是建立不少layer,每一個layer上保存具體的pathmethods,和回調函數。6.@koa/router建立的數據結構跟Express.js路由模塊有區別,少了route這個層級,可是我的以爲@koa/router的這種結構反而更清晰。Express.jslayerroute的相互引用反而更讓人疑惑。7.匹配路由就是去遍歷全部的layer,找出匹配的layer,將回調方法拿來執行。8.一個路由可能匹配多個layer和回調函數,執行時使用koa-compose將這些匹配的回調函數串起來,一個一個執行。9.須要注意的是,若是一個路由匹配了多個回調函數,前面的回調函數必須調用next()才能繼續走到下一個回調函數。

參考資料

@koa/router官方文檔:https://github.com/koajs/router

@koa/router源碼地址:https://github.com/koajs/router/tree/master/lib

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址:https://github.com/dennis-jiang/Front-End-Knowledges

做者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

我也搞了個公衆號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

References

[1] 上一篇文章咱們講了Koa的基本架構: https://juejin.im/post/6892952604163342344
[2] @koa/router: https://github.com/koajs/router
[3] koa-router: https://github.com/ZijianHe/koa-router
[4] Express文章中的例子: https://juejin.im/post/6890358903960240142
[5] Express.js的路由模塊: https://juejin.im/post/6890358903960240142#heading-6
[6] Express.js源碼解析裏面: https://juejin.im/post/6890358903960240142#heading-6
[7] 因爲@koa/router模塊大量使用了面向對象的思想,若是你對JS的面向對象還不熟悉,能夠先看看這篇文章。: https://juejin.im/post/6844904069887164423
[8] methods: https://github.com/jshttp/methods
[9] Express源碼解析中也提出過: https://juejin.im/post/6890358903960240142
[10] path-to-regexp這個庫: https://github.com/pillarjs/path-to-regexp


本文分享自微信公衆號 - 進擊的大前端(AdvanceOnFE)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索