參考KOA,5步手寫一款粗糙的web框架

小美娜娜發表於2018-08-23

我經常在網上看到類似於KOA VS express的文章,大家都在討論哪一個好,哪一個更好。作為小白,我真心看不出他兩who更勝一籌。我只知道,我只會跟著官方文件的start做一個DEMO,然後我就會宣稱我會用KOA或者express框架了。但是幾個禮拜後,我就全忘了。web框架就相當於一個工具,要使用起來,那是分分鐘的事。畢竟人家寫這個框架就是為了方便大家上手使用。但是這種生硬的照搬模式,不適合我這種理解能力極差的使用者。因此我決定扒一扒原始碼,通過官方API,自己寫一個web框架,其實就相當於“抄”一遍原始碼,加上自己的理解,從而加深影響。不僅需要知其然,還要需要知其所以然。

我這裡選擇KOA作為參考範本,只有一個原因!他非常的精簡!核心只有4個js檔案!基本上就是對createServer的一個封裝。

在開始解刨KOA之前,createServer的用法還是需要回顧下的:

const http = require('http');
let app=http.createServer((req, res) => {
    //此處省略其他操作
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.body="我是createServer";
    res.end('okay');
});
app.listen(3000)
複製程式碼

回顧了createServer,接下來就是解刨KOA的那4個檔案了:

  • application.js
    • 這個js主要就是對createServer的封裝,其中一個最主要的目的就是將他的callback分離出來,讓我們可以通過app.use(callback);來呼叫,其中callback大概就是令大家聞風喪膽的中介軟體(middleWare)了。
  • request.js
    • 封裝createServer中返回的req,主要用於讀寫屬性。
  • response.js
    • 封裝createServer中返回的res,主要用於讀寫屬性。
  • context.js
    • 這個檔案就很重要了,它主要是封裝了request和response,用於框架和中介軟體的溝通。所以他叫上下文,也是有道理的。

好了~開始寫框架咯~

僅分析大概思路,分析KOA的原理,所以並不是100%重現KOA。

本文github地址:點我

step1 封裝http.createServer

先寫一個初始版的application,讓程式先跑起來。這裡我們僅僅實現:

  • 封裝http.createServer到myhttp的類
  • 將回撥獨立出來
  • listen方法可以直接用

step1/application.js

let http=require("http")
class myhttp{
    handleRequest(req,res){
        console.log(req,res)
    }
    listen(...args){
        // 起一個服務
        let server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args)
    }
}
複製程式碼

這邊的listen完全和server.listen的用法一摸一樣,就是傳遞了下引數

友情連結

server.listen的API

ES6解構賦值...

step1/testhttp.js

let myhttp=require("./application")
let app= new myhttp()
app.listen(3000)
複製程式碼

執行testhttp.js,結果列印出了reqres就成功了~

step2 封裝原生req和res

這裡我們需要做的封裝,所需只有兩步:

  • 讀取(get)req和res的內容
  • 修改(set)res的內容

step2/request.js

let request={
    get url(){
        return this.req.url
    }
}
module.exports=request
複製程式碼

step2/response.js

let response={
    get body(){
        return this.res.body
    },
    set body(value){
        this.res.body=value
    }
}
module.exports=response
複製程式碼

如果po上程式碼,就是這麼簡單,需要的屬性可以自己加上去。那麼問題來這個this指向哪裡??程式碼是很簡單,但是這個指向,並不簡單。

回到我們的application.js,讓這個this指向我們的myhttp的例項。

step2/application.js

class myhttp{
    constructor(){
        this.request=Object.create(request)
        this.response=Object.create(response)
    }
    handleRequest(req,res){
        let request=Object.create(this.request)
        let response=Object.create(this.response)
        request.req=req
        request.request=request
        response.req=req
        response.response=response
        console.log(request.headers.host,request.req.headers.host,req.headers.host)
    }
    ...
}
複製程式碼

此處,我們用Object.create拷貝了一個副本,然後把request和response分別掛上,我們可以通過最後的一個測試看到,我們可以直接通過request.headers.host訪問我們需要的資訊,而可以不用通過request.req.headers.host這麼長的一個指令。這為我們下一步,將requestresponse掛到context打了基礎。

step3 context閃亮登場

context的功能,我對他沒有其他要求,就可以直接context.headers.host,而不用context.request.headers.host,但是我不可能每次新增需要的屬性,都去寫一個get/set吧?於是Object.defineProperty這個神操作來了。

step3/content.js

let context = {
}
//可讀可寫
function access(target,property){
   Object.defineProperty(context,property,{
        get(){
            return this[target][property]
        },
        set(value){
            this[target][property]=value
        }
   })
}
//只可讀
function getter(target,property){
   Object.defineProperty(context,property,{
        get(){
            return this[target][property]
        }
   })
}
getter('request','headers')
access('response','body')
...
複製程式碼

這樣我們就可以方便地進行定義資料了,不過需要注意地是,Object.defineProperty地物件只能定義一次,不能多次定義,會報錯滴。

step3/application.js 接下來就是連線contextrequestresponse了,新建一個createContext,將responserequest顛來倒去地掛到context就可了。

class myhttp{
    constructor(){
        this.context=Object.create(context)
        ...
    }
    createContext(req,res){
        let ctx=Object.create(this.context)
        let request=Object.create(this.request)
        let response=Object.create(this.response)
        ctx.request=request
        ctx.response=response
        ctx.request.req=ctx.req=req
        ctx.response.res=ctx.res=res
        return ctx
    }
    handleRequest(req,res){
        let ctx=this.createContext(req,res)
        console.log(ctx.headers)
        ctx.body="text"
        console.log(ctx.body,res.body)
        res.end(ctx.body);
    }
    ...
}
複製程式碼

以上3步終於把準備工作做好了,接下來進入正題。? 友情連結:

step4 實現use

這裡我需要完成兩個功能點:

  • use可以多次呼叫,中介軟體middleWare按順序執行。
  • use中傳入ctx上下文,供中介軟體middleWare呼叫

想要多箇中介軟體執行,那麼就建一個陣列,將所有地方法都儲存在裡頭,然後等到執行的地時候forEach一下,逐個執行。傳入的ctx就在執行的時候傳入即可。

step4/application.js

class myhttp{
    constructor(){
        this.middleWares=[]
        ...
    }
    use(callback){
        this.middleWares.push(callback)
        return this;
    }
    ...
    handleRequest(req,res){
        ...
        this.middleWares.forEach(m=>{
            m(ctx)
        })
        ...
    }
    ...
}
複製程式碼

此處在use中加了一個小功能,就是讓use可以實現鏈式呼叫,直接返回this即可,因為this就指代了myhttp的例項app

step4/testhttp.js

...
app.use(ctx=>{
    console.log(1)
}).use(ctx=>{
    console.log(2)
})
app.use(ctx=>{
    console.log(3)
})
...
複製程式碼

step5 實現中介軟體的非同步執行

任何程式只要加上了非同步之後,感覺難度就蹭蹭蹭往上漲。

這裡要分兩點來處理:

  • use中中介軟體的非同步執行
  • 中介軟體的非同步完成後compose的非同步執行。

首先是use中的非同步 如果我需要中介軟體是非同步的,那麼我們可以利用async/await這麼寫,返回一個promise

app.use(async (ctx,next)=>{
    await next()//等待下方完成後再繼續執行
    ctx.body="aaa"
})
複製程式碼

如果是promise,那麼我就不能按照普通的程式foreach執行了,我們需要一個完成之後在執行另一個,那麼這邊我們就需要將這些函式組合放入另一個方法compose中進行處理,然後返回一個promise,最後來一個then,告訴程式我執行完了。

handleRequest(req,res){
    ....
    this.compose(ctx,this.middleWares).then(()=>{
        res.end(ctx.body)
    }).catch(err=>{
        console.log(err)
    })
    
}
複製程式碼

那麼compose怎麼寫呢?

首先這個middlewares需要一個執行完之後再進行下一個的執行,也就是回撥。其次compose需要返回一個promise,為了告訴最後我執行完畢了。

第一版本compose,簡易的回撥,像這樣。不過這個和foreach並無差別。這裡的fn就是我們的中介軟體,()=>dispatch(index+1)就是next

compose(ctx,middlewares){
    function dispatch(index){
        console.log(index)
        if(index===middlewares.length) return;
        let fn=middlewares[index]
        fn(ctx,()=>dispatch(index+1));
    }
    dispatch(0)
}
複製程式碼

第二版本compose,我們加上async/await,並返回promise,像這樣。不過這個和foreach並無差別。dispatch一定要返回一個promise。

compose(ctx,middlewares){
    async function dispatch(index){
        console.log(index)
        if(index===middlewares.length) return;
        let fn=middlewares[index]
        return await fn(ctx,()=>dispatch(index+1));
    }
    return dispatch(0)
}
複製程式碼

return await fn(ctx,()=>dispatch(index+1));注意此處,這就是為什麼我們需要在next前面加上await才能生效?作為promise的fn已經執行完畢了,如果不等待後方的promise,那麼就直接then了,後方的next就自生自滅了。所以如果是非同步的,我們就需要在中介軟體上加上async/await以保證next執行完之後再返回上一個promise。無法理解??了?我們看幾個例子。

具體操作如下:

function makeAPromise(ctx){
    return new Promise((rs,rj)=>{
        setTimeout(()=>{
            ctx.body="bbb"
            rs()
        },1000)
    })
}
//如果下方有需要執行的非同步操作
app.use(async (ctx,next)=>{
    await next()//等待下方完成後再繼續執行
    ctx.body="aaa"
})
app.use(async (ctx,next)=>{
    await makeAPromise(ctx).then(()=>{next()})
})
複製程式碼

上述程式碼先執行ctx.body="bbb"再執行ctx.body="aaa",因此列印出來是aaa。如果我們反一反:

app.use(async (ctx,next)=>{
    ctx.body="aaa"
    await next()//等待下方程式碼完成
})
複製程式碼

那麼上述程式碼就先執行ctx.body="aaa"再執行ctx.body="bb",因此列印出來是bbb。 這個時候我們會想,既然我這個中介軟體不是非同步的,那麼是不是就可以不用加上async/await了呢?實踐出真理:

app.use((ctx,next)=>{
    ctx.body="aaa"
    next()//不等了
})
複製程式碼

那麼程式就不會等後面的非同步結束就先結束了。因此如果有非同步的需求,尤其是需要靠非同步執行再進行下一步的的操作,就算本中介軟體沒有非同步需求,也要加上async/await。

有關於router的操作,請移步這份Koa的簡易Router手敲指南請收下

相關文章