上一篇文章我們講了怎么用Node.js原生API來寫一個web服務器,雖然代碼比較丑,但是基本功能還是有的。但是一般我們不會直接用原生API來寫,而是借助框架來做,比如本文要講的Express。通過上一篇文章的鋪墊,我們可以猜測,Express其實也沒有什么黑魔法,也僅僅是原生API的封裝,主要是用來提供更好的擴展性,使用起來更方便,代碼更優雅。本文照例會從Express的基本使用入手,然后自己手寫一個Express來替代他,也就是源碼解析。
本文可運行代碼已經上傳GitHub,拿下來一邊玩代碼,一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express
簡單示例
使用Express搭建一個最簡單的Hello World也是幾行代碼就可以搞定,下面這個例子來源官方文檔:
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
可以看到Express的路由可以直接用app.get這種方法來處理,比我們之前在http.createServer里面寫一堆if優雅多了。我們用這種方式來改寫下上一篇文章的代碼:
const path = require("path");
const express = require("express");
const fs = require("fs");
const url = require("url");
const app = express();
const port = 3000;
app.get("/", (req, res) => {
res.end("Hello World");
});
app.get("/api/users", (req, res) => {
const resData = [
{
id: 1,
name: "小明",
age: 18,
},
{
id: 2,
name: "小紅",
age: 19,
},
];
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(resData));
});
app.post("/api/users", (req, res) => {
let postData = "";
req.on("data", (chunk) => {
postData = postData + chunk;
});
req.on("end", () => {
// 數據傳完后往db.txt插入內容
fs.appendFile(path.join(__dirname, "db.txt"), postData, () => {
res.end(postData); // 數據寫完后將數據再次返回
});
});
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}/`);
});
Express還支持中間件,我們寫個中間件來打印出每次請求的路徑:
app.use((req, res, next) => {
const urlObject = url.parse(req.url);
const { pathname } = urlObject;
console.log(`request path: ${pathname}`);
next();
});
Express也支持靜態資源托管,不過他的API是需要指定一個文件夾來單獨存放靜態資源的,比如我們新建一個public文件夾來存放靜態資源,使用express.static中間件配置一下就行:
app.use(express.static(path.join(__dirname, 'public')));
然后就可以拿到靜態資源了:
手寫源碼
手寫源碼才是本文的重點,前面的不過是鋪墊,本文手寫的目標就是自己寫一個express來替換前面用到的express api,其實就是源碼解析。在開始之前,我們先來看看用到了哪些API:
express(),第一個肯定是express函數,這個運行后會返回一個app的實例,后面用的很多方法都是這個app上的。app.listen,這個方法類似於原生的server.listen,用來啟動服務器。app.get,這是處理路由的API,類似的還有app.post等。app.use,這是中間件的調用入口,所有中間件都要通過這個方法來調用。express.static,這個中間件幫助我們做靜態資源托管,其實是另外一個庫了,叫serve-static,因為跟Express架構關系不大,本文就先不講他的源碼了。
本文所有手寫代碼全部參照官方源碼寫成,方法名和變量名盡量與官方保持一致,大家可以對照着看,寫到具體的方法時我也會貼出官方源碼的地址。
express()
首先需要寫的肯定是express(),這個方法是一切的開始,他會創建並返回一個app,這個app就是我們的web服務器。
// express.js
var mixin = require('merge-descriptors');
var proto = require('./application');
// 創建web服務器的方法
function createApplication() {
// 這個app方法其實就是傳給http.createServer的回調函數
var app = function (req, res) {
};
mixin(app, proto, false);
return app;
}
exports = module.exports = createApplication;
上述代碼就是我們在運行express()的時候執行的代碼,其實就是個空殼,返回的app暫時是個空函數,真正的app並沒在這里,而是在proto上,從上述代碼可以看出proto其實就是application.js,然后通過下面這行代碼將proto上的東西都賦值給了app:
mixin(app, proto, false);
這行代碼用到了一個第三方庫merge-descriptors,這個庫總共沒有幾行代碼,做的事情也很簡單,就是將proto上面的屬性挨個賦值給app,對merge-descriptors源碼感興趣的可以看這里:https://github.com/component/merge-descriptors/blob/master/index.js。
Express這里之所以使用mixin,而不是普通的面向對象來繼承,是因為它除了要mixin proto外,還需要mixin其他庫,也就是需要多繼承,我這里省略了,但是官方源碼是有的。
express.js對應的源碼看這里:https://github.com/expressjs/express/blob/master/lib/express.js
app.listen
上面說了,express.js只是一個空殼,真正的app在application.js里面,所以app.listen也是在這里。
// application.js
var app = exports = module.exports = {};
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
上面代碼就是調用原生http模塊創建了一個服務器,但是傳的參數是this,這里的this是什么呢?回想一下我們使用express的時候是這樣用的:
const app = express();
app.listen(3000);
所以listen方法的實際調用者是express()的返回值,也就是上面express.js里面createApplication的返回值,也就是這個函數:
var app = function (req, res) {
};
所以這里的this也是這個函數,所以我在express.js里面就加了注釋,這個函數是http.createServer的回調函數。現在這個函數是空的,實際上他應該是整個web服務器的處理入口,所以我們給他加上處理的邏輯,在里面再加一行代碼:
var app = function(req, res) {
app.handle(req, res); // 這是真正的服務器處理入口
};
app.handle
app.handle也是掛載在app下面的,所以他實際也在application.js這個文件里面,下面我們來看看他干了什么:
app.handle = function handle(req, res) {
var router = this._router;
// 最終的處理方法
var done = finalhandler(req, res);
// 如果沒有定義router
// 直接結束返回
if (!router) {
done();
return;
}
// 有router,就用router來處理
router.handle(req, res, done);
}
上面代碼可以看出,實際處理路由的是router,這是Router的一個實例,並且掛載在this上的,我們這里還沒有給他賦值,如果沒有賦值的話,會直接運行finalhandler並且結束處理。finalhandler也是一個第三方庫,GitHub鏈接在這里:https://github.com/pillarjs/finalhandler。這個庫的功能也不復雜,就是幫你處理一些收尾的工作,比如所有路由都沒匹配上,你可能需要返回404並記錄下error log,這個庫就可以幫你做。
app.get
上面說了,在具體處理網絡請求時,實際上是用app._router來處理的,那么app._router是在哪里賦值的呢?事實上app._router的賦值有多個地方,一個地方就是HTTP動詞處理方法上,比如我們用到的app.get或者app.post。無論是app.get還是app.post都是調用的router方法來處理,所以可以統一用一個循環來寫這一類的方法。
// HTTP動詞的方法
var methods = ['get', 'post'];
methods.forEach(function (method) {
app[method] = function (path) {
this.lazyrouter();
var route = this._router.route(path);
route[method].apply(route, Array.prototype.slice.call(arguments, 1));
return this;
}
});
上面代碼HTTP動詞都放到了一個數組里面,官方源碼中這個數組也是一個第三方庫維護的,名字就叫methods,GitHub地址在這里:https://github.com/jshttp/methods。我這個例子因為只需要兩個動詞,就簡化了,直接用數組了。這段代碼其實給app創建了跟每個動詞同名的函數,所有動詞的處理函數都是一樣的,都是去調router里面的對應方法來處理。這種將不同部分抽取出來,從而復用共同部分的代碼,有點像我之前另一篇文章寫過的設計模式----享元模式。
我們注意到上面代碼除了調用router來處理路由外,還有一行代碼:
this.lazyrouter();
lazyrouter方法其實就是我們給this._router賦值的地方,代碼也比較簡單,就是檢測下有沒有_router,如果沒有就給他賦個值,賦的值就是Router的一個實例:
app.lazyrouter = function lazyrouter() {
if (!this._router) {
this._router = new Router();
}
}
app.listen,app.handle和methods處理方法都在application.js里面,application.js源碼在這里:https://github.com/expressjs/express/blob/master/lib/application.js
Router
寫到這里我們發現我們已經使用了Router的多個API,比如:
router.handlerouter.routeroute[method]
所以我們來看下Router這個類,下面的代碼是從源碼中簡化出來的:
// router/index.js
var setPrototypeOf = require('setprototypeof');
var proto = module.exports = function () {
function router(req, res, next) {
router.handle(req, res, next);
}
setPrototypeOf(router, proto);
return router;
}
這段代碼對我來說是比較奇怪的,我們在執行new Router()的時候其實執行的是new proto(),new proto()並不是我奇怪的地方,奇怪的是他設置原型的方式。我之前在講JS的面向對象的文章提到過如果你要給一個類加上類方法可以這樣寫:
function Class() {}
Class.prototype.method1 = function() {}
var instance = new Class();
這樣instance.__proto__就會指向Class.prototype,你就可使用instance.method1了。
Express.js的上述代碼其實也是實現了類似的效果,setprototypeof又是一個第三方庫,作用類似Object.setPrototypeOf(obj, prototype),就是給一個對象設置原型,setprototypeof存在的意義就是兼容老標准的JS,也就是加了一些polyfill,他的代碼在這里。所以:
setPrototypeOf(router, proto);
這行代碼的意思就是讓router.__proto__指向proto,router是你在new proto()時的返回對象,執行了上面這行代碼,這個router就可以拿到proto上的全部方法了。像router.handle這種方法就可以掛載到proto上了,成為proto.handle。
繞了一大圈,其實就是JS面向對象的使用,給router添加類方法,但是為什么使用這么繞的方式,而不是像我上面那個Class那樣用呢?這我就不是很清楚了,可能有什么歷史原因吧。
路由架構
Router的基本結構知道了,要理解Router的具體代碼,我們還需要對Express的路由架構有一個整體的認識。就以我們這兩個示例API來說:
get /api/users
post /api/users
我們發現他們的path是一樣的,都是/api/users,但是他們的請求方法,也就是method不一樣。Express里面將path這一層提取出來作為了一個類,叫做Layer。但是對於一個Layer,我們只知道他的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
}
]
}
}
]
}
知道了這個結構我們可以猜到,整個流程可以分成兩部分:注冊路由和匹配路由。當我們寫app.get和app.post這些方法時,其實就是在router上添加layer和route。當一個網絡請求過來時,其實就是遍歷layer和route,找到對應的handle拿出來執行。
注意route數組里面的結構,每個項按理來說應該使用一種新的數據結構來存儲,比如routeItem之類的。但是Express並沒有這樣做,而是將它和layer合在一起了,給layer添加了method和handle屬性。這在初次看源碼的時候可能造成困惑,因為layer同時存在於router的stack上和route的stack上,肩負了兩種職責。
router.route
這個方法是我們前面注冊路由的時候調用的一個方法,回顧下前面的注冊路由的方法,比如app.get:
app.get = function (path) {
this.lazyrouter();
var route = this._router.route(path);
route.get.apply(route, Array.prototype.slice.call(arguments, 1));
return this;
}
結合上面講的路由架構,我們在注冊路由的時候,應該給router添加對應的layer和route,router.route的代碼就不難寫出了:
proto.route = function route(path) {
var route = new Route();
var layer = new Layer(path, route.dispatch.bind(route)); // 參數是path和回調函數
layer.route = route;
this.stack.push(layer);
return route;
}
Layer和Route構造函數
上面代碼新建了Route和Layer實例,這兩個類的構造函數其實也挺簡單的。只是參數的申明和初始化:
// layer.js
module.exports = Layer;
function Layer(path, fn) {
this.path = path;
this.handle = fn;
this.method = '';
}
// route.js
module.exports = Route;
function Route() {
this.stack = [];
this.methods = {}; // 一個加快查找的hash表
}
route.get
前面我們看到了app.get其實通過下面這行代碼,最終調用的是route.get:
route.get.apply(route, Array.prototype.slice.call(arguments, 1));
也知道了route.get這種動詞處理函數,其實就是往route.stack上添加layer,那我們的route.get也可以寫出來了:
var methods = ["get", "post"];
methods.forEach(function (method) {
Route.prototype[method] = function () {
// 支持傳入多個回調函數
var handles = flatten(slice.call(arguments));
// 為每個回調新建一個layer,並加到stack上
for (var i = 0; i < handles.length; i++) {
var handle = handles[i];
// 每個handle都應該是個函數
if (typeof handle !== "function") {
var type = toString.call(handle);
var msg =
"Route." +
method +
"() requires a callback function but got a " +
type;
throw new Error(msg);
}
// 注意這里的層級是layer.route.layer
// 前面第一個layer已經做個path的比較了,所以這里是第二個layer,path可以直接設置為/
var layer = new Layer("/", handle);
layer.method = method;
this.methods[method] = true; // 將methods對應的method設置為true,用於后面的快速查找
this.stack.push(layer);
}
};
});
這樣,其實整個router的結構就構建出來了,后面就看看怎么用這個結構來處理請求了,也就是router.handle方法。
router.handle
前面說了app.handle實際上是調用的router.handle,也知道了router的結構是在stack上添加了layer和router,所以router.handle需要做的就是從router.stack上找出對應的layer和router並執行回調函數:
// 真正處理路由的函數
proto.handle = function handle(req, res, done) {
var self = this;
var idx = 0;
var stack = self.stack;
// next方法來查找對應的layer和回調函數
next();
function next() {
// 使用第三方庫parseUrl獲取path,如果沒有path,直接返回
var path = parseUrl(req).pathname;
if (path == null) {
return done();
}
var layer;
var match;
var route;
while (match !== true && idx < stack.length) {
layer = stack[idx++]; // 注意這里先執行 layer = stack[idx]; 再執行idx++;
match = layer.match(path); // 調用layer.match來檢測當前路徑是否匹配
route = layer.route;
// 沒匹配上,跳出當次循環
if (match !== true) {
continue;
}
// layer匹配上了,但是沒有route,也跳出當次循環
if (!route) {
continue;
}
// 匹配上了,看看route上有沒有對應的method
var method = req.method;
var has_method = route._handles_method(method);
// 如果沒有對應的method,其實也是沒匹配上,跳出當次循環
if (!has_method) {
match = false;
continue;
}
}
// 循環完了還沒有匹配的,就done了,其實就是404
if (match !== true) {
return done();
}
// 如果匹配上了,就執行對應的回調函數
return layer.handle_request(req, res, next);
}
};
上面代碼還用到了幾個Layer和Route的實例方法:
layer.match(path): 檢測當前
layer的path是否匹配。route._handles_method(method):檢測當前
route的method是否匹配。layer.handle_request(req, res, next):使用
layer的回調函數來處理請求。
這幾個方法看起來並不復雜,我們后面一個一個來實現。
到這里其實還有個疑問。從他整個的匹配流程來看,他尋找的其實是router.stack.layer這一層,但是最終應該執行的回調卻是在router.stack.layer.route.stack.layer.handle。這是怎么通過router.stack.layer找到最終的router.stack.layer.route.stack.layer.handle來執行的呢?
這要回到我們前面的router.route方法:
proto.route = function route(path) {
var route = new Route();
var layer = new Layer(path, route.dispatch.bind(route));
layer.route = route;
this.stack.push(layer);
return route;
}
這里我們new Layer的時候給的回調其實是route.dispatch.bind(route),這個方法會再去route.stack上找到正確的layer來執行。所以router.handle真正的流程其實是:
- 找到
path匹配的layer- 拿出
layer上的route,看看有沒有匹配的methodlayer和method都有匹配的,再調用route.dispatch去找出真正的回調函數來執行。
所以又多了一個需要實現的函數,route.dispatch。
layer.match
layer.match是用來檢測當前path是否匹配的函數,用到了一個第三方庫path-to-regexp,這個庫可以將path轉為正則表達式,方便后面的匹配,這個庫在之前寫過的react-router源碼中也出現過。
var pathRegexp = require("path-to-regexp");
module.exports = Layer;
function Layer(path, fn) {
this.path = path;
this.handle = fn;
this.method = "";
// 添加一個匹配正則
this.regexp = pathRegexp(path);
// 快速匹配/
this.regexp.fast_slash = path === "/";
}
然后就可以添加match實例方法了:
Layer.prototype.match = function match(path) {
var match;
if (path != null) {
if (this.regexp.fast_slash) {
return true;
}
match = this.regexp.exec(path);
}
// 沒匹配上,返回false
if (!match) {
return false;
}
// 不然返回true
return true;
};
layer.handle_request
layer.handle_request是用來調用具體的回調函數的方法,其實就是拿出layer.handle來執行:
Layer.prototype.handle_request = function handle(req, res, next) {
var fn = this.handle;
fn(req, res, next);
};
route._handles_method
route._handles_method就是檢測當前route是否包含需要的method,因為之前添加了一個methods對象,可以用它來進行快速查找:
Route.prototype._handles_method = function _handles_method(method) {
var name = method.toLowerCase();
return Boolean(this.methods[name]);
};
route.dispatch
route.dispatch其實是router.stack.layer的回調函數,作用是找到對應的router.stack.layer.route.stack.layer.handle並執行。
Route.prototype.dispatch = function dispatch(req, res, done) {
var idx = 0;
var stack = this.stack; // 注意這個stack是route.stack
// 如果stack為空,直接done
// 這里的done其實是router.stack.layer的next
// 也就是執行下一個router.stack.layer
if (stack.length === 0) {
return done();
}
var method = req.method.toLowerCase();
// 這個next方法其實是在router.stack.layer.route.stack上尋找method匹配的layer
// 找到了就執行layer的回調函數
next();
function next() {
var layer = stack[idx++];
if (!layer) {
return done();
}
if (layer.method && layer.method !== method) {
return next();
}
layer.handle_request(req, res, next);
}
};
到這里其實Express整體的路由結構,注冊和執行流程都完成了,貼下對應的官方源碼:
Router類:https://github.com/expressjs/express/blob/master/lib/router/index.js
Layer類:https://github.com/expressjs/express/blob/master/lib/router/layer.js
Route類:https://github.com/expressjs/express/blob/master/lib/router/route.js
中間件
其實我們前面已經隱含了中間件,從前面的結構可以看出,一個網絡請求過來,會到router的第一個layer,然后調用next到到第二個layer,匹配上layer的path就執行回調,然后一直這樣把所有的layer都走完。所以中間件是啥?中間件就是一個layer,他的path默認是/,也就是對所有請求都生效。按照這個思路,代碼就簡單了:
// application.js
// app.use就是調用router.use
app.use = function use(fn) {
var path = "/";
this.lazyrouter();
var router = this._router;
router.use(path, fn);
};
然后在router.use里面再加一層layer就行了:
proto.use = function use(path, fn) {
var layer = new Layer(path, fn);
this.stack.push(layer);
};
總結
Express也是用原生APIhttp.createServer來實現的。Express的主要工作是將http.createServer的回調函數拆出來了,構建了一個路由結構Router。- 這個路由結構由很多層
layer組成。 - 一個中間件就是一個
layer。 - 路由也是一個
layer,layer上有一個path屬性來表示他可以處理的API路徑。 path可能有不同的method,每個method對應layer.route上的一個layer。layer.route上的layer雖然名字和router上的layer一樣,但是功能側重點並不一樣,這也是源碼中讓人困惑的一個點。layer.route上的layer的主要參數是method和handle,如果method匹配了,就執行對應的handle。- 整個路由匹配過程其實就是遍歷
router.layer的一個過程。 - 每個請求來了都會遍歷一遍所有的
layer,匹配上就執行回調,一個請求可能會匹配上多個layer。 - 總體來看,
Express代碼給人的感覺並不是很完美,特別是Layer類肩負兩種職責,跟軟件工程強調的單一職責原則不符,這也導致Router,Layer,Route三個類的調用關系有點混亂。而且對於繼承和原型的使用都是很老的方式。可能也是這種不完美催生了Koa的誕生,下一篇文章我們就來看看Koa的源碼吧。 Express其實還對原生的req和res進行了擴展,讓他們變得更好用,但是這個其實只相當於一個語法糖,對整體架構沒有太大影響,所以本文就沒涉及了。
本文可運行代碼已經上傳GitHub,拿下來一邊玩代碼,一邊看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express
參考資料
Express官方文檔:http://expressjs.com/
Express官方源碼:https://github.com/expressjs/express/tree/master/lib
文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

