上一篇文章我們講了Koa
的基本架構,可以看到Koa
的基本架構只有中介軟體核心,並沒有其他功能,路由功能也沒有。要實現路由功能我們必須引入第三方中介軟體,本文要講的路由中介軟體是@koa/router,這個中介軟體是掛在Koa
官方名下的,他跟另一箇中介軟體koa-router名字很像。其實@koa/router
是fork
的koa-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文章中的例子:
- 訪問跟路由返回
Hello World
get /api/users
返回一個使用者列表,資料是隨便造的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是我們需要解決的:
Router
類:我們從@koa/router
引入的就是這個類,通過new
關鍵字生成一個例項router
,後續使用的方法都掛載在這個例項下面。router.get
和router.post
:router
的例項方法get
和post
是我們定義路由的方法。router.routes
:這個例項方法的返回值是作為中介軟體傳給app.use
的,所以這個方法很可能是生成具體的中介軟體給Koa
呼叫。
@koa/router
的這種使用方法跟我們之前看過的Express.js的路由模組有點像,如果之前看過Express.js
原始碼解析的,看本文應該會有種似曾相識的感覺。
先看看路由架構
Express.js原始碼解析裡面我講過他的路由架構,本文講的@koa/router
的架構跟他有很多相似之處,但是也有一些改進。在進一步深入@koa/router
原始碼前,我們先來回顧下Express.js
的路由架構,這樣我們可以有一個整體的認識,可以更好的理解後面的原始碼。對於我們上面這個例子來說,他有兩個API:
get /api/users
post /api/users
這兩個API的path
是一樣的,都是/api/users
,但是他們的method
不一樣,一個是get
,一個是post
。Express
裡面將path
這一層提取出來單獨作為了一個類----Layer
。一個Layer
對應一個path
,但是同一個path
可能對應多個method
。所以Layer
上還新增了一個屬性route
,route
上也存了一個陣列,陣列的每個項存了對應的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
,這個layer
的path
是/api/users
,同時還會在layer.route
的陣列上新增一個項:
{
method: 'get',
handle: function1
}
匹配路由就是當一個請求來了我們就去遍歷router
上的所有layer
,找出path
匹配的layer
,再找出layer
上method
匹配的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.get
和router.post
這種函式。HTTP
動詞有很多,有一個庫專門維護了這些動詞:methods。@koa/router
也是用的這個庫,我們這裡就簡化下,直接一個將get
和post
放到一個陣列裡面吧。
// 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
上成為一個例項方法。這個方法接收path
和middleware
兩個引數,這裡的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;
};
程式碼跟預期的一樣,就是用path
,method
和middleware
來建立一個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.js
的Layer
上還有Route
這個概念。而@koa/router
的stack
上存的直接是回撥函式了,已經沒有route
這一層了。我個人覺得這種層級結構是比Express
的要清晰的,因為Express
的route.stack
裡面存的又是layer
,這種相互引用是有點繞的,這點我在Express原始碼解析中也提出過。
另外我們看到他也用到了path-to-regexp
這個庫,這個庫我在很多處理路由的庫裡面都見到過,比如React-Router
,Express
,真想去看看他的原始碼,加到我的待寫文章列表裡面去,空了去看看~
Layer
建構函式官方原始碼:https://github.com/koajs/router/blob/master/lib/layer.js#L20
router.routes()
前面架構提到的還有件事情需要做,那就是路由匹配。
對於Koa
來說,一個請求來了會依次經過每個中介軟體,所以我們的路由匹配其實也是在中介軟體裡面做的。而@koa/router
的中介軟體是通過router.routes()
返回的。所以router.routes()
主要做兩件事:
- 他應該返回一個
Koa
中介軟體,以便Koa
呼叫 - 這個中介軟體的主要工作是遍歷
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);
所以我們也需要寫一下這個函式,這個函式不復雜,通過傳入的path
和method
去router.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
最後我們再來總結下本文的要點吧:
@koa/router
整體是作為一個Koa
中介軟體存在的。@koa/router
是fork
的koa-router
繼續進行維護。@koa/router
的整體思路跟Express.js
路由模組很像。@koa/router
也可以分為註冊路由和匹配路由兩部分。- 註冊路由主要是構建路由的資料結構,具體來說就是建立很多
layer
,每個layer
上儲存具體的path
,methods
,和回撥函式。 @koa/router
建立的資料結構跟Express.js
路由模組有區別,少了route
這個層級,但是個人覺得@koa/router
的這種結構反而更清晰。Express.js
的layer
和route
的相互引用反而更讓人疑惑。- 匹配路由就是去遍歷所有的
layer
,找出匹配的layer
,將回撥方法拿來執行。 - 一個路由可能匹配多個
layer
和回撥函式,執行時使用koa-compose
將這些匹配的回撥函式串起來,一個一個執行。 - 需要注意的是,如果一個路由匹配了多個回撥函式,前面的回撥函式必須呼叫
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
我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~