1. 程式人生 > >手寫Express.js原始碼

手寫Express.js原始碼

[上一篇文章我們講了怎麼用`Node.js`原生API來寫一個`web伺服器`](https://juejin.im/post/6887797543212843016),雖然程式碼比較醜,但是基本功能還是有的。但是一般我們不會直接用原生API來寫,而是藉助框架來做,比如本文要講的`Express`。通過上一篇文章的鋪墊,我們可以猜測,`Express`其實也沒有什麼黑魔法,也僅僅是原生API的封裝,主要是用來提供更好的擴充套件性,使用起來更方便,程式碼更優雅。本文照例會從`Express`的基本使用入手,然後自己手寫一個`Express`來替代他,也就是原始碼解析。 **本文可執行程式碼已經上傳GitHub,拿下來一邊玩程式碼,一邊看文章效果更佳:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express)** ## 簡單示例 使用`Express`搭建一個最簡單的`Hello World`也是幾行程式碼就可以搞定,下面這個例子來源官方文件: ```javascript 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`優雅多了。我們用這種方式來改寫下上一篇文章的程式碼: ```javascript 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`還支援中介軟體,我們寫個中介軟體來打印出每次請求的路徑: ```javascript 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`中介軟體配置一下就行: ```javascript app.use(express.static(path.join(__dirname, 'public'))); ``` 然後就可以拿到靜態資源了: ## 手寫原始碼 手寫原始碼才是本文的重點,前面的不過是鋪墊,本文手寫的目標就是自己寫一個`express`來替換前面用到的`express api`,其實就是原始碼解析。在開始之前,我們先來看看用到了哪些`API`: > 1. `express()`,第一個肯定是`express`函式,這個執行後會返回一個`app`的例項,後面用的很多方法都是這個`app`上的。 > 2. `app.listen`,這個方法類似於原生的`server.listen`,用來啟動伺服器。 > 3. `app.get`,這是處理路由的API,類似的還有`app.post`等。 > 4. `app.use`,這是中介軟體的呼叫入口,所有中介軟體都要通過這個方法來呼叫。 > 5. `express.static`,這個中介軟體幫助我們做靜態資源託管,其實是另外一個庫了,叫[serve-static](https://github.com/expressjs/serve-static),因為跟`Express`架構關係不大,本文就先不講他的原始碼了。 [本文所有手寫程式碼全部參照官方原始碼寫成](https://github.com/expressjs/express/tree/master/lib),方法名和變數名儘量與官方保持一致,大家可以對照著看,寫到具體的方法時我也會貼出官方原始碼的地址。 ### express() 首先需要寫的肯定是`express()`,這個方法是一切的開始,他會建立並返回一個`app`,這個`app`就是我們的`web伺服器`。 ```javascript // 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`: ```javascript mixin(app, proto, false); ``` 這行程式碼用到了一個第三方庫`merge-descriptors`,這個庫總共沒有幾行程式碼,做的事情也很簡單,就是將`proto`上面的屬性挨個賦值給`app`,對`merge-descriptors`原始碼感興趣的可以看這裡:[https://github.com/component/merge-descriptors/blob/master/index.js](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](https://github.com/expressjs/express/blob/master/lib/express.js) ### app.listen 上面說了,`express.js`只是一個空殼,真正的`app`在`application.js`裡面,所以`app.listen`也是在這裡。 ```javascript // 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`的時候是這樣用的: ```javascript const app = express(); app.listen(3000); ``` 所以`listen`方法的實際呼叫者是`express()`的返回值,也就是上面`express.js`裡面`createApplication`的返回值,也就是這個函式: ```javascript var app = function (req, res) { }; ``` 所以這裡的`this`也是這個函式,所以我在`express.js`裡面就加了註釋,這個函式是`http.createServer`的回撥函式。現在這個函式是空的,實際上他應該是整個`web伺服器`的處理入口,所以我們給他加上處理的邏輯,在裡面再加一行程式碼: ```javascript var app = function(req, res) { app.handle(req, res); // 這是真正的伺服器處理入口 }; ``` ### app.handle `app.handle`也是掛載在`app`下面的,所以他實際也在`application.js`這個檔案裡面,下面我們來看看他幹了什麼: ```javascript 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](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`方法來處理,所以可以統一用一個迴圈來寫這一類的方法。 ```javascript // 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](https://github.com/jshttp/methods)。我這個例子因為只需要兩個動詞,就簡化了,直接用陣列了。這段程式碼其實給`app`建立了跟每個動詞同名的函式,所有動詞的處理函式都是一樣的,都是去調`router`裡面的對應方法來處理。[這種將不同部分抽取出來,從而複用共同部分的程式碼,有點像我之前另一篇文章寫過的設計模式----享元模式](https://juejin.im/post/6844904168017100813#heading-3)。 我們注意到上面程式碼除了呼叫`router`來處理路由外,還有一行程式碼: ```javascript this.lazyrouter(); ``` `lazyrouter`方法其實就是我們給`this._router`賦值的地方,程式碼也比較簡單,就是檢測下有沒有`_router`,如果沒有就給他賦個值,賦的值就是`Router`的一個例項: ```javascript 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](https://github.com/expressjs/express/blob/master/lib/application.js) ### Router 寫到這裡我們發現我們已經使用了`Router`的多個`API`,比如: > 1. `router.handle` > 2. `router.route` > 3. `route[method]` 所以我們來看下`Router`這個類,下面的程式碼是從原始碼中簡化出來的: ```javascript // 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的面向物件的文章](https://juejin.im/post/6844904069887164423)提到過如果你要給一個類加上類方法可以這樣寫: ```javascript 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`,[他的程式碼在這裡](https://github.com/wesleytodd/setprototypeof/blob/master/index.js)。所以: ```javascript 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`。整個結構你可以理解成這個樣子: ```javascript 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`: ```javascript 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`的程式碼就不難寫出了: ```javascript 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`例項,這兩個類的建構函式其實也挺簡單的。只是引數的申明和初始化: ```javascript // layer.js module.exports = Layer; function Layer(path, fn) { this.path = path; this.handle = fn; this.method = ''; } ``` ```javascript // route.js module.exports = Route; function Route() { this.stack = []; this.methods = {}; // 一個加快查詢的hash表 } ``` ### route.get 前面我們看到了`app.get`其實通過下面這行程式碼,最終呼叫的是`route.get`: ```javascript route.get.apply(route, Array.prototype.slice.call(arguments, 1)); ``` 也知道了`route.get`這種動詞處理函式,其實就是往`route.stack`上新增`layer`,那我們的`route.get`也可以寫出來了: ```javascript 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`並執行回撥函式: ```javascript // 真正處理路由的函式 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`方法: ```javascript 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`真正的流程其實是: > 1. 找到`path`匹配的`layer` > 2. 拿出`layer`上的`route`,看看有沒有匹配的`method` > 3. `layer`和`method`都有匹配的,再呼叫`route.dispatch`去找出真正的回撥函式來執行。 所以又多了一個需要實現的函式,`route.dispatch`。 ### layer.match `layer.match`是用來檢測當前`path`是否匹配的函式,用到了一個第三方庫`path-to-regexp`,這個庫可以將`path`轉為正則表示式,方便後面的匹配,這個庫在[之前寫過的`react-router`原始碼](https://juejin.im/post/6855129007949398029#heading-8)中也出現過。 ```javascript 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`例項方法了: ```javascript 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`來執行: ```javascript 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`物件,可以用它來進行快速查詢: ```javascript 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`並執行。 ```javascript 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](https://github.com/expressjs/express/blob/master/lib/router/index.js) > > **Layer類**:[https://github.com/expressjs/express/blob/master/lib/router/layer.js](https://github.com/expressjs/express/blob/master/lib/router/layer.js) > > **Route類**:[https://github.com/expressjs/express/blob/master/lib/router/route.js](https://github.com/expressjs/express/blob/master/lib/router/route.js) ### 中介軟體 其實我們前面已經隱含了中介軟體,從前面的結構可以看出,一個網路請求過來,會到`router`的第一個`layer`,然後呼叫`next`到到第二個`layer`,匹配上`layer`的`path`就執行回撥,然後一直這樣把所有的`layer`都走完。所以中介軟體是啥?中介軟體就是一個`layer`,他的`path`預設是`/`,也就是對所有請求都生效。按照這個思路,程式碼就簡單了: ```javascript // 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`就行了: ```javascript proto.use = function use(path, fn) { var layer = new Layer(path, fn); this.stack.push(layer); }; ``` ## 總結 1. `Express`也是用原生API`http.createServer`來實現的。 2. `Express`的主要工作是將`http.createServer`的回撥函式拆出來了,構建了一個路由結構`Router`。 3. 這個路由結構由很多層`layer`組成。 4. 一箇中間件就是一個`layer`。 5. 路由也是一個`layer`,`layer`上有一個`path`屬性來表示他可以處理的API路徑。 6. `path`可能有不同的`method`,每個`method`對應`layer.route`上的一個`layer`。 7. `layer.route`上的`layer`雖然名字和`router`上的`layer`一樣,但是功能側重點並不一樣,這也是原始碼中讓人困惑的一個點。 8. `layer.route`上的`layer`的主要引數是`method`和`handle`,如果`method`匹配了,就執行對應的`handle`。 9. 整個路由匹配過程其實就是遍歷`router.layer`的一個過程。 10. 每個請求來了都會遍歷一遍所有的`layer`,匹配上就執行回撥,一個請求可能會匹配上多個`layer`。 11. 總體來看,`Express`程式碼給人的感覺並不是很完美,特別是`Layer`類肩負兩種職責,跟軟體工程強調的`單一職責`原則不符,這也導致`Router`,`Layer`,`Route`三個類的呼叫關係有點混亂。而且對於繼承和原型的使用都是很老的方式。可能也是這種不完美催生了`Koa`的誕生,下一篇文章我們就來看看`Koa`的原始碼吧。 12. `Express`其實還對原生的`req`和`res`進行了擴充套件,讓他們變得更好用,但是這個其實只相當於一個語法糖,對整體架構沒有太大影響,所以本文就沒涉及了。 **本文可執行程式碼已經上傳GitHub,拿下來一邊玩程式碼,一邊看文章效果更佳:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express)** ## 參考資料 Express官方文件:[http://expressjs.com/](http://expressjs.com/) Express官方原始碼:[https://github.com/expressjs/express/tree/master/lib](https://github.com/expressjs/express/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)** **我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎