天天看點

手寫Koa.js源碼

Node.js

寫一個

web伺服器

,我前面已經寫過兩篇文章了:

  • 第一篇是不使用任何架構也能搭建一個

    web伺服器

    ,主要是熟悉

    Node.js

    原生API的使用:使用Node.js原生API寫一個web伺服器
  • 第二篇文章是看了

    Express

    的基本用法,更主要的是看了下他的源碼:手寫Express.js源碼

Express

的源碼還是比較複雜的,自帶了路由處理和靜态資源支援等等功能,功能比較全面。與之相比,本文要講的

Koa

就簡潔多了,

Koa

雖然是

Express

的原班人馬寫的,但是設計思路卻不一樣。

Express

更多是偏向

All in one

的思想,各種功能都內建在一起,而

Koa

本身的庫隻有一個中間件核心,其他像路由處理和靜态資源這些功能都沒有,全部需要引入第三方中間件庫才能實作。下面這張圖可以直覺的看到

Express

koa

在功能上的差別,此圖來自于官方文檔:

手寫Koa.js源碼

基于

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

的前面。

從上面兩個例子的代碼來看,

Koa

Express

有幾個明顯的差別:

  • ctx

    替代了

    req

    res

  • 可以使用JS的新API了,比如

    async

    await

手寫源碼

手寫源碼前我們看看用到了哪些API,這些就是我們手寫的目标:

  • new Koa():首先肯定是

    Koa

    這個類了,因為他使用

    new

    進行執行個體化,是以我們認為他是一個類。
  • app.use:app是

    Koa

    的一個執行個體,

    app.use

    看起來是一個添加中間件的執行個體方法。
  • app.listen:啟動伺服器的執行個體方法
  • ctx:這個是

    Koa

    的上下文,看起來替代了以前的

    req

    res

  • async和await:支援新的文法,而且能使用

    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

的原因吧。是以到這裡我們其實已經可以看出

Koa

Express

的一個重大差別了,那就是:

Express

使用老的API,相容性更強,可以在老的

Node.js

版本上運作;

Koa

因為使用了新API,隻能在

v7.6.0

或者更高版本上運作了。

這段代碼還有個點需要注意,那就是

Application

繼承自

Node.js

原生的

EventEmitter

類,這個類其實就是一個釋出訂閱模式,可以訂閱和釋出消息,我在另一篇文章裡面詳細講過他的源碼。是以他有些方法如果在

application.js

裡面找不到,那可能就是繼承自

EventEmitter

,比如下圖這行代碼:

手寫Koa.js源碼

這裡有

this.on

這個方法,看起來他應該是

Application

的一個執行個體方法,但是這個檔案裡面沒有,其實他就是繼承自

EventEmitter

,是用來給

error

這個事件添加回調函數的。這行代碼

if

this.listenerCount

也是

EventEmitter

的一個執行個體方法。

Application

類完全是JS面向對象的運用,如果你對JS面向對象還不是很熟悉,可以先看看這篇文章:https://juejin.im/post/6844904069887164423。

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()

的傳回值就是

app

app

上有個執行個體方法

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

這樣根據自己需求單獨實作的

compose

Koa

compose

也單獨封裝了一個庫

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

的回調裡面使用

req

res

建立了一個

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

,是以我們所有自己寫的中間件,無論你是否用了

Promise

next

調用後傳回的都是一個

Promise

,是以你可以使用

await next()

koa-compose

的源碼看這裡:https://github.com/koajs/compose/blob/master/index.js

app.createContext

上面用到的

this.createContext

也是一個執行個體方法。這個方法根據

http.createServer

傳入的

req

res

來建構

ctx

這個上下文,官方源碼長這樣:

手寫Koa.js源碼

這段代碼裡面

context

ctx

response

res

request

req

app

這幾個變量互相指派,頭都看暈了。其實完全沒必要陷入這堆面條裡面去,我們隻需要将他的思路和骨架拎清楚就行,那怎麼來拎呢?

  1. 首先搞清楚他這麼指派的目的,他的目的其實很簡單,就是為了使用友善。通過一個變量可以很友善的拿到其他變量,比如我現在隻有

    request

    ,但是我想要的是

    req

    ,怎麼辦呢?通過這種指派後,直接用

    request.req

    就行。其他的類似,這種面條式的指派我很難說好還是不好,但是使用時确實很友善,缺點就是看源碼時容易陷進去。
  2. request

    req

    有啥差別?這兩個變量長得這麼像,到底是幹啥的?這就要說到

    Koa

    對于原生

    req

    的擴充,我們知道

    http.createServer

    的回調裡面會傳入

    req

    作為請求對象的描述,裡面可以拿到請求的

    header

    啊,

    method

    啊這些變量。但是

    Koa

    覺得這個

    req

    提供的API不好用,是以他在這個基礎上擴充了一些API,其實就是一些文法糖,擴充後的

    req

    就變成了

    request

    。之是以擴充後還保留的原始的

    req

    ,應該也是想為使用者提供更多選擇吧。是以這兩個變量的差別就是

    request

    Koa

    包裝過的

    req

    req

    是原生的請求對象。

    response

    res

    也是類似的。
  3. 既然

    request

    response

    都隻是包裝過的文法糖,那其實

    Koa

    沒有這兩個變量也能跑起來。是以我們拎骨架的時候完全可以将這兩個變量踢出去,這下骨架就清晰了。

那我們踢出

response

request

後再來寫下

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

,這個對象本身有一些方法,

inspect

toJSON

之類的。然後還有一堆

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

就囊括了

response

request

,是以這裡的

context

也是一個文法糖。因為我們前面已經踢了

response

request

這兩個文法糖,

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

現在我們

ctx

fn

都構造好了,那我們處理請求其實就是調用

fn

ctx

是作為參數傳給他的,是以

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.method

ctx.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. Koa

    Express

    原班人馬寫的一個新架構。
  2. Koa

    使用了JS的新API,比如

    async

    await

  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小星星,你的支援是作者持續創作的動力。

歡迎關注我的公衆号進擊的大前端第一時間擷取高品質原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章源碼GitHub位址: https://github.com/dennis-jiang/Front-End-Knowledges

手寫Koa.js源碼

繼續閱讀