從零實現(xiàn)TypeScript版Koa

這篇文章會講些什么?

  • 如何從零開始完成一個涵蓋Koa核心功能的Node.js類庫
  • 從代碼層面解釋Koa一些代碼寫法的原因:如中間件為什么必須調(diào)用next函數(shù)柠衅、ctx是怎么來的和一個請求是什么關(guān)系

我們知道Koa類庫主要有以下幾個重要特性:

  • 支持洋蔥圈模型的中間件機制
  • 封裝request、response提供context對象述么,方便http操作
  • 異步函數(shù)蝌数、中間件的錯誤處理機制

第一步:基礎(chǔ)Server運行

目標:完成基礎(chǔ)可行新的Koa Server

  • 支持app.listen監(jiān)聽端口啟動Server
  • 支持app.use添加類middleware處理函數(shù)

核心代碼如下:

class Koa {
  private middleware: middlewareFn = () => {};
  constructor() {}
  listen(port: number, cb: noop) {
    const server = http.createServer((req, res) => {
      this.middleware(req, res);
    });
    return server.listen(port, cb);
  }
  use(middlewareFn: middlewareFn) {
    this.middleware = middlewareFn;
    return this;
  }
}

const app = new Koa();
app.use((req, res) => {
  res.writeHead(200);
  res.end("A request come in");
});
app.listen(3000, () => {
  console.log("Server listen on port 3000");
});

第二步:洋蔥圈中間件機制實現(xiàn)

目標:接下來我們要完善listen和use方法,實現(xiàn)洋蔥圈中間件模型

如下面代碼所示度秘,在這一步中我們希望app.use能夠支持添加多個中間件顶伞,并且中間件是按照洋蔥圈(類似深度遞歸調(diào)用)的方式順序執(zhí)行

app.use(async (req, res, next) => {
  console.log("middleware 1 start");
  // 具體原因我們會在下面代碼實現(xiàn)詳細講解
  await next();
  console.log("middleware 1 end");
});
app.use(async (req, res, next) => {
  console.log("middleware 2 start");
  await next();
  console.log("middleware 2 end");
});
app.use(async (req, res, next) => {
  res.writeHead(200);
  res.end("An request come in");
  await next();
});
app.listen(3000, () => {
  console.log("Server listen on port 3000");
});

上述Demo有三個需要我們注意的點:

  • 在中間件中next()函數(shù)必須且只能調(diào)用一次
  • 調(diào)用next函數(shù)時必須使用await

我們會在接下來的代碼中逐個分析這些使用方法的原因,下面我們來看一看具體怎么實現(xiàn)這種洋蔥圈機制:

class Koa {
  ...
  use(middlewareFn: middlewareFn) {
    // 1剑梳、調(diào)用use時唆貌,使用數(shù)組存貯所有的middleware
    this.middlewares.push(middlewareFn);
    return this;
  }
  listen(port: number, cb: noop) {
    // 2、 通過composeMiddleware將中間件數(shù)組轉(zhuǎn)換為串行[洋蔥圈]調(diào)用的函數(shù)垢乙,在createServer中回調(diào)函數(shù)中調(diào)用
    // 所以真正的重點就是 composeMiddleware锨咙,如果做到的,我們接下來看該函數(shù)的實現(xiàn)
    // BTW: 從這里可以看到 fn 是在listen函數(shù)被調(diào)用之后就生成了追逮,這就意味著我們不能在運行時動態(tài)的添加middleware
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      await fn(req, res);
    });
    return server.listen(port, cb);
  }
}

// 3酪刀、洋蔥圈模型的核心:
// 入?yún)ⅲ核惺占闹虚g件
// 返回:串行調(diào)用中間件數(shù)組的函數(shù)
function composeMiddleware(middlewares: middlewareFn[]) {
  return (req: IncomingMessage, res: ServerResponse) => {
    let start = -1;
    // dispatch:觸發(fā)第i個中間件執(zhí)行
    function dispatch(i: number) {
      // 剛開始可能不理解這里為什么這么判斷,可以看完整個函數(shù)在來思考這個問題
      // 正常情況下每次調(diào)用前 start < i钮孵,調(diào)用完next() 應(yīng)該 start === i
      // 如果調(diào)用多次next()骂倘,第二次及以后調(diào)用因為之前已完成start === i賦值,所以會導(dǎo)致 start >= i
      if (i <= start) {
        return Promise.reject(new Error("next() call more than once!"));
      }
      if (i >= middlewares.length) {
        return Promise.resolve();
      }
      start = i;
      const middleware = middlewares[i];
      // 重點來了0拖@浴!
      // 取出第i個中間件執(zhí)行,并將dispatch(i+1)作為next函數(shù)傳給各下一個中間件
      return middleware(req, res, () => {
        return dispatch(i + 1);
      });
    }
    return dispatch(0);
  };
}

主要涉及到Promise幾個知識點:

  • async 函數(shù)返回的是一個Promise對象【所以的中間件都會返回一個promise對象】
  • async 函數(shù)內(nèi)部遇到 await 調(diào)用時會暫停執(zhí)行await函數(shù)荧库,等待返回結(jié)果后繼續(xù)向下執(zhí)行
  • async 函數(shù)內(nèi)部發(fā)生錯誤會導(dǎo)致返回的Promise變?yōu)閞eject狀態(tài)

現(xiàn)在我們在回顧之前提出的幾個問題:

  1. koa中間件中為什么必須且只能調(diào)用一次next函數(shù)

     可以看到如果不調(diào)用next堰塌,就不會觸發(fā)dispatch(i+1),下一個中間件就沒辦法觸發(fā)电爹,造成假死狀態(tài)最終請求超時
     
     調(diào)用多次next則會導(dǎo)致下一個中間件執(zhí)行多次
    
  2. next() 調(diào)用為什么需要加 await

     這也是洋蔥圈調(diào)用機制的核心蔫仙,當執(zhí)行到 await next(),會執(zhí)行next()【調(diào)用下一個中間件】等待返回結(jié)果丐箩,在接著向下執(zhí)行
    

第三步:Context提供

目標:封裝Context摇邦,提供request、response的便捷操作方式

// 1屎勘、 定義KoaRequest施籍、KoaResponse、KoaContext
interface KoaContext {
  request?: KoaRequest;
  response?: KoaResponse;
  body: String | null;
}
const context: KoaContext = {
  get body() {
    return this.response!.body;
  },
  set body(body) {
    this.response!.body = body;
  }
};

function composeMiddleware(middlewares: middlewareFn[]) {
  return (context: KoaContext) => {
    let start = -1;
    function dispatch(i: number) {
      // ..省略其他代碼..
      // 2概漱、所有的中間件接受context參數(shù)
      middleware(context, () => {
        return dispatch(i + 1);
      });
    }
    return dispatch(0);
  };
}

class Koa {
  private context: KoaContext = Object.create(context);
  listen(port: number, cb: noop) {
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      // 3丑慎、利用req、res創(chuàng)建context對象
      // 這里需要注意:context是創(chuàng)建一個新的對象瓤摧,而不是直接賦值給this.context
      // 因為context適合請求相關(guān)聯(lián)的竿裂,這里也保證了每一個請求都是一個新的context對象
      const context = this.createContext(req, res);
      await fn(context);
      if (context.response && context.response.res) {
        context.response.res.writeHead(200);
        context.response.res.end(context.body);
      }
    });
    return server.listen(port, cb);
  }
  // 4、創(chuàng)建context對象
  createContext(req: IncomingMessage, res: ServerResponse): KoaContext {
    // 為什么要使用Object.create而不是直接賦值照弥?
    // 原因同上需要保證每一次請求request腻异、response、context都是全新的
    const request = Object.create(this.request);
    const response = Object.create(this.response);
    const context = Object.create(this.context);
    request.req = req;
    response.res = res;
    context.request = request;
    context.response = response;
    return context;
  }
}

第四步:異步函數(shù)錯誤處理機制

目標:支持通過 app.on("error")这揣,監(jiān)聽錯誤事件處理異常

我們回憶下在Koa中如何處理異常悔常,代碼可能類似如下:

app.use(async (context, next) => {
  console.log("middleware 2 start");
  // throw new Error("出錯了");
  await next();
  console.log("middleware 2 end");
});

// koa統(tǒng)一錯誤處理:監(jiān)聽error事件
app.on("error", (error, context) => {
  console.error(`請求${context.url}發(fā)生了錯誤`);
});

從上面的代碼可以看到核心在于:

  • Koa實例app需要支持事件觸發(fā)、事件監(jiān)聽能力
  • 需要我們捕獲異步函數(shù)異常给赞,并觸發(fā)error事件

下面我們看具體代碼如何實現(xiàn):

// 1机打、繼承EventEmitter,增加事件觸發(fā)片迅、監(jiān)聽能力
class Koa extends EventEmitter {
  listen(port: number, cb: noop) {
    const fn = composeMiddleware(this.middlewares);
    const server = http.createServer(async (req, res) => {
      const context = this.createContext(req, res);
      // 2残邀、await調(diào)用fn,可以使用try catch捕獲異常障涯,觸發(fā)異常事件
      try {
        await fn(context);
        if (context.response && context.response.res) {
          context.response.res.writeHead(200);
          context.response.res.end(context.body);
        }
      } catch (error) {
        console.error("Server Error");
        // 3罐旗、觸發(fā)error時提供context更多信息,方面日志記錄唯蝶,定位問題
        this.emit("error", error, context);
      }
    });
    return server.listen(port, cb);
  }
}

總結(jié)

至此我們已經(jīng)使用TypeScript完成簡版Koa類庫九秀,支持了

  • 洋蔥圈中間件機制
  • Context封裝request、response
  • 異步異常錯誤處理機制

完整Demo代碼可以參考koa2-reference

更多精彩文章粘我,歡迎大家Star我們的倉庫鼓蜒,我們每周都會推出幾篇高質(zhì)量的大前端領(lǐng)域相關(guān)文章痹换。

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市都弹,隨后出現(xiàn)的幾起案子娇豫,更是在濱河造成了極大的恐慌,老刑警劉巖畅厢,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件冯痢,死亡現(xiàn)場離奇詭異,居然都是意外死亡框杜,警方通過查閱死者的電腦和手機浦楣,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來咪辱,“玉大人振劳,你說我怎么就攤上這事∮涂瘢” “怎么了历恐?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長专筷。 經(jīng)常有香客問我弱贼,道長,這世上最難降的妖魔是什么磷蛹? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任哮洽,我火速辦了婚禮,結(jié)果婚禮上弦聂,老公的妹妹穿的比我還像新娘。我一直安慰自己氛什,他們只是感情好莺葫,可當我...
    茶點故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著枪眉,像睡著了一般捺檬。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上贸铜,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天堡纬,我揣著相機與錄音,去河邊找鬼蒿秦。 笑死烤镐,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的棍鳖。 我是一名探鬼主播炮叶,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼碗旅,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了镜悉?” 一聲冷哼從身側(cè)響起祟辟,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎侣肄,沒想到半個月后旧困,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡稼锅,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年吼具,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片缰贝。...
    茶點故事閱讀 38,039評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡馍悟,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出剩晴,到底是詐尸還是另有隱情锣咒,我是刑警寧澤,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布赞弥,位于F島的核電站毅整,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏绽左。R本人自食惡果不足惜悼嫉,卻給世界環(huán)境...
    茶點故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望拼窥。 院中可真熱鬧戏蔑,春花似錦、人聲如沸鲁纠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽改含。三九已至情龄,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間捍壤,已是汗流浹背骤视。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留鹃觉,地道東北人专酗。 一個月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像盗扇,于是被迫代替她去往敵國和親笼裳。 傳聞我的和親對象是個殘疾皇子唯卖,可洞房花燭夜當晚...
    茶點故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 本節(jié)將結(jié)合例子和源碼對koa2的中間件機制做一介紹。 什么是中間件躬柬? 中間件的本質(zhì)就是一種在特定場景下使用的函數(shù)拜轨,...
    空無一碼閱讀 1,430評論 0 2
  • 參考資料 https://chenshenhai.github.io/koa2-note/note/static/...
    JunChow520閱讀 10,478評論 1 8
  • 弄懂js異步 講異步之前,我們必須掌握一個基礎(chǔ)知識-event-loop允青。 我們知道JavaScript的一大特點...
    DCbryant閱讀 2,697評論 0 5
  • 看到標題橄碾,也許您會覺得奇怪,redux跟Koa以及Express并不是同一類別的框架颠锉,干嘛要拿來做類比法牲。盡管,例如...
    Perkin_閱讀 1,715評論 0 4
  • 陸陸續(xù)續(xù)用了koa和co也算差不多用了大半年了琼掠,大部分的場景都是在服務(wù)端使用koa來作為restful服務(wù)器用拒垃,使...
    Sunil閱讀 1,528評論 0 3