关于前端:手写koarouter源码

39次阅读

共计 11309 个字符,预计需要花费 29 分钟才能阅读完成。

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

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

简略例子

咱们这里的例子还是应用之前 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构造函数官网源码:https://github.com/koajs/router/blob/master/lib/router.js#L50

申请动词函数

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

先来看看注册路由,注册路由次要是在申请动词函数外面进行的,比方 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() 去注册路由的,上面咱们看看他是怎么写的。

申请动词函数官网源码: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 源码解析中也提出过。

另外咱们看到他也用到了 path-to-regexp 这个库,这个库我在很多解决路由的库外面都见到过,比方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

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢送关注~

正文完
 0