乐趣区

关于前端:手写Koajs源码

Node.js 写一个web 服务器,我后面曾经写过两篇文章了:

  • 第一篇是不应用任何框架也能搭建一个 web 服务器,次要是相熟Node.js 原生 API 的应用:应用 Node.js 原生 API 写一个 web 服务器
  • 第二篇文章是看了 Express 的根本用法,更次要的是看了下他的源码:手写 Express.js 源码

Express的源码还是比较复杂的,自带了路由解决和动态资源反对等等性能,性能比拟全面。与之相比,本文要讲的 Koa 就简洁多了,Koa尽管是 Express 的原班人马写的,然而设计思路却不一样。Express更多是偏差 All in one 的思维,各种性能都集成在一起,而 Koa 自身的库只有一个中间件内核,其余像路由解决和动态资源这些性能都没有,全副须要引入第三方中间件库能力实现。上面这张图能够直观的看到 Expresskoa在性能上的区别,此图来自于官网文档:

基于 Koa 的这种架构,我打算会分几篇文章来写,全部都是源码解析:

  • Koa的外围架构会写一篇文章,也就是本文。
  • 对于一个 web 服务器 来说,路由是必不可少的,所以 @koa/router 会写一篇文章。
  • 另外可能会写一些罕用中间件,动态文件反对或者 bodyparser 等等,具体还没定,可能会有一篇或多篇文章。

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

简略示例

我写源码解析,个别都遵循一个简略的套路:先引入库,写一个简略的例子,而后本人手写源码来代替这个库,并让咱们的例子顺利运行。本文也是遵循这个套路,因为 Koa 的外围库只有中间件,所以咱们写出的例子也比较简单,也只有中间件。

Hello World

第一个例子是Hello World,轻易申请一个门路都返回Hello World

const Koa = require("koa");
const app = new Koa();

app.use((ctx) => {ctx.body = "Hello World";});

const port = 3001;
app.listen(port, () => {console.log(`Server is running on http://127.0.0.1:${port}/`);
});

logger

而后再来一个 logger 吧,就是记录下解决以后申请花了多长时间:

app.use(async (ctx, next) => {const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

留神这个中间件应该放到 Hello World 的后面。

从下面两个例子的代码来看,KoaExpress 有几个显著的区别:

  • ctx代替了 reqres
  • 能够应用 JS 的新 API 了,比方 asyncawait

手写源码

手写源码前咱们看看用到了哪些 API,这些就是咱们手写的指标:

  • new Koa():首先必定是 Koa 这个类了,因为他应用 new 进行实例化,所以咱们认为他是一个类。
  • app.useappKoa 的一个实例,app.use看起来是一个增加中间件的实例办法。
  • app.listen:启动服务器的实例办法
  • ctx:这个是 Koa 的上下文,看起来代替了以前的 reqres
  • asyncawait:反对新的语法,而且能应用await next(),阐明next() 返回的很可能是一个promise

本文的手写源码全副参照官网源码写成,文件名和函数名尽量保持一致,写到具体的办法时我也会贴上官网源码地址。Koa这个库代码并不多,次要都在这个文件夹外面:https://github.com/koajs/koa/tree/master/lib,上面咱们开始吧。

Koa 类

Koa 我的项目的 package.json 外面的 main 这行代码能够看出,整个利用的入口是 lib/application.js 这个文件:

"main": "lib/application.js",

lib/application.js这个文件就是咱们常常用的 Koa 类,尽管咱们常常叫他 Koa 类,然而在源码外面这个类叫做Application。咱们先来写一下这个类的壳吧:

// application.js

const Emitter = require("events");

// module.exports 间接导出 Application 类
module.exports = class Application extends Emitter {
  // 构造函数先运行下父类的构造函数
  // 再进行一些初始化工作
  constructor() {super();

    // middleware 实例属性初始化为一个空数组,用来存储后续可能的中间件
    this.middleware = [];}
};

这段代码咱们能够看出,Koa间接应用 class 关键字来申明类了,看过我之前 Express 源码解析的敌人可能还有印象,Express源码外面还是应用的老的 prototype 来实现面向对象的。所以 Koa 我的项目介绍外面的 Expressive middleware for node.js using ES2017 async functions 并不是一句虚言,它不仅反对 ES2017 新的 API,而且在本人的源码外面外面也是用的新 API。我想这也是 Koa 要求运行环境必须是 node v7.6.0 or higher 的起因吧。所以到这里咱们其实曾经能够看出 KoaExpress的一个重大区别了,那就是:Express应用老的 API,兼容性更强,能够在老的 Node.js 版本上运行;Koa因为应用了新 API,只能在 v7.6.0 或者更高版本上运行了。

这段代码还有个点须要留神,那就是 Application 继承自 Node.js 原生的 EventEmitter 类,这个类其实就是一个公布订阅模式,能够订阅和公布音讯,我在另一篇文章外面具体讲过他的源码。所以他有些办法如果在 application.js 外面找不到,那可能就是继承自EventEmitter,比方下图这行代码:

这里有 this.on 这个办法,看起来他应该是 Application 的一个实例办法,然而这个文件外面没有,其实他就是继承自 EventEmitter,是用来给error 这个事件增加回调函数的。这行代码 if 外面的 this.listenerCount 也是 EventEmitter 的一个实例办法。

Application类齐全是 JS 面向对象的使用,如果你对 JS 面向对象还不是很相熟,能够先看看这篇文章:https://segmentfault.com/a/1190000023201844。

app.use

从咱们后面的应用示例能够看出 app.use 的作用就是增加一个中间件,咱们在构造函数外面也初始化了一个变量 middleware,用来存储中间件,所以app.use 的代码就很简略了,将接管到的中间件塞到这个数组就行:

use(fn) {
  // 中间件必须是一个函数,不然就报错
  if (typeof fn !== "function")
    throw new TypeError("middleware must be a function!");

  // 解决逻辑很简略,将接管到的中间件塞入到 middleware 数组就行
  this.middleware.push(fn);
  return this;
}

留神 app.use 办法最初返回了 this,这个有点意思,为什么要返回this 呢?这个其实我之前在其余文章讲过的:类的实例办法返回 this 能够实现链式调用。比方这里的 app.use 就能够间断点点点了,像这样:

app.use(middlewaer1).use(middlewaer2).use(middlewaer3)

为什么会有这种成果呢?因为这里的 this 其实就是以后实例,也就是 app,所以app.use() 的返回值就是 appapp 上有个实例办法use,所以能够持续点app.use().use()

app.use的官网源码看这里: https://github.com/koajs/koa/blob/master/lib/application.js#L122

app.listen

在后面的示例中,app.listen的作用是用来启动服务器,看过后面用原生 API 实现 web 服务器 的敌人都晓得,要启动服务器须要调用原生的 http.createServer,所以这个办法就是用来调用http.createServer 的。

listen(...args) {const server = http.createServer(this.callback());
  return server.listen(...args);
}

这个办法自身其实没有太多可说的,只是调用 http 模块启动服务而已,次要的逻辑都在 this.callback() 外面了。

app.listen的官网源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L79

app.callback

this.callback()是传给 http.createServer 的回调函数,也是一个实例函数,这个函数必须合乎 http.createServer 的参数模式,也就是

http.createServer(function(req, res){})

所以 this.callback() 的返回值必须是一个函数,而且是这种模式function(req, res){}

除了模式必须合乎外,this.callback()具体要干什么呢?他是 http 模块的回调函数,所以他必须解决所有的网络申请,所有解决逻辑都必须在这个办法外面。然而 Koa 的解决逻辑是以中间件的模式存在的,对于一个申请来说,他必须一个一个的穿过所有的中间件,具体穿过的逻辑,你当然能够遍历 middleware 这个数组,将外面的办法一个一个拿进去解决,当然也能够用业界更罕用的办法:compose

compose一般来说就是将一系列办法合并成一个办法来不便调用,具体实现的模式并不是固定的,有面试中常见的用 reduce 实现的 compose,也有像Koa 这样依据本人需要独自实现的 composeKoacompose也独自封装了一个库 koa-compose,这个库源码也是咱们必须要看的,咱们一步一步来,先把this.callback 写进去吧。

callback() {
  // compose 来自 koa-compose 库,就是将中间件合并成一个函数
  // 咱们须要本人实现
  const fn = compose(this.middleware);

  // callback 返回值必须合乎 http.createServer 参数模式
  // 即 (req, res) => {}
  const handleRequest = (req, res) => {const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };

  return handleRequest;
}

这个办法先用 koa-compose 将中间件都合成了一个函数 fn,而后在http.createServer 的回调外面应用 reqres创立了一个 Koa 罕用的上下文 ctx,而后再调用this.handleRequest 来真正解决网络申请。留神这里的 this.handleRequest 是个实例办法,和以后办法外面的局部变量 handleRequest 并不是一个货色。这几个办法咱们一个一个来看下。

this.callback对应的官网源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L143

koa-compose

koa-compose尽管被作为了一个独自的库,然而他的作用却很要害,所以咱们也来看看他的源码吧。koa-compose的作用是将一个中间件组成的数组合并成一个办法以便内部调用。咱们先来回顾下一个 Koa 中间件的构造:

function middleware(ctx, next) {}

这个数组就是有很多这样的中间件:

[function middleware1(ctx, next) {},
  function middleware2(ctx, next) {}]

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);
      }
    }
  };
}

下面代码次要的逻辑就是这行:

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

这里的 fn 就是咱们本人写的中间件,比方文章开始那个logger,咱们略微改下看得更分明:

const logger = async (ctx, next) => {const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
};

app.use(logger);

那咱们 compose 外面执行的其实是:

logger(context, dispatch.bind(null, i + 1));

也就是说 logger 接管到的 next 其实是 dispatch.bind(null, i + 1),你调用next() 的时候,其实调用的是dispatch(i + 1),这样就达到了执行数组下一个中间件的成果。

另外因为中间件在返回前还包裹了一层 Promise.resolve,所以咱们所有本人写的中间件,无论你是否用了Promisenext 调用后返回的都是一个Promise,所以你能够应用await next()

koa-compose的源码看这里:https://github.com/koajs/compose/blob/master/index.js

app.createContext

下面用到的 this.createContext 也是一个实例办法。这个办法依据 http.createServer 传入的 reqres来构建 ctx 这个上下文,官网源码长这样:

这段代码外面 contextctxresponseresrequestreqapp 这几个变量互相赋值,头都看晕了。其实齐全没必要陷入这堆面条外面去,咱们只须要将他的思路和骨架拎分明就行,那怎么来拎呢?

  1. 首先搞清楚他这么赋值的目标,他的目标其实很简略,就是为了使用方便。通过一个变量能够很不便的拿到其余变量,比方我当初只有 request,然而我想要的是req,怎么办呢?通过这种赋值后,间接用request.req 就行。其余的相似,这种面条式的赋值我很难说好还是不好,然而应用时的确很不便,毛病就是看源码时容易陷进去。
  2. requestreq有啥区别?这两个变量长得这么像,到底是干啥的?这就要说到 Koa 对于原生 req 的扩大,咱们晓得 http.createServer 的回调外面会传入 req 作为申请对象的形容,外面能够拿到申请的 header 啊,method啊这些变量。然而 Koa 感觉这个 req 提供的 API 不好用,所以他在这个根底上扩大了一些 API,其实就是一些语法糖,扩大后的 req 就变成了 request。之所以扩大后还保留的原始的req,应该也是想为用户提供更多抉择吧。 所以这两个变量的区别就是 requestKoa包装过的 reqreq 是原生的申请对象。responseres 也是相似的。
  3. 既然 requestresponse都只是包装过的语法糖,那其实 Koa 没有这两个变量也能跑起来。所以咱们拎骨架的时候齐全能够将这两个变量踢出去,这下骨架就清晰了。

那咱们踢出 responserequest后再来写下 createContext 这个办法:

// 创立上下文 ctx 对象的函数
createContext(req, res) {const context = Object.create(this.context);
  context.app = this;
  context.req = req;
  context.res = res;

  return context;
}

这下整个世界感觉都清新了,context上的货色也高深莫测了。然而咱们的 context 最后是来自 this.context 的,这个变量还必须看下。

app.createContext对应的官网源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L177

context.js

下面的 this.context 其实就是来自 context.js,所以咱们先在Application 构造函数外面增加这个变量:

// application.js

const context = require("./context");

// 构造函数外面
constructor() {
    // 省略其余代码
  this.context = context;
}

而后再来看看 context.js 外面有啥,context.js的构造大略是这个样子:

const delegate = require("delegates");

module.exports = {inspect() {},
  toJSON() {},
  throw() {},
  onerror() {},
};

const proto = module.exports;

delegate(proto, "response")
  .method("set")
  .method("append")
  .access("message")
  .access("body");

delegate(proto, "request")
  .method("acceptsLanguages")
  .method("accepts")
  .access("querystring")
  .access("socket");

这段代码外面 context 导出的是一个对象 proto,这个对象自身有一些办法,inspecttoJSON 之类的。而后还有一堆 delegate().method()delegate().access() 之类的。嗯,这个是干啥的呢?要晓得这个的作用,咱们须要去看 delegates 这个库:https://github.com/tj/node-delegates,这个库也是 tj 大神写的。个别应用是这样的:

delegate(proto, target).method("set");

这行代码的作用是,当你调用 proto.set() 办法时,其实是转发给了 proto[target],理论调用的是proto[target].set()。所以就是proto 代理了对 target 的拜访。

那用在咱们 context.js 外面是啥意思呢?比方这行代码:

delegate(proto, "response")
  .method("set");

这行代码的作用是,当你调用 proto.set() 时,理论去调用 proto.response.set(),将proto 换成 ctx 就是:当你调用 ctx.set() 时,理论调用的是 ctx.response.set()。这么做的目标其实也是为了使用方便,能够少写一个response。而且ctx 不仅仅代理 response,还代理了request,所以你还能够通过ctx.accepts() 这样来调用到 ctx.request.accepts()。一个ctx 就囊括了 responserequest,所以这里的 context 也是一个语法糖。因为咱们后面曾经踢了 responserequest这两个语法糖,context作为包装了这两个语法糖的语法糖,咱们也一起踢掉吧。在 Application 的构造函数外面间接将 this.context 赋值为空对象:

// application.js
constructor() {
    // 省略其余代码
  this.context = {};}

当初语法糖都踢掉了,整个 Koa 的构造就更清晰了,ctx下面也只有几个必须的变量:

ctx = {
  app,
  req,
  res
}

context.js对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/context.js

app.handleRequest

当初咱们 ctxfn都结构好了,那咱们解决申请其实就是调用 fnctx 是作为参数传给他的,所以 app.handleRequest 代码就能够写进去了:

// 解决具体申请
handleRequest(ctx, fnMiddleware) {const handleResponse = () => respond(ctx);

  // 调用中间件解决
  // 所有解决完后就调用 handleResponse 返回申请
  return fnMiddleware(ctx)
    .then(handleResponse)
    .catch((err) => {console.log("Somethis is wrong:", err);
  });
}

咱们看到 compose 库返回的 fn 尽管反对第二个参数用来收尾,然而 Koa 并没有用他,如果不传的话,所有中间件执行完返回的就是一个空的 promise,所以能够用then 接着他前面解决。前面要进行的解决就只有一个了,就是将处理结果返回给请求者的,这也就是 respond 须要做的。

app.handleRequest对应的源码看这里:https://github.com/koajs/koa/blob/master/lib/application.js#L162

respond

respond是一个辅助办法,并不在 Application 类外面,他要做的就是将网络申请返回:

function respond(ctx) {
  const res = ctx.res; // 取出 res 对象
  const body = ctx.body; // 取出 body

  return res.end(body); // 用 res 返回 body
}

功败垂成

当初咱们能够用本人写的 Koa 替换官网的 Koa 来运行咱们结尾的例子了,不过 logger 这个中间件运行的时候会有点问题,因为他上面这行代码用到了语法糖:

console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);

这里的 ctx.methodctx.url在咱们构建的 ctx 上并不存在,不过没关系,他不就是个 req 的语法糖嘛,咱们从 ctx.req 上拿就行,所以下面这行代码改为:

console.log(`${ctx.req.method} ${ctx.req.url} - ${ms}ms`);

总结

通过一层一层的抽丝剥茧,咱们胜利拎出了 Koa 的代码骨架,本人写了一个迷你版的Koa

这个迷你版代码曾经上传 GitHub,大家能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaCore

最初咱们再来总结下本文的要点吧:

  1. KoaExpress 原班人马写的一个新框架。
  2. Koa应用了 JS 的新 API,比方 asyncawait
  3. Koa的架构和 Express 有很大区别。
  4. Express的思路是大而全,内置了很多性能,比方路由,动态资源等,而且 Express 的中间件也是应用路由同样的机制实现的,整个代码更简单。Express源码能够看我之前这篇文章:手写 Express.js 源码
  5. Koa的思路看起来更清晰,Koa自身的库只是一个内核,只有中间件性能,来的申请会顺次通过每一个中间件,而后再进去返回给请求者,这就是大家常常据说的“洋葱模型”。
  6. 想要 Koa 反对其余性能,必须手动增加中间件。作为一个 web 服务器,路由能够算是基本功能了,所以下一遍文章咱们会来看看Koa 官网的路由库@koa/router,敬请关注。

参考资料

Koa 官网文档:https://github.com/koajs/koa

Koa 源码地址:https://github.com/koajs/koa/tree/master/lib

文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和 GitHub 小星星,你的反对是作者继续创作的能源。

作者博文 GitHub 我的项目地址:https://github.com/dennis-jiang/Front-End-Knowledges

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

退出移动版