天天看点

手写@koa/router源码

上一篇文章我们讲了<code>Koa</code>的基本架构,可以看到<code>Koa</code>的基本架构只有中间件内核,并没有其他功能,路由功能也没有。要实现路由功能我们必须引入第三方中间件,本文要讲的路由中间件是@koa/router,这个中间件是挂在<code>Koa</code>官方名下的,他跟另一个中间件koa-router名字很像。其实<code>@koa/router</code>是<code>fork</code>的<code>koa-router</code>,因为<code>koa-router</code>的作者很多年没维护了,所以<code>Koa</code>官方将它<code>fork</code>到了自己名下进行维护。这篇文章我们还是老套路,先写一个<code>@koa/router</code>的简单例子,然后自己手写<code>@koa/router</code>源码来替换他。

本文可运行代码已经上传GitHun,拿下来一边玩代码,一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter

我们这里的例子还是使用之前Express文章中的例子:

访问跟路由返回<code>Hello World</code>

<code>get /api/users</code>返回一个用户列表,数据是随便造的

<code>post /api/users</code>写入一个用户信息,用一个文件来模拟数据库

这个例子之前写过几次了,用<code>@koa/router</code>写出来就是这个样子:

上述代码中需要注意,<code>Koa</code>主要提倡的是<code>promise</code>的用法,所以如果像之前那样使用回调方法可能会导致返回<code>Not Found</code>。比如在<code>post /api/users</code>这个路由中,我们会去写文件,如果我们还是像之前<code>Express</code>那样使用回调函数:

这会导致这个路由的处理方法并不知道这里需要执行回调,而是直接将外层函数执行完就结束了。而外层函数执行完并没有设置<code>ctx</code>的返回值,所以<code>Koa</code>会默认返回一个<code>Not Found</code>。为了避免这种情况,我们需要让外层函数等待这里执行完,所以我们这里使用<code>fs.promises</code>下面的方法,这下面的方法都会返回<code>promise</code>,我们就可以使用<code>await</code>来等待返回结果了。

本文手写源码全部参照官方源码写成,方法名和变量名尽可能与官方代码保持一致,大家可以对照着看,写到具体方法时我也会贴上官方源码地址。手写源码前我们先来看看有哪些API是我们需要解决的:

<code>Router</code>类:我们从<code>@koa/router</code>引入的就是这个类,通过<code>new</code>关键字生成一个实例<code>router</code>,后续使用的方法都挂载在这个实例下面。

<code>router.get</code>和<code>router.post</code>:<code>router</code>的实例方法<code>get</code>和<code>post</code>是我们定义路由的方法。

<code>router.routes</code>:这个实例方法的返回值是作为中间件传给<code>app.use</code>的,所以这个方法很可能是生成具体的中间件给<code>Koa</code>调用。

<code>@koa/router</code>的这种使用方法跟我们之前看过的Express.js的路由模块有点像,如果之前看过<code>Express.js</code>源码解析的,看本文应该会有种似曾相识的感觉。

Express.js源码解析里面我讲过他的路由架构,本文讲的<code>@koa/router</code>的架构跟他有很多相似之处,但是也有一些改进。在进一步深入<code>@koa/router</code>源码前,我们先来回顾下<code>Express.js</code>的路由架构,这样我们可以有一个整体的认识,可以更好的理解后面的源码。对于我们上面这个例子来说,他有两个API:

<code>get /api/users</code>

<code>post /api/users</code>

这两个API的<code>path</code>是一样的,都是<code>/api/users</code>,但是他们的<code>method</code>不一样,一个是<code>get</code>,一个是<code>post</code>。<code>Express</code>里面将<code>path</code>这一层提取出来单独作为了一个类----<code>Layer</code>。一个<code>Layer</code>对应一个<code>path</code>,但是同一个<code>path</code>可能对应多个<code>method</code>。所以<code>Layer</code>上还添加了一个属性<code>route</code>,<code>route</code>上也存了一个数组,数组的每个项存了对应的<code>method</code>和回调函数<code>handle</code>。所以整个结构就是这个样子:

整个路由的执行分为了两部分:注册路由和匹配路由。

注册路由就是构造上面这样一个结构,主要是通过请求动词对应的方法来实现,比如运行<code>router.get('/api/users', function1)</code>其实就会往<code>router</code>上添加一个<code>layer</code>,这个<code>layer</code>的<code>path</code>是<code>/api/users</code>,同时还会在<code>layer.route</code>的数组上添加一个项:

匹配路由就是当一个请求来了我们就去遍历<code>router</code>上的所有<code>layer</code>,找出<code>path</code>匹配的<code>layer</code>,再找出<code>layer</code>上<code>method</code>匹配的<code>route</code>,然后将对应的回调函数<code>handle</code>拿出来执行。

<code>@koa/router</code>有着类似的架构,他的代码就是在实现这种架构,先带着这种架构思维,我们可以很容易读懂他的代码。

首先肯定是<code>Router</code>类,他的构造函数也比较简单,只需要初始化几个属性就行。由于<code>@koa/router</code>模块大量使用了面向对象的思想,如果你对JS的面向对象还不熟悉,可以先看看这篇文章。

上面代码有一行比较有意思

这种使用方法我在其他文章也提到过:支持无<code>new</code>调用。我们知道要实例化一个类,一般要使用<code>new</code>关键字,比如<code>new Router()</code>。但是如果<code>Router</code>构造函数加了这行代码,就可以支持无<code>new</code>调用了,直接<code>Router()</code>可以达到同样的效果。这是因为如果你直接<code>Router()</code>调用,<code>this instanceof Router</code>返回为<code>false</code>,会走到这个<code>if</code>里面去,构造函数会帮你调用一下<code>new Router()</code>。

所以这个构造函数的主要作用就是初始化了一个属性<code>stack</code>,嗯,这个属性名字都跟<code>Express.js</code>路由模块一样。前面的架构已经说了,这个属性就是用来存放<code>layer</code>的。

<code>Router</code>构造函数官方源码:https://github.com/koajs/router/blob/master/lib/router.js#L50

前面架构讲了,作为一个路由模块,我们主要解决两个问题:注册路由和匹配路由。

先来看看注册路由,注册路由主要是在请求动词函数里面进行的,比如<code>router.get</code>和<code>router.post</code>这种函数。<code>HTTP</code>动词有很多,有一个库专门维护了这些动词:methods。<code>@koa/router</code>也是用的这个库,我们这里就简化下,直接一个将<code>get</code>和<code>post</code>放到一个数组里面吧。

上面代码直接循环<code>methods</code>数组,将里面的每个值都添加到<code>Router.prototype</code>上成为一个实例方法。这个方法接收<code>path</code>和<code>middleware</code>两个参数,这里的<code>middleware</code>其实就是我们路由的回调函数,因为代码是取的<code>arguments</code>第二个开始到最后所有的参数,所以其实他是支持同时传多个回调函数的。另外官方源码其实是三个参数,还有可选参数<code>name</code>,因为是可选的,跟核心逻辑无关,我这里直接去掉了。

还需要注意这个实例方法最后返回了<code>this</code>,这种操作我们在<code>Koa</code>源码里面也见过,目的是让用户可以连续点点点,比如这样:

这些实例方法最后其实都是调<code>this.register()</code>去注册路由的,下面我们看看他是怎么写的。

请求动词函数官方源码:https://github.com/koajs/router/blob/master/lib/router.js#L189

<code>router.register()</code>实例方法是真正注册路由的方法,结合前面架构讲的,注册路由就是构建<code>layer</code>的数据结构可知,<code>router.register()</code>的主要作用就是构建这个数据结构:

代码跟预期的一样,就是用<code>path</code>,<code>method</code>和<code>middleware</code>来创建一个<code>layer</code>实例,然后把它塞到<code>stack</code>数组里面去。

<code>router.register</code>官方源码:https://github.com/koajs/router/blob/master/lib/router.js#L553

上面代码出现了<code>Layer</code>这个类,我们来看看他的构造函数吧:

从<code>Layer</code>的构造函数可以看出,他的架构跟<code>Express.js</code>路由模块已经有点区别了。<code>Express.js</code>的<code>Layer</code>上还有<code>Route</code>这个概念。而<code>@koa/router</code>的<code>stack</code>上存的直接是回调函数了,已经没有<code>route</code>这一层了。我个人觉得这种层级结构是比<code>Express</code>的要清晰的,因为<code>Express</code>的<code>route.stack</code>里面存的又是<code>layer</code>,这种相互引用是有点绕的,这点我在Express源码解析中也提出过。

另外我们看到他也用到了<code>path-to-regexp</code>这个库,这个库我在很多处理路由的库里面都见到过,比如<code>React-Router</code>,<code>Express</code>,真想去看看他的源码,加到我的待写文章列表里面去,空了去看看~

<code>Layer</code>构造函数官方源码:https://github.com/koajs/router/blob/master/lib/layer.js#L20

前面架构提到的还有件事情需要做,那就是路由匹配。

对于<code>Koa</code>来说,一个请求来了会依次经过每个中间件,所以我们的路由匹配其实也是在中间件里面做的。而<code>@koa/router</code>的中间件是通过<code>router.routes()</code>返回的。所以<code>router.routes()</code>主要做两件事:

他应该返回一个<code>Koa</code>中间件,以便<code>Koa</code>调用

这个中间件的主要工作是遍历<code>router</code>上的<code>layer</code>,找到匹配的路由,并拿出来执行。

上述代码中主体返回的是一个<code>Koa</code>中间件,这个中间件里面先是通过<code>router.match</code>方法将所有匹配的<code>layer</code>拿出来,然后将这些<code>layer</code>对应的回调函数通过<code>reduce</code>放到一个数组里面,也就是<code>layerChain</code>。然后用<code>koa-compose</code>将这个数组合并成一个可执行方法,这里就有问题了。之前在<code>Koa</code>源码解析我讲过<code>koa-compose</code>的源码,这里再大致贴一下:

这段代码里面<code>fn</code>是我们传入的中间件,在<code>@koa/router</code>这里对应的其实是<code>layerChain</code>里面的一项,执行<code>fn</code>的时候是这样的:

这里传的参数符合我们使用<code>@koa/router</code>的习惯,我们使用<code>@koa/router</code>一般是这样的:

上面的<code>fn</code>就是我们传的回调函数,注意我们执行<code>fn</code>时传入的第二个参数<code>dispatch.bind(null, i + 1)</code>,也就是<code>router.get</code>这里的<code>next</code>。所以我们上面回调函数里面再执行下<code>next</code>:

这个回调里面执行<code>next()</code>其实就是把<code>koa-compose</code>里面的<code>dispatch.bind(null, i + 1)</code>拿出来执行,也就是<code>dispatch(i + 1)</code>,对应的就是执行<code>layerChain</code>里面的下一个函数。在这个例子里面并没有什么用,因为匹配的回调函数只有一个。但是如果<code>/</code>这个路径匹配了多个回调函数,比如这样:

这里<code>/</code>就匹配了两个回调函数,但是你如果这么写,你会得到一个<code>Not Found</code>。为什么呢?因为你第一个回调里面没有调用<code>next()</code>!前面说了,这里的<code>next()</code>是<code>dispatch(i + 1)</code>,会去调用<code>layerChain</code>里面的下一个回调函数,换一句话说,你这里不调<code>next()</code>就不会运行下一个回调函数了!要想让<code>/</code>返回<code>Hello World</code>,我们需要在第一个回调函数里面调用<code>next</code>,像这样:

所以有朋友觉得<code>@koa/router</code>回调函数里面的<code>next</code>没什么用,如果你一个路由只有一个匹配的回调函数,那确实没什么用,但是如果你一个路径可能匹配多个回调函数,记得调用<code>next</code>。

<code>router.routes</code>官方源码:https://github.com/koajs/router/blob/master/lib/router.js#L335

上面<code>router.routes</code>的源码里面我们用到了<code>router.match</code>这个实例方法来查找所有匹配的<code>layer</code>,上面是这么用的:

所以我们也需要写一下这个函数,这个函数不复杂,通过传入的<code>path</code>和<code>method</code>去<code>router.stack</code>上找到所有匹配的<code>layer</code>就行:

上面代码只是循环了所有的<code>layer</code>,然后将匹配的<code>layer</code>放到一个对象<code>matched</code>里面并返回给外面调用,<code>match.path</code>保存了所有<code>path</code>匹配,但是<code>method</code>并不一定匹配的<code>layer</code>,本文并没有用到这个变量。具体匹配<code>path</code>其实还是调用的<code>layer</code>的实例方法<code>layer.match</code>,我们后面会来看看。

这段代码还有个有意思的点是检测<code>layer.methods</code>里面是否包含<code>method</code>的时候,源码是这样写的:

而一般我们可能是这样写:

这个源码里面的<code>~</code>是按位取反的意思,达到的效果与我们后面这种写法其实是一样的,因为:

这种用法可以少写几个字母,又学会一招,大家具体使用的还是根据自己的情况来吧,选取喜欢的方式。

<code>router.match</code>官方源码:https://github.com/koajs/router/blob/master/lib/router.js#L669

上面用到了<code>layer.match</code>这个方法,我们也来写一下吧。因为我们在创建<code>layer</code>实例的时候,其实已经将<code>path</code>转换为了一个正则,我们直接拿来用就行:

<code>layer.match</code>官方源码:https://github.com/koajs/router/blob/master/lib/layer.js#L54

到这里,我们自己的<code>@koa/router</code>就写完了,使用他替换官方的源码也能正常工作啦~

本文可运行代码已经上传到GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter

最后我们再来总结下本文的要点吧:

<code>@koa/router</code>整体是作为一个<code>Koa</code>中间件存在的。

<code>@koa/router</code>是<code>fork</code>的<code>koa-router</code>继续进行维护。

<code>@koa/router</code>的整体思路跟<code>Express.js</code>路由模块很像。

<code>@koa/router</code>也可以分为注册路由和匹配路由两部分。

注册路由主要是构建路由的数据结构,具体来说就是创建很多<code>layer</code>,每个<code>layer</code>上保存具体的<code>path</code>,<code>methods</code>,和回调函数。

<code>@koa/router</code>创建的数据结构跟<code>Express.js</code>路由模块有区别,少了<code>route</code>这个层级,但是个人觉得<code>@koa/router</code>的这种结构反而更清晰。<code>Express.js</code>的<code>layer</code>和<code>route</code>的相互引用反而更让人疑惑。

匹配路由就是去遍历所有的<code>layer</code>,找出匹配的<code>layer</code>,将回调方法拿来执行。

一个路由可能匹配多个<code>layer</code>和回调函数,执行时使用<code>koa-compose</code>将这些匹配的回调函数串起来,一个一个执行。

需要注意的是,如果一个路由匹配了多个回调函数,前面的回调函数必须调用<code>next()</code>才能继续走到下一个回调函数。

<code>@koa/router</code>官方文档:https://github.com/koajs/router

<code>@koa/router</code>源码地址:https://github.com/koajs/router/tree/master/lib

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端进阶知识”系列文章源码GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

手写@koa/router源码

继续阅读