手写@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源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索