1. 程式人生 > >手寫@koa/router原始碼

手寫@koa/router原始碼

[上一篇文章我們講了`Koa`的基本架構](https://juejin.im/post/6892952604163342344),可以看到`Koa`的基本架構只有中介軟體核心,並沒有其他功能,路由功能也沒有。要實現路由功能我們必須引入第三方中介軟體,本文要講的路由中介軟體是[@koa/router](https://github.com/koajs/router),這個中介軟體是掛在`Koa`官方名下的,他跟另一箇中間件[koa-router](https://github.com/ZijianHe/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](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter)** ## 簡單例子 我們這裡的例子還是使用之前[Express文章中的例子](https://juejin.im/post/6890358903960240142): 1. 訪問跟路由返回`Hello World` 2. `get /api/users`返回一個使用者列表,資料是隨便造的 3. `post /api/users`寫入一個使用者資訊,用一個檔案來模擬資料庫 這個例子之前寫過幾次了,用`@koa/router`寫出來就是這個樣子: ```javascript 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`那樣使用回撥函式: ```javascript fs.appendFile(path.join(__dirname, "db.txt"), postData, () => { ctx.body = postData; }); ``` 這會導致這個路由的處理方法並不知道這裡需要執行回撥,而是直接將外層函式執行完就結束了。而外層函式執行完並沒有設定`ctx`的返回值,所以`Koa`會預設返回一個`Not Found`。為了避免這種情況,我們需要讓外層函式等待這裡執行完,所以我們這裡使用`fs.promises`下面的方法,這下面的方法都會返回`promise`,我們就可以使用`await`來等待返回結果了。 ## 手寫原始碼 本文手寫原始碼全部參照官方原始碼寫成,方法名和變數名儘可能與官方程式碼保持一致,大家可以對照著看,寫到具體方法時我也會貼上官方原始碼地址。手寫原始碼前我們先來看看有哪些API是我們需要解決的: 1. `Router`類:我們從`@koa/router`引入的就是這個類,通過`new`關鍵字生成一個例項`router`,後續使用的方法都掛載在這個例項下面。 2. `router.get`和`router.post`:`router`的例項方法`get`和`post`是我們定義路由的方法。 3. `router.routes`:這個例項方法的返回值是作為中介軟體傳給`app.use`的,所以這個方法很可能是生成具體的中介軟體給`Koa`呼叫。 `@koa/router`的這種使用方法跟我們之前看過的[Express.js的路由模組](https://juejin.im/post/6890358903960240142#heading-6)有點像,如果之前看過`Express.js`原始碼解析的,看本文應該會有種似曾相識的感覺。 ### 先看看路由架構 [Express.js原始碼解析裡面](https://juejin.im/post/6890358903960240142#heading-6)我講過他的路由架構,本文講的`@koa/router`的架構跟他有很多相似之處,但是也有一些改進。在進一步深入`@koa/router`原始碼前,我們先來回顧下`Express.js`的路由架構,這樣我們可以有一個整體的認識,可以更好的理解後面的原始碼。對於我們上面這個例子來說,他有兩個API: 1. `get /api/users` 2. `post /api/users` 這兩個API的`path`是一樣的,都是`/api/users`,但是他們的`method`不一樣,一個是`get`,一個是`post`。`Express`裡面將`path`這一層提取出來單獨作為了一個類----`Layer`。一個`Layer`對應一個`path`,但是同一個`path`可能對應多個`method`。所以`Layer`上還添加了一個屬性`route`,`route`上也存了一個數組,陣列的每個項存了對應的`method`和回撥函式`handle`。所以整個結構就是這個樣子: ```javascript 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`的陣列上新增一個項: ```javascript { method: 'get', handle: function1 } ``` **匹配路由**就是當一個請求來了我們就去遍歷`router`上的所有`layer`,找出`path`匹配的`layer`,再找出`layer`上`method`匹配的`route`,然後將對應的回撥函式`handle`拿出來執行。 `@koa/router`有著類似的架構,他的程式碼就是在實現這種架構,先帶著這種架構思維,我們可以很容易讀懂他的程式碼。 ### Router類 首先肯定是`Router`類,他的建構函式也比較簡單,只需要初始化幾個屬性就行。[由於`@koa/router`模組大量使用了面向物件的思想,如果你對JS的面向物件還不熟悉,可以先看看這篇文章。](https://juejin.im/post/6844904069887164423) ```javascript module.exports = Router; function Router() { // 支援無new直接呼叫 if (!(this instanceof Router)) return new Router(); this.stack = []; // 變數名字都跟Express.js的路由模組一樣 } ``` 上面程式碼有一行比較有意思 ```javascript 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](https://github.com/koajs/router/blob/master/lib/router.js#L50) ### 請求動詞函式 前面架構講了,作為一個路由模組,我們主要解決兩個問題:**註冊路由**和**匹配路由**。 先來看看註冊路由,註冊路由主要是在請求動詞函式裡面進行的,比如`router.get`和`router.post`這種函式。`HTTP`動詞有很多,有一個庫專門維護了這些動詞:[methods](https://github.com/jshttp/methods)。`@koa/router`也是用的這個庫,我們這裡就簡化下,直接一個將`get`和`post`放到一個數組裡面吧。 ```javascript // 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`原始碼裡面也見過,目的是讓使用者可以連續點點點,比如這樣: ```javascript router.get().post(); ``` 這些例項方法最後其實都是調`this.register()`去註冊路由的,下面我們看看他是怎麼寫的。 請求動詞函式官方原始碼:[https://github.com/koajs/router/blob/master/lib/router.js#L189](https://github.com/koajs/router/blob/master/lib/router.js#L189) ### router.register() `router.register()`例項方法是真正註冊路由的方法,結合前面架構講的,註冊路由就是構建`layer`的資料結構可知,`router.register()`的主要作用就是構建這個資料結構: ```javascript 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](https://github.com/koajs/router/blob/master/lib/router.js#L553) ### Layer類 上面程式碼出現了`Layer`這個類,我們來看看他的建構函式吧: ```javascript 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原始碼解析中也提出過](https://juejin.im/post/6890358903960240142)。 另外我們看到他也用到了[`path-to-regexp`這個庫](https://github.com/pillarjs/path-to-regexp),這個庫我在很多處理路由的庫裡面都見到過,比如`React-Router`,`Express`,真想去看看他的原始碼,加到我的待寫文章列表裡面去,空了去看看~ `Layer`建構函式官方原始碼:[https://github.com/koajs/router/blob/master/lib/layer.js#L20](https://github.com/koajs/router/blob/master/lib/layer.js#L20) ### router.routes() 前面架構提到的還有件事情需要做,那就是**路由匹配**。 對於`Koa`來說,一個請求來了會依次經過每個中介軟體,所以我們的路由匹配其實也是在中介軟體裡面做的。而`@koa/router`的中介軟體是通過`router.routes()`返回的。所以`router.routes()`主要做兩件事: 1. 他應該返回一個`Koa`中介軟體,以便`Koa`呼叫 2. 這個中介軟體的主要工作是遍歷`router`上的`layer`,找到匹配的路由,並拿出來執行。 ```javascript 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`的原始碼,這裡再大致貼一下: ```javascript 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`的時候是這樣的: ```javascript fn(context, dispatch.bind(null, i + 1)) ``` 這裡傳的引數符合我們使用`@koa/router`的習慣,我們使用`@koa/router`一般是這樣的: ```javascript router.get("/", (ctx, next) => { ctx.body = "Hello World"; }); ``` 上面的`fn`就是我們傳的回撥函式,注意我們執行`fn`時傳入的第二個引數`dispatch.bind(null, i + 1)`,也就是`router.get`這裡的`next`。所以我們上面回撥函式裡面再執行下`next`: ```javascript router.get("/", (ctx, next) => { ctx.body = "Hello World"; next(); // 注意這裡 }); ``` 這個回撥裡面執行`next()`其實就是把`koa-compose`裡面的`dispatch.bind(null, i + 1)`拿出來執行,也就是`dispatch(i + 1)`,對應的就是執行`layerChain`裡面的下一個函式。在這個例子裡面並沒有什麼用,因為匹配的回撥函式只有一個。但是如果`/`這個路徑匹配了多個回撥函式,比如這樣: ```javascript 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`,像這樣: ```javascript 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](https://github.com/koajs/router/blob/master/lib/router.js#L335) ### router.match() 上面`router.routes`的原始碼裡面我們用到了`router.match`這個例項方法來查詢所有匹配的`layer`,上面是這麼用的: ```javascript const matched = router.match(path, ctx.method); ``` 所以我們也需要寫一下這個函式,這個函式不復雜,通過傳入的`path`和`method`去`router.stack`上找到所有匹配的`layer`就行: ```javascript 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`的時候,原始碼是這樣寫的: ```javascript ~layer.methods.indexOf(method) ``` 而一般我們可能是這樣寫: ```javascript layer.methods.indexOf(method) > -1 ``` 這個原始碼裡面的`~`是按位取反的意思,達到的效果與我們後面這種寫法其實是一樣的,因為: ```javascript ~ -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](https://github.com/koajs/router/blob/master/lib/router.js#L669) ### layer.match() 上面用到了`layer.match`這個方法,我們也來寫一下吧。因為我們在建立`layer`例項的時候,其實已經將`path`轉換為了一個正則,我們直接拿來用就行: ```javascript Layer.prototype.match = function (path) { return this.regexp.test(path); }; ``` `layer.match`官方原始碼:[https://github.com/koajs/router/blob/master/lib/layer.js#L54](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](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/KoaRouter)** 最後我們再來總結下本文的要點吧: 1. `@koa/router`整體是作為一個`Koa`中介軟體存在的。 2. `@koa/router`是`fork`的`koa-router`繼續進行維護。 3. `@koa/router`的整體思路跟`Express.js`路由模組很像。 4. `@koa/router`也可以分為**註冊路由**和**匹配路由**兩部分。 5. **註冊路由**主要是構建路由的資料結構,具體來說就是建立很多`layer`,每個`layer`上儲存具體的`path`,`methods`,和回撥函式。 6. `@koa/router`建立的資料結構跟`Express.js`路由模組有區別,少了`route`這個層級,但是個人覺得`@koa/router`的這種結構反而更清晰。`Express.js`的`layer`和`route`的相互引用反而更讓人疑惑。 7. **匹配路由**就是去遍歷所有的`layer`,找出匹配的`layer`,將回調方法拿來執行。 8. 一個路由可能匹配多個`layer`和回撥函式,執行時使用`koa-compose`將這些匹配的回撥函式串起來,一個一個執行。 9. 需要注意的是,如果一個路由匹配了多個回撥函式,前面的回撥函式必須呼叫`next()`才能繼續走到下一個回撥函式。 ## 參考資料 `@koa/router`官方文件:[https://github.com/koajs/router](https://github.com/koajs/router) `@koa/router`原始碼地址:[https://github.com/koajs/router/tree/master/lib](https://github.com/koajs/router/tree/master/lib) **文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。** **作者博文GitHub專案地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)** **作者掘金文章彙總:[https://juejin.im/post/5e3ffc85518825494e2772fd](https://juejin.im/post/5e3ffc85518825494e2772fd)** **我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎