手写@koa/router源码

1,863

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

本文可运行代码已经上传GitHun,拿下来一边玩代码,一边看文章效果更佳:github.com/dennis-jian…

简单例子

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

  1. 访问跟路由返回Hello World
  2. get /api/users返回一个用户列表,数据是随便造的
  3. post /api/users写入一个用户信息,用一个文件来模拟数据库

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

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是我们需要解决的:

  1. Router类:我们从@koa/router引入的就是这个类,通过new关键字生成一个实例router,后续使用的方法都挂载在这个实例下面。
  2. router.getrouter.postrouter的实例方法getpost是我们定义路由的方法。
  3. router.routes:这个实例方法的返回值是作为中间件传给app.use的,所以这个方法很可能是生成具体的中间件给Koa调用。

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

先看看路由架构

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

  1. get /api/users
  2. 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的面向对象还不熟悉,可以先看看这篇文章。

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构造函数官方源码:github.com/koajs/route…

请求动词函数

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

先来看看注册路由,注册路由主要是在请求动词函数里面进行的,比如router.getrouter.post这种函数。HTTP动词有很多,有一个库专门维护了这些动词:methods@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()去注册路由的,下面我们看看他是怎么写的。

请求动词函数官方源码:github.com/koajs/route…

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官方源码:github.com/koajs/route…

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源码解析中也提出过

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

Layer构造函数官方源码:github.com/koajs/route…

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官方源码:github.com/koajs/route…

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官方源码:github.com/koajs/route…

layer.match()

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

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

layer.match官方源码:github.com/koajs/route…

总结

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

本文可运行代码已经上传到GitHub,大家可以拿下来玩玩:github.com/dennis-jian…

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

  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官方文档:github.com/koajs/route…

@koa/router源码地址:github.com/koajs/route…

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

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

“前端进阶知识”系列文章:juejin.cn/post/684490…

“前端进阶知识”系列文章源码GitHub地址: github.com/dennis-jian…