玩轉(zhuǎn)Koa之koa-router原理解析
一、前言
Koa為了保持自身的簡(jiǎn)潔,并沒(méi)有捆綁中間件。但是在實(shí)際的開(kāi)發(fā)中,我們需要和形形色色的中間件打交道,本文將要分析的是經(jīng)常用到的路由中間件 -- koa-router。
如果你對(duì)Koa的原理還不了解的話,可以先查看Koa原理解析。
二、koa-router概述
koa-router的源碼只有兩個(gè)文件:router.js和layer.js,分別對(duì)應(yīng)Router對(duì)象和Layer對(duì)象。
Layer對(duì)象是對(duì)單個(gè)路由的管理,其中包含的信息有路由路徑(path)、路由請(qǐng)求方法(method)和路由執(zhí)行函數(shù)(middleware),并且提供路由的驗(yàn)證以及params參數(shù)解析的方法。
相比較Layer對(duì)象,Router對(duì)象則是對(duì)所有注冊(cè)路由的統(tǒng)一處理,并且它的API是面向開(kāi)發(fā)者的。
接下來(lái)從以下幾個(gè)方面全面解析koa-router的實(shí)現(xiàn)原理:
- Layer對(duì)象的實(shí)現(xiàn)
- 路由注冊(cè)
- 路由匹配
- 路由執(zhí)行流程
三、Layer
Layer對(duì)象主要是對(duì)單個(gè)路由的管理,是整個(gè)koa-router中最小的處理單元,后續(xù)模塊的處理都離不開(kāi)Layer中的方法,這正是首先介紹Layer的重要原因。
function Layer(path, methods, middleware, opts) { this.opts = opts || {}; // 支持路由別名 this.name = this.opts.name || null; this.methods = []; this.paramNames = []; // 將路由執(zhí)行函數(shù)保存在stack中,支持輸入多個(gè)處理函數(shù) this.stack = Array.isArray(middleware) ? middleware : [middleware]; methods.forEach(function(method) { var l = this.methods.push(method.toUpperCase()); // HEAD請(qǐng)求頭部信息與GET一致,這里就一起處理了。 if (this.methods[l-1] === 'GET') { this.methods.unshift('HEAD'); } }, this); // 確保類型正確 this.stack.forEach(function(fn) { var type = (typeof fn); if (type !== 'function') { throw new Error( methods.toString() + " `" + (this.opts.name || path) +"`: `middleware` " + "must be a function, not `" + type + "`" ); } }, this); this.path = path; // 1、根據(jù)路由路徑生成路由正則表達(dá)式 // 2、將params參數(shù)信息保存在paramNames數(shù)組中 this.regexp = pathToRegExp(path, this.paramNames, this.opts); };
Layer構(gòu)造函數(shù)主要用來(lái)初始化路由路徑、路由請(qǐng)求方法數(shù)組、路由處理函數(shù)數(shù)組、路由正則表達(dá)式以及params參數(shù)信息數(shù)組,其中主要采用path-to-regexp方法根據(jù)路徑字符串生成正則表達(dá)式,通過(guò)該正則表達(dá)式,可以實(shí)現(xiàn)路由的匹配以及params參數(shù)的捕獲:
// 驗(yàn)證路由 Layer.prototype.match = function (path) { return this.regexp.test(path); } // 捕獲params參數(shù) Layer.prototype.captures = function (path) { // 后續(xù)會(huì)提到 對(duì)于路由級(jí)別中間件 無(wú)需捕獲params if (this.opts.ignoreCaptures) return []; return path.match(this.regexp).slice(1); }
根據(jù)paramNames中的參數(shù)信息以及captrues方法,可以獲取到當(dāng)前路由params參數(shù)的鍵值對(duì):
Layer.prototype.params = function (path, captures, existingParams) { var params = existingParams || {}; for (var len = captures.length, i=0; i<len; i++) { if (this.paramNames[i]) { var c = captures[i]; params[this.paramNames[i].name] = c ? safeDecodeURIComponent(c) : c; } } return params; };
需要注意上述代碼中的safeDecodeURIComponent方法,為了避免服務(wù)器收到不可預(yù)知的請(qǐng)求,對(duì)于任何用戶輸入的作為URI部分的內(nèi)容都需要采用encodeURIComponent進(jìn)行轉(zhuǎn)義,否則當(dāng)用戶輸入的內(nèi)容中含有'&'、'='、'?'等字符時(shí),會(huì)出現(xiàn)預(yù)料之外的情況。而當(dāng)我們獲取URL上的參數(shù)時(shí),則需要通過(guò)decodeURIComponent進(jìn)行解碼,而decodeURIComponent只能解碼由encodeURIComponent方法或者類似方法編碼,如果編碼方法不符合要求,decodeURIComponent則會(huì)拋出URIError,所以作者在這里對(duì)該方法進(jìn)行了安全化的處理:
function safeDecodeURIComponent(text) { try { return decodeURIComponent(text); } catch (e) { // 編碼方式不符合要求,返回原字符串 return text; } }
Layer還提供了對(duì)于單個(gè)param前置處理的方法:
Layer.prototype.param = function (param, fn) { var stack = this.stack; var params = this.paramNames; var middleware = function (ctx, next) { return fn.call(this, ctx.params[param], ctx, next); }; middleware.param = param; var names = params.map(function (p) { return p.name; }); var x = names.indexOf(param); if (x > -1) { stack.some(function (fn, i) { if (!fn.param || names.indexOf(fn.param) > x) { // 將單個(gè)param前置處理函數(shù)插入正確的位置 stack.splice(i, 0, middleware); return true; // 跳出循環(huán) } }); } return this; };
上述代碼中通過(guò)some方法尋找單個(gè)param處理函數(shù)的原因在于以下兩點(diǎn):
- 保持param處理函數(shù)位于其他路由處理函數(shù)的前面;
- 路由中存在多個(gè)param參數(shù),需要保持param處理函數(shù)的前后順序。
Layer.prototype.setPrefix = function (prefix) { if (this.path) { this.path = prefix + this.path; // 拼接新的路由路徑 this.paramNames = []; // 根據(jù)新的路由路徑字符串生成正則表達(dá)式 this.regexp = pathToRegExp(this.path, this.paramNames, this.opts); } return this; };
Layer中的setPrefix方法用于設(shè)置路由路徑的前綴,這在嵌套路由的實(shí)現(xiàn)中尤其重要。
最后,Layer還提供了根據(jù)路由生成url的方法,主要采用path-to-regexp的compile和parse對(duì)路由路徑中的param進(jìn)行替換,而在拼接query的環(huán)節(jié),正如前面所說(shuō)需要對(duì)鍵值對(duì)進(jìn)行繁瑣的encodeURIComponent操作,作者采用了urijs提供的簡(jiǎn)潔api進(jìn)行處理。
四、路由注冊(cè)
1、Router構(gòu)造函數(shù)
首先看了解一下Router構(gòu)造函數(shù):
function Router(opts) { if (!(this instanceof Router)) { // 限制必須采用new關(guān)鍵字 return new Router(opts); } this.opts = opts || {}; // 服務(wù)器支持的請(qǐng)求方法, 后續(xù)allowedMethods方法會(huì)用到 this.methods = this.opts.methods || [ 'HEAD', 'OPTIONS', 'GET', 'PUT', 'PATCH', 'POST', 'DELETE' ]; this.params = {}; // 保存param前置處理函數(shù) this.stack = []; // 存儲(chǔ)layer };
在構(gòu)造函數(shù)中初始化的params和stack屬性最為重要,前者用來(lái)保存param前置處理函數(shù),后者用來(lái)保存實(shí)例化的Layer對(duì)象。并且這兩個(gè)屬性與接下來(lái)要講的路由注冊(cè)息息相關(guān)。
koa-router中提供兩種方式注冊(cè)路由:
- 具體的HTTP動(dòng)詞注冊(cè)方式,例如:router.get('/users', ctx => {})
- 支持所有的HTTP動(dòng)詞注冊(cè)方式,例如:router.all('/users', ctx => {})
2、http METHODS
源碼中采用methods模塊獲取HTTP請(qǐng)求方法名,該模塊內(nèi)部實(shí)現(xiàn)主要依賴于http模塊:
http.METHODS && http.METHODS.map(function lowerCaseMethod (method) { return method.toLowerCase() })
3、router.verb() and router.all()
這兩種注冊(cè)路由的方式的內(nèi)部實(shí)現(xiàn)基本類似,下面以router.verb()的源碼為例:
methods.forEach(function (method) { Router.prototype[method] = function (name, path, middleware) { var middleware; // 1、處理是否傳入name參數(shù) // 2、middleware參數(shù)支持middleware1, middleware2...的形式 if (typeof path === 'string' || path instanceof RegExp) { middleware = Array.prototype.slice.call(arguments, 2); } else { middleware = Array.prototype.slice.call(arguments, 1); path = name; name = null; } // 路由注冊(cè)的核心處理邏輯 this.register(path, [method], middleware, { name: name }); return this; }; });
該方法第一部分是對(duì)傳入?yún)?shù)的處理,對(duì)于middleware參數(shù)的處理會(huì)讓大家聯(lián)想到ES6中的rest參數(shù),但是rest參數(shù)與arguments其中一個(gè)致命的區(qū)別:
rest參數(shù)只包含那些沒(méi)有對(duì)應(yīng)形參的實(shí)參,而arguments則包含傳給函數(shù)的所有實(shí)參。
如果采用rest參數(shù)的方式,上述函數(shù)則必須要求開(kāi)發(fā)者傳入name參數(shù)。但是也可以將name和path參數(shù)整合成對(duì)象,再結(jié)合rest參數(shù):
Router.prototype[method] = function (options, ...middleware) { let { name, path } = options if (typeof options === 'string' || options instanceof RegExp) { path = options name = null } // ... return this; };
采用ES6的新特性,代碼變得簡(jiǎn)潔多了。
第二部分是register方法,傳入的method參數(shù)的形式就是router.verb()與router.all()的最大區(qū)別,在router.verb()中傳入的method是單個(gè)方法,后者則是以數(shù)組的形式傳入HTTP所有的請(qǐng)求方法,所以對(duì)于這兩種注冊(cè)方法的實(shí)現(xiàn),本質(zhì)上是沒(méi)有區(qū)別的。
4、register
Router.prototype.register = function (path, methods, middleware, opts) { opts = opts || {}; var router = this; var stack = this.stack; // 注冊(cè)路由中間件時(shí),允許path為數(shù)組 if (Array.isArray(path)) { path.forEach(function (p) { router.register.call(router, p, methods, middleware, opts); }); return this; } // 實(shí)例化Layer var route = new Layer(path, methods, middleware, { end: opts.end === false ? opts.end : true, name: opts.name, sensitive: opts.sensitive || this.opts.sensitive || false, strict: opts.strict || this.opts.strict || false, prefix: opts.prefix || this.opts.prefix || "", ignoreCaptures: opts.ignoreCaptures }); // 設(shè)置前綴 if (this.opts.prefix) { route.setPrefix(this.opts.prefix); } // 設(shè)置param前置處理函數(shù) Object.keys(this.params).forEach(function (param) { route.param(param, this.params[param]); }, this); stack.push(route); return route; };
register方法主要負(fù)責(zé)實(shí)例化Layer對(duì)象、更新路由前綴和前置param處理函數(shù),這些操作在Layer中已經(jīng)提及過(guò),相信大家應(yīng)該輕車熟路了。
5、use
熟悉Koa的同學(xué)都知道use是用來(lái)注冊(cè)中間件的方法,相比較Koa中的全局中間件,koa-router的中間件則是路由級(jí)別的。
Router.prototype.use = function () {
var router = this; var middleware = Array.prototype.slice.call(arguments); var path; // 支持多路徑在于中間件可能作用于多條路由路徑 if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') { middleware[0].forEach(function (p) { router.use.apply(router, [p].concat(middleware.slice(1))); }); return this; } // 處理路由路徑參數(shù) var hasPath = typeof middleware[0] === 'string'; if (hasPath) { path = middleware.shift(); } middleware.forEach(function (m) { // 嵌套路由 if (m.router) { // 嵌套路由扁平化處理 m.router.stack.forEach(function (nestedLayer) { // 更新嵌套之后的路由路徑 if (path) nestedLayer.setPrefix(path); // 更新掛載到父路由上的路由路徑 if (router.opts.prefix) nestedLayer.setPrefix(router.opts.prefix); router.stack.push(nestedLayer); }); // 不要忘記將父路由上的param前置處理操作 更新到新路由上。 if (router.params) { Object.keys(router.params).forEach(function (key) { m.router.param(key, router.params[key]); }); } } else { // 路由級(jí)別中間件 創(chuàng)建一個(gè)沒(méi)有method的Layer實(shí)例 router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath }); } }); return this; };
koa-router中間件注冊(cè)方法主要完成兩項(xiàng)功能:
- 將路由嵌套結(jié)構(gòu)扁平化,其中涉及到路由路徑的更新和param前置處理函數(shù)的插入;
- 路由級(jí)別中間件通過(guò)注冊(cè)一個(gè)沒(méi)有method的Layer實(shí)例進(jìn)行管理。
五、路由匹配
Router.prototype.match = function (path, method) { var layers = this.stack; var layer; var matched = { path: [], pathAndMethod: [], route: false }; for (var len = layers.length, i = 0; i < len; i++) { layer = layers[i]; if (layer.match(path)) { // 路由路徑滿足要求 matched.path.push(layer); if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) { // layer.methods.length === 0 該layer為路由級(jí)別中間件 // ~layer.methods.indexOf(method) 路由請(qǐng)求方法也被匹配 matched.pathAndMethod.push(layer); // 僅當(dāng)路由路徑和路由請(qǐng)求方法都被滿足才算是路由被匹配 if (layer.methods.length) matched.route = true; } } } return matched; };
match方法主要通過(guò)layer.match方法以及methods屬性對(duì)layer進(jìn)行篩選,返回的matched對(duì)象包含以下幾個(gè)部分:
- path: 保存所有路由路徑被匹配的layer;
- pathAndMethod: 在路由路徑被匹配的前提下,保存路由級(jí)別中間件和路由請(qǐng)求方法被匹配的layer;
- route: 僅當(dāng)存在路由路徑和路由請(qǐng)求方法都被匹配的layer,才能算是本次路由被匹配上。
另外,在ES7之前,對(duì)于判斷數(shù)組是否包含一個(gè)元素,都需要通過(guò)indexOf方法來(lái)實(shí)現(xiàn), 而該方法返回元素的下標(biāo),這樣就不得不通過(guò)與-1的比較得到布爾值:
if (layer.methods.indexOf(method) > -1) { ... }
而作者巧妙地利用位運(yùn)算省去了“討厭的-1”,當(dāng)然在ES7中可以愉快地使用includes方法:
if (layer.methods.includes(method)) { ... }
六、路由執(zhí)行流程
理解koa-router中路由的概念以及路由注冊(cè)的方式,接下來(lái)就是如何作為一個(gè)中間件在koa中執(zhí)行。
koa中注冊(cè)koa-router中間件的方式如下:
const Koa = require('koa'); const Router = require('koa-router'); const app = new Koa(); const router = new Router(); router.get('/', (ctx, next) => { // ctx.router available }); app .use(router.routes()) .use(router.allowedMethods());
從代碼中可以看出koa-router提供了兩個(gè)中間件方法:routes和allowedMethods。
1、allowedMethods()
Router.prototype.allowedMethods = function (options) { options = options || {}; var implemented = this.methods; return function allowedMethods(ctx, next) { return next().then(function() { var allowed = {}; if (!ctx.status || ctx.status === 404) { ctx.matched.forEach(function (route) { route.methods.forEach(function (method) { allowed[method] = method; }); }); var allowedArr = Object.keys(allowed); if (!~implemented.indexOf(ctx.method)) { // 服務(wù)器不支持該方法的情況 if (options.throw) { var notImplementedThrowable; if (typeof options.notImplemented === 'function') { notImplementedThrowable = options.notImplemented(); } else { notImplementedThrowable = new HttpError.NotImplemented(); } throw notImplementedThrowable; } else { // 響應(yīng) 501 Not Implemented ctx.status = 501; ctx.set('Allow', allowedArr.join(', ')); } } else if (allowedArr.length) { if (ctx.method === 'OPTIONS') { // 獲取服務(wù)器對(duì)該路由路徑支持的方法集合 ctx.status = 200; ctx.body = ''; ctx.set('Allow', allowedArr.join(', ')); } else if (!allowed[ctx.method]) { if (options.throw) { var notAllowedThrowable; if (typeof options.methodNotAllowed === 'function') { notAllowedThrowable = options.methodNotAllowed(); } else { notAllowedThrowable = new HttpError.MethodNotAllowed(); } throw notAllowedThrowable; } else { // 響應(yīng) 405 Method Not Allowed ctx.status = 405; ctx.set('Allow', allowedArr.join(', ')); } } } } }); }; };
allowedMethods()中間件主要用于處理options請(qǐng)求,響應(yīng)405和501狀態(tài)。上述代碼中的ctx.matched中保存的正是前面matched對(duì)象中的path(在routes方法中設(shè)置,后面會(huì)提到。),在matched對(duì)象中的path數(shù)組不為空的前提條件下:
- 服務(wù)器不支持當(dāng)前請(qǐng)求方法,返回501狀態(tài)碼;
- 當(dāng)前請(qǐng)求方法為OPTIONS,返回200狀態(tài)碼;
- path中的layer不支持該方法,返回405狀態(tài);
對(duì)于上述三種情況,服務(wù)器都會(huì)設(shè)置Allow響應(yīng)頭,返回該路由路徑上支持的請(qǐng)求方法。
2、routes()
Router.prototype.routes = Router.prototype.middleware = function () { var router = this; // 返回中間件處理函數(shù) var dispatch = function dispatch(ctx, next) { var path = router.opts.routerPath || ctx.routerPath || ctx.path; var matched = router.match(path, ctx.method); var layerChain, layer, i; // 【1】為后續(xù)的allowedMethods中間件準(zhǔn)備 if (ctx.matched) { ctx.matched.push.apply(ctx.matched, matched.path); } else { ctx.matched = matched.path; } ctx.router = router; // 未匹配路由 直接跳過(guò) if (!matched.route) return next(); var matchedLayers = matched.pathAndMethod var mostSpecificLayer = matchedLayers[matchedLayers.length - 1] ctx._matchedRoute = mostSpecificLayer.path; if (mostSpecificLayer.name) { ctx._matchedRouteName = mostSpecificLayer.name; } layerChain = matchedLayers.reduce(function(memo, layer) { // 【3】路由的前置處理中間件 主要負(fù)責(zé)將params、路由別名以及捕獲數(shù)組屬性掛載在ctx上下文對(duì)象中。 memo.push(function(ctx, next) { ctx.captures = layer.captures(path, ctx.captures); ctx.params = layer.params(path, ctx.captures, ctx.params); ctx.routerName = layer.name; return next(); }); return memo.concat(layer.stack); }, []); // 【4】利用koa中間件組織的方式,形成一個(gè)‘小洋蔥'模型 return compose(layerChain)(ctx, next); }; // 【2】router屬性用來(lái)use方法中區(qū)別路由級(jí)別中間件 dispatch.router = this; return dispatch; };
routes()中間件主要實(shí)現(xiàn)了四大功能。
- 將matched對(duì)象的path屬性掛載在ctx.matched上,提供給后續(xù)的allowedMethods中間件使用。(見(jiàn)代碼中的【1】)
- 將返回的dispatch函數(shù)設(shè)置router屬性,以便在前面提到的Router.prototype.use方法中區(qū)別路由級(jí)別中間件和嵌套路由。(見(jiàn)代碼中的【2】)
- 插入一個(gè)新的路由前置處理中間件,將layer解析出來(lái)的params對(duì)象、路由別名以及捕獲數(shù)組掛載在ctx上下文中,這種操作同理Koa在處理請(qǐng)求之前先構(gòu)建context對(duì)象。(見(jiàn)代碼中的【3】)
- 而對(duì)于路由匹配到眾多l(xiāng)ayer,koa-router通過(guò)koa-compose進(jìn)行處理,這和koa對(duì)于中間件處理的方式一樣的,所以koa-router完全就是一個(gè)小型洋蔥模型。
七、總結(jié)
koa-router雖然是koa的一個(gè)中間件,但是其內(nèi)部也包含眾多的中間件,這些中間件通過(guò)Layer對(duì)象根據(jù)路由路徑的不同進(jìn)行劃分,使得它們不再像koa的中間件那樣每次請(qǐng)求都執(zhí)行,而是針對(duì)每次請(qǐng)求采用match方法匹配出相應(yīng)的中間件,再利用koa-compose形成一個(gè)中間件執(zhí)行鏈。
以上便是koa-router實(shí)現(xiàn)原理的全部?jī)?nèi)容,希望可以幫助你更好的理解koa-router。也希望大家多多支持腳本之家。
相關(guān)文章
在 Angular-cli 中使用 simple-mock 實(shí)現(xiàn)前端開(kāi)發(fā) API Mock 接口數(shù)據(jù)模擬功能的方法
這篇文章主要介紹了在 Angular-cli 中使用 simple-mock 實(shí)現(xiàn)前端開(kāi)發(fā) API Mock 接口數(shù)據(jù)模擬功能的方法,需要的朋友可以參考下2018-11-11AngularJS定時(shí)器的使用與移除操作方法【interval與timeout】
這篇文章主要介紹了AngularJS定時(shí)器的使用與移除操作方法,結(jié)合實(shí)例形式分析了AngularJS中interval與timeout方法的相關(guān)使用技巧,需要的朋友可以參考下2016-12-12angularJS實(shí)現(xiàn)動(dòng)態(tài)添加,刪除div方法
下面小編就為大家分享一篇angularJS實(shí)現(xiàn)動(dòng)態(tài)添加,刪除div方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-02-02Angular實(shí)現(xiàn)下載安裝包的功能代碼分享
本文通過(guò)實(shí)例代碼給大家介紹了angular實(shí)現(xiàn)下載安裝包的功能以及基于angularjs代碼實(shí)現(xiàn)錨點(diǎn)跳轉(zhuǎn)的功能,需要的朋友參考下吧2017-09-09angular學(xué)習(xí)之從零搭建一個(gè)angular4.0項(xiàng)目
本篇文章主要介紹了從零搭建一個(gè)angular4.0項(xiàng)目,主要用到的工具angular4.0、angular-cli、npm(v3.10.8)、node(v6.2.0),有興趣的可以了解一下2017-07-07解決angularJS中input標(biāo)簽的ng-change事件無(wú)效問(wèn)題
今天小編就為大家分享一篇解決angularJS中input標(biāo)簽的ng-change事件無(wú)效問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-09-09AngularJS實(shí)現(xiàn)圖片上傳和預(yù)覽功能的方法分析
這篇文章主要介紹了AngularJS實(shí)現(xiàn)圖片上傳和預(yù)覽功能的方法,結(jié)合HTML5實(shí)例形式對(duì)比分析了AngularJS圖片上傳的相關(guān)操作技巧與注意事項(xiàng),需要的朋友可以參考下2017-11-11Angular中封裝fancyBox(圖片預(yù)覽)遇到問(wèn)題小結(jié)
這篇文章主要介紹了Angular中封裝fancyBox(圖片預(yù)覽)遇到的問(wèn)題小結(jié),需要的朋友可以參考下2017-09-09