1. 程式人生 > >手寫一個webpack,看看AST怎麼用

手寫一個webpack,看看AST怎麼用

本文開始我會圍繞`webpack`和`babel`寫一系列的工程化文章,這兩個工具我雖然天天用,但是對他們的原理理解的其實不是很深入,寫這些文章的過程其實也是我深入學習的過程。由於`webpack`和`babel`的體系太大,知識點眾多,不可能一篇文章囊括所有知識點,目前我的計劃是從簡單入手,先實現一個最簡單的可以執行的`webpack`,然後再看看`plugin`, `loader`和`tree shaking`等功能。目前我計劃會有這些文章: 1. 手寫最簡`webpack`,也就是本文 2. `webpack`的`plugin`實現原理 3. `webpack`的`loader`實現原理 4. `webpack`的`tree shaking`實現原理 5. `webpack`的`HMR`實現原理 6. `babel`和`ast`原理 所有文章都是原理或者原始碼解析,歡迎關注~ **本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack)** **注意:本文主要講`webpack`原理,在實現時並不嚴謹,而且只處理了`import`和`export`的`default`情況,如果你想在生產環境使用,請自己新增其他情況的處理和邊界判斷**。 ## 為什麼要用webpack 筆者剛開始做前端時,其實不知道什麼`webpack`,也不懂模組化,都是`html`裡面直接寫`script`,引入`jquery`直接幹。所以如果一個頁面的JS需要依賴`jquery`和`lodash`,那`html`可能就長這樣: ```html ``` 這樣寫會導致幾個問題: 1. 單獨看`index.js`不能清晰的找到他到底依賴哪些外部庫 2. `script`的順序必須寫正確,如果錯了就會導致找不到依賴,直接報錯 3. 模組間通訊困難,基本都靠往`window`上注入變數來暴露給外部 4. 瀏覽器嚴格按照`script`標籤來下載程式碼,有些沒用到的程式碼也會下載下來 5. 當前端規模變大,JS指令碼會顯得很雜亂,專案管理混亂 `webpack`的一個最基本的功能就是來解決上述的情況,允許在JS裡面通過`import`或者`require`等關鍵字來顯式申明依賴,可以引用第三方庫,自己的JS程式碼間也可以相互引用,這樣在實質上就實現了前端程式碼的模組化。由於歷史問題,老版的JS並沒有自己模組管理方案,所以社群提出了很多模組管理方案,比如`ES2015`的`import`,`CommonJS`的`require`,另外還有`AMD`,`CMD`等等。就目前我見到的情況來說,`import`因為已經成為`ES2015`標準,所以在客戶端廣泛使用,而`require`是`Node.js`的自帶模組管理機制,也有很廣泛的用途,而`AMD`和`CMD`的使用已經很少見了。 但是`webpack`作為一個開放的模組化工具,他是支援`ES6`,`CommonJS`和`AMD`等多種標準的,不同的模組化標準有不同的解析方法,本文只會講`ES6`標準的`import`方案,這也是客戶端JS使用最多的方案。 ## 簡單例子 按照業界慣例,我也用`hello world`作為一個簡單的例子,但是我將這句話拆成了幾部分,放到了不同的檔案裡面。 先來建一個`hello.js`,只匯出一個簡單的字串: ```javascript const hello = 'hello'; export default hello; ``` 然後再來一個`helloWorld.js`,將`hello`和`world`拼成一句話,並匯出拼接的這個方法: ```javascript import hello from './hello'; const world = 'world'; const helloWorld = () => `${hello} ${world}`; export default helloWorld; ``` 最後再來個`index.js`,將拼好的`hello world`插入到頁面上去: ```javascript import helloWorld from "./helloWorld"; const helloWorldStr = helloWorld(); function component() { const element = document.createElement("div"); element.innerHTML = helloWorldStr; return element; } document.body.appendChild(component()); ``` 現在如果你直接在`html`裡面引用`index.js`是不能執行成功的,因為大部分瀏覽器都不支援`import`這種模組匯入。而`webpack`就是來解決這個問題的,它會將我們模組化的程式碼轉換成瀏覽器認識的普通JS來執行。 ### 引入webpack 我們印象中`webpack`的配置很多,很麻煩,但那是因為我們需要開啟的功能很多,如果只是解析轉換`import`,配置起來非常簡單。 1. 先把依賴裝上吧,這沒什麼好說的: ```javascript // package.json { "devDependencies": { "webpack": "^5.4.0", "webpack-cli": "^4.2.0" }, } ``` 2. 為了使用方便,再加個`build`指令碼吧: ```javascript // package.json { "scripts": { "build": "webpack" }, } ``` 3. 最後再簡單寫下`webpack`的配置檔案就好了: ```javascript // webpack.config.js const path = require("path"); module.exports = { mode: "development", devtool: 'source-map', entry: "./src/index.js", output: { filename: "main.js", path: path.resolve(__dirname, "dist"), }, }; ``` 這個配置檔案裡面其實只要指定了入口檔案`entry`和編譯後的輸出檔案目錄`output`就可以正常工作了,這裡這個配置的意思是讓`webpack`從`./src/index.js`開始編譯,編譯後的檔案輸出到`dist/main.js`這個檔案裡面。 這個配置檔案上還有兩個配置`mode`和`devtool`只是我用來方便除錯編譯後的程式碼的,`mode`指定用哪種模式編譯,預設是`production`,會對程式碼進行壓縮和混淆,不好讀,所以我設定為`development`;而`devtool`是用來控制生成哪種粒度的`source map`,簡單來說,想要更好除錯,就要更好的,更清晰的`source map`,但是編譯速度變慢;反之,想要編譯速度快,就要選擇粒度更粗,更不好讀的`source map`,`webpack`提供了很多可供選擇的`source map`,[具體的可以看他的文件](https://webpack.docschina.org/configuration/devtool/)。 4. 然後就可以在`dist`下面建個`index.html`來引用編譯後的程式碼了: ```html // index.html ``` 5. 執行下`yarn build`就會編譯我們的程式碼,然後開啟`index.html`就可以看到效果了。 ![image-20210203154111168](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/85570ae60d6f4b67bcebb78480df30da~tplv-k3u1fbpfcp-zoom-1.image) ## 深入原理 前面講的這個例子很簡單,一般也滿足不了我們實際工程中的需求,但是對於我們理解原理卻是一個很好的突破口,畢竟`webpack`這麼龐大的一個體系,我們也不能一口吃個胖子,得一點一點來。 ### webpack把程式碼編譯成了啥? 為了弄懂他的原理,我們可以直接從編譯後的程式碼入手,先看看他長啥樣子,有的朋友可能一提到去看原始碼,心理就沒底,其實我以前也是這樣的。但是完全沒有必要懼怕,他編譯後的程式碼瀏覽器能夠執行,那肯定就是普通的JS程式碼,不會藏著這麼黑科技。 下面是編譯完的程式碼截圖: ![image-20210203155553091](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d5f84078639246e88f4126402b56f6e6~tplv-k3u1fbpfcp-zoom-1.image) 雖然我們只有三個簡單的JS檔案,但是加上`webpack`自己的邏輯,編譯後的檔案還是有一百多行程式碼,所以即使我把具體邏輯摺疊起來了,這個截圖還是有點長,為了能夠看清楚他的結構,我將它分成了4個部分,標記在了截圖上,下面我們分別來看看這幾個部分吧。 1. 第一部分其實就是一個物件`__webpack_modules__`,這個物件裡面有三個屬性,屬性名字是我們三個模組的檔案路徑,屬性的值是一個函式,我們隨便展開一個`./src/helloWorld.js`看下: ![image-20210203161613636](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9f0b7586c2ae4af0a574b9bbe8b3123b~tplv-k3u1fbpfcp-zoom-1.image) 我們發現這個程式碼內容跟我們自己寫的`helloWorld.js`非常像: ![image-20210203161902647](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0b2efefc71c64112b539299e1ba01ae7~tplv-k3u1fbpfcp-zoom-1.image) 他只是在我們的程式碼前先呼叫了`__webpack_require__.r`和`__webpack_require__.d`,這兩個輔助函式我們在後面會看到。 然後對我們的程式碼進行了一點修改,將我們的`import`關鍵字改成了`__webpack_require__`函式,並用一個變數`_hello__WEBPACK_IMPORTED_MODULE_0__`來接收了`import`進來的內容,後面引用的地方也改成了這個,其他跟這個無關的程式碼,比如`const world = 'world';`還是保持原樣的。 這個`__webpack_modules__`物件存了所有的模組程式碼,其實對於模組程式碼的儲存,在不同版本的`webpack`裡面實現的方式並不一樣,我這個版本是`5.4.0`,在`4.x`的版本里面好像是作為陣列存下來,然後在最外層的立即執行函式裡面以引數的形式傳進來的。但是不管是哪種方式,都只是轉換然後儲存一下模組程式碼而已。 2. 第二塊程式碼的核心是`__webpack_require__`,這個程式碼展開,瞬間給了我一種熟悉感: ![image-20210203162542359](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/970cdb63de9c417b8dec3bfffb178359~tplv-k3u1fbpfcp-zoom-1.image) 來看一下這個流程吧: 1. 先定義一個變數`__webpack_module_cache__`作為載入了的模組的快取 2. `__webpack_require__`其實就是用來載入模組的 3. 載入模組時,先檢查快取中有沒有,如果有,就直接返回快取 4. 如果快取沒有,就從`__webpack_modules__`將對應的模組取出來執行 5. `__webpack_modules__`就是上面第一塊程式碼裡的那個物件,取出的模組其實就是我們自己寫的程式碼,取出執行的也是我們每個模組的程式碼 6. 每個模組執行除了執行我們的邏輯外,還會將`export`的內容新增到`module.exports`上,這就是前面說的`__webpack_require__.d`輔助方法的作用。新增到`module.exports`上其實就是新增到了`__webpack_module_cache__`快取上,後面再引用這個模組就直接從快取拿了。 這個流程我太熟悉了,因為他簡直跟`Node.js`的`CommonJS`實現思路一模一樣,具體的可以看我之前寫的這篇文章:[深入Node.js的模組載入機制,手寫require函式](https://juejin.cn/post/6866973719634542606)。 3. 第三塊程式碼其實就是我們前面看到過的幾個輔助函式的定義,具體幹啥的,其實他的註釋已經寫了: 1. `__webpack_require__.d`:核心其實是`Object.defineProperty`,主要是用來將我們模組匯出的內容新增到全域性的`__webpack_module_cache__`快取上。 ![image-20210203164427116](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/47b2956eb7a84a5c91dd9a5f606aac6d~tplv-k3u1fbpfcp-zoom-1.image) 2. `__webpack_require__.o`:其實就是`Object.prototype.hasOwnProperty`的一個簡寫而已。 ![image-20210203164450385](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9fc9e42d833f406f930f8b38ff9ccef2~tplv-k3u1fbpfcp-zoom-1.image) 3. `__webpack_require__.r`:這個方法就是給每個模組新增一個屬性`__esModule`,來表明他是一個`ES6`的模組。 ![image-20210203164658054](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cb495d14cb4148f380f07039e153182d~tplv-k3u1fbpfcp-zoom-1.image) 4. 第四塊就一行程式碼,呼叫`__webpack_require__`載入入口模組,啟動執行。 這樣我們將程式碼分成了4塊,每塊的作用都搞清楚,其實webpack乾的事情就清晰了: 1. 將`import`這種瀏覽器不認識的關鍵字替換成了`__webpack_require__`函式呼叫。 2. `__webpack_require__`在實現時採用了類似`CommonJS`的模組思想。 3. 一個檔案就是一個模組,對應模組快取上的一個物件。 4. 當模組程式碼執行時,會將`export`的內容新增到這個模組物件上。 5. 當再次引用一個以前引用過的模組時,會直接從快取上讀取模組。 ### 自己實現一個webpack 現在webpack到底幹了什麼事情我們已經清楚了,接下來我們就可以自己動手實現一個了。根據前面最終生成的程式碼結果,我們要實現的程式碼其實主要分兩塊: 1. 遍歷所有模組,將每個模組程式碼讀取出來,替換掉`import`和`export`關鍵字,放到`__webpack_modules__`物件上。 2. 整個程式碼裡面除了`__webpack_modules__`和最後啟動的入口是變化的,其他程式碼,像`__webpack_require__`,`__webpack_require__.r`這些方法其實都是固定的,整個程式碼結構也是固定的,所以完全可以先定義好一個模板。 ### 使用AST解析程式碼 由於我們需要將`import`這種程式碼轉換成瀏覽器能識別的普通JS程式碼,所以我們首先要能夠將程式碼解析出來。在解析程式碼的時候,可以將它讀出來當成字串替換,也可以使用更專業的`AST`來解析。`AST`全稱叫`Abstract Syntax Trees`,也就是`抽象語法樹`,是一個將程式碼用樹來表示的資料結構,一個程式碼可以轉換成`AST`,`AST`又可以轉換成程式碼,而我們熟知的`babel`其實就可以做這個工作。要生成`AST`很複雜,涉及到編譯原理,但是如果僅僅拿來用就比較簡單了,本文就先不涉及複雜的編譯原理,而是直接將`babel`生成好的`AST`拿來使用。 **注意: webpack原始碼解析AST並不是使用的`babel`,而是使用的[acorn](https://github.com/acornjs/acorn),webpack繼承`acorn`的`Parser`,自己實現了一個[JavascriptParser](https://github.com/webpack/webpack/blob/a07a1269f0a0b23d40de6c9565eeaf962fbc8904/lib/javascript/JavascriptParser.js),本文寫作時採用了`babel`,這也是一個大家更熟悉的工具**。 比如我先將入口檔案讀出來,然後用`babel`轉換成`AST`可以直接這樣寫: ```javascript const fs = require("fs"); const parser = require("@babel/parser"); const config = require("../webpack.config"); // 引入配置檔案 // 讀取入口檔案 const fileContent = fs.readFileSync(config.entry, "utf-8"); // 使用babel parser解析AST const ast = parser.parse(fileContent, { sourceType: "module" }); console.log(ast); // 把ast打印出來看看 ``` 上面程式碼可以將生成好的`ast`列印在控制檯: ![image-20210207153459699](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/937d28a355604040b1531db8481d81ab~tplv-k3u1fbpfcp-zoom-1.image) 這雖然是一個完整的`AST`,但是看起來並不清晰,關鍵資料其實是`body`欄位,這裡的`body`也只是展示了型別名字。所以照著這個寫程式碼其實不好寫,這裡推薦一個線上工具[https://astexplorer.net/](https://astexplorer.net/),可以很清楚的看到每個節點的內容: ![image-20210207154116026](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4b00670ed09144b2b5890b96e5b58da0~tplv-k3u1fbpfcp-zoom-1.image) 從這個解析出來的`AST`我們可以看到,`body`主要有4塊程式碼: 1. `ImportDeclaration`:就是第一行的`import`定義 2. `VariableDeclaration`:第三行的一個變數申明 3. `FunctionDeclaration`:第五行的一個函式定義 4. `ExpressionStatement`:第十三行的一個普通語句 你如果把每個節點展開,會發現他們下面又嵌套了很多其他節點,比如第三行的`VariableDeclaration`展開後,其實還有個函式呼叫`helloWorld()`: ![image-20210207154741847](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9a45d3bbb5c2448cbcb43c155938d04a~tplv-k3u1fbpfcp-zoom-1.image) ### 使用`traverse`遍歷`AST` 對於這樣一個生成好的`AST`,我們可以使用`@babel/traverse`來對他進行遍歷和操作,比如我想拿到`ImportDeclaration`進行操作,就直接這樣寫: ```javascript // 使用babel traverse來遍歷ast上的節點 traverse(ast, { ImportDeclaration(path) { console.log(path.node); }, }); ``` 上面程式碼可以拿到所有的`import`語句: ![image-20210207162114290](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/252d31dfc265429fbab68e92106e8f51~tplv-k3u1fbpfcp-zoom-1.image) ### 將`import`轉換為函式呼叫 前面我們說了,我們的目標是將ES6的`import`: ```javascript import helloWorld from "./helloWorld"; ``` 轉換成普通瀏覽器能識別的函式呼叫: ```javascript var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js"); ``` 為了實現這個功能,我們還需要引入`@babel/types`,這個庫可以幫我們建立新的`AST`節點,所以這個轉換程式碼寫出來就是這樣: ```javascript const t = require("@babel/types"); // 使用babel traverse來遍歷ast上的節點 traverse(ast, { ImportDeclaration(p) { // 獲取被import的檔案 const importFile = p.node.source.value; // 獲取檔案路徑 let importFilePath = path.join(path.dirname(config.entry), importFile); importFilePath = `./${importFilePath}.js`; // 構建一個變數定義的AST節點 const variableDeclaration = t.variableDeclaration("var", [ t.variableDeclarator( t.identifier( `__${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__` ), t.callExpression(t.identifier("__webpack_require__"), [ t.stringLiteral(importFilePath), ]) ), ]); // 將當前節點替換為變數定義節點 p.replaceWith(variableDeclaration); }, }); ``` 上面這段程式碼我們用了很多`@babel/types`下面的API,比如`t.variableDeclaration`,`t.variableDeclarator`,這些都是用來建立對應的節點的,[具體的API可以看這裡](https://babeljs.io/docs/en/babel-types#variabledeclaration)。注意這個程式碼裡面我有很多寫死的地方,比如`importFilePath`生成邏輯,還應該處理多種字尾名的,還有最終生成的變數名`_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__`,最後的數字我也是直接寫了`0`,按理來說應該是根據不同的`import`順序來生成的,但是本文主要講`webpack`的原理,這些細節上我就沒花過多時間了。 上面的程式碼其實是修改了我們的`AST`,修改後的`AST`可以用`@babel/generator`又轉換為程式碼: ```javascript const generate = require('@babel/generator').default; const newCode = generate(ast).code; console.log(newCode); ``` 這個列印結果是: ![image-20210207172310114](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bbc93177df0d4937ba0e599500fedfd1~tplv-k3u1fbpfcp-zoom-1.image) 可以看到這個結果裡面`import helloWorld from "./helloWorld";`已經被轉換為`var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");`。 ### 替換`import`進來的變數 前面我們將`import`語句替換成了一個變數定義,變數名字也改為了`__helloWorld__WEBPACK_IMPORTED_MODULE_0__`,自然要將呼叫的地方也改了。為了更好的管理,我們將`AST`遍歷,操作以及最後的生成新程式碼都封裝成一個函式吧。 ```javascript function parseFile(file) { // 讀取入口檔案 const fileContent = fs.readFileSync(file, "utf-8"); // 使用babel parser解析AST const ast = parser.parse(fileContent, { sourceType: "module" }); let importFilePath = ""; // 使用babel traverse來遍歷ast上的節點 traverse(ast, { ImportDeclaration(p) { // 跟之前一樣的 }, }); const newCode = generate(ast).code; // 返回一個包含必要資訊的新物件 return { file, dependcies: [importFilePath], code: newCode, }; } ``` 然後啟動執行的時候就可以調這個函數了 ```javascript parseFile(config.entry); ``` 拿到的結果跟之前的差不多: ![image-20210207173744463](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8ce0038ae6bb42b68ab593644d42b059~tplv-k3u1fbpfcp-zoom-1.image) 好了,現在需要將使用`import`的地方也替換了,因為我們已經知道了這個地方是將它作為函式呼叫的,也就是要將 ```javascript const helloWorldStr = helloWorld(); ``` 轉為這個樣子: ```javascript const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)(); ``` 這行程式碼的效果其實跟`_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()`是一樣的,為啥在前面包個`(0, )`,我也不知道,有知道的大佬告訴下我唄。 所以我們在`traverse`裡面加一個`CallExpression`: ```javascript traverse(ast, { ImportDeclaration(p) { // 跟前面的差不多,省略了 }, CallExpression(p) { // 如果呼叫的是import進來的函式 if (p.node.callee.name === importVarName) { // 就將它替換為轉換後的函式名字 p.node.callee.name = `${importCovertVarName}.default`; } }, }); ``` 這樣轉換後,我們再重新生成一下程式碼,已經像那麼個樣子了: ![image-20210207175649607](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0b146f7b94614899be19cd1e720388c3~tplv-k3u1fbpfcp-zoom-1.image) ### 遞迴解析多個檔案 現在我們有了一個`parseFile`方法來解析處理入口檔案,但是我們的檔案其實不止一個,我們應該依據模組的依賴關係,遞迴的將所有的模組都解析了。要實現遞迴解析也不復雜,因為前面的`parseFile`的依賴`dependcies`已經返回了: 1. 我們建立一個數組存放檔案的解析結果,初始狀態下他只有入口檔案的解析結果 2. 根據入口檔案的解析結果,可以拿到入口檔案的依賴 3. 解析所有的依賴,將結果繼續加到解析結果數組裡面 4. 一直迴圈這個解析結果陣列,將裡面的依賴檔案解析完 5. 最後將解析結果陣列返回就行 寫成程式碼就是這樣: ```javascript function parseFiles(entryFile) { const entryRes = parseFile(entryFile); // 解析入口檔案 const results = [entryRes]; // 將解析結果放入一個數組 // 迴圈結果陣列,將它的依賴全部拿出來解析 for (const res of results) { const dependencies = res.dependencies; dependencies.map((dependency) => { if (dependency) { const ast = parseFile(dependency); results.push(ast); } }); } return results; } ``` 然後就可以呼叫這個方法解析所有檔案了: ```javascript const allAst = parseFiles(config.entry); console.log(allAst); ``` 看看解析結果吧: ![image-20210208152330212](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b02eed01d40649919bf839f9740b28f2~tplv-k3u1fbpfcp-zoom-1.image) 這個結果其實跟我們最終需要生成的`__webpack_modules__`已經很像了,但是還有兩塊沒有處理: 1. 一個是`import`進來的內容作為變數使用,比如 ```javascript import hello from './hello'; const world = 'world'; const helloWorld = () => `${hello} ${world}`; ``` 2. 另一個就是`export`語句還沒處理 ### 替換`import`進來的變數(作為變數呼叫) 前面我們已經用`CallExpression`處理過作為函式使用的`import`變量了,現在要處理作為變數使用的其實用`Identifier`處理下就行了,處理邏輯跟之前的`CallExpression`差不多: ```javascript traverse(ast, { ImportDeclaration(p) { // 跟以前一樣的 }, CallExpression(p) { // 跟以前一樣的 }, Identifier(p) { // 如果呼叫的是import進來的變數 if (p.node.name === importVarName) { // 就將它替換為轉換後的變數名字 p.node.name = `${importCovertVarName}.default`; } }, }); ``` 現在再執行下,`import`進來的變數名字已經變掉了: ![image-20210208153942630](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/04ddb264723944e8beb8c7a008f02af3~tplv-k3u1fbpfcp-zoom-1.image) ### 替換`export`語句 從我們需要生成的結果來看,`export`需要進行兩個處理: 1. 如果一個檔案有`export default`,需要新增一個`__webpack_require__.d`的輔助方法呼叫,內容都是固定的,加上就行。 2. 將`export`語句轉換為普通的變數定義。 對應生成結果上的這兩個: ![image-20210208154959592](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a1cea0ea255b43fe8619c320f6081c6d~tplv-k3u1fbpfcp-zoom-1.image) 要處理`export`語句,在遍歷`ast`的時候新增`ExportDefaultDeclaration`就行了: ```javascript traverse(ast, { ImportDeclaration(p) { // 跟以前一樣的 }, CallExpression(p) { // 跟以前一樣的 }, Identifier(p) { // 跟以前一樣的 }, ExportDefaultDeclaration(p) { hasExport = true; // 先標記是否有export // 跟前面import類似的,建立一個變數定義節點 const variableDeclaration = t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("__WEBPACK_DEFAULT_EXPORT__"), t.identifier(p.node.declaration.name) ), ]); // 將當前節點替換為變數定義節點 p.replaceWith(variableDeclaration); }, }); ``` 然後再執行下就可以看到`export`語句被替換了: ![image-20210208160244276](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ec8f29cbf70f44d29864ec9c372dab4b~tplv-k3u1fbpfcp-zoom-1.image) 然後就是根據`hasExport`變數判斷在`AST`轉換為程式碼的時候要不要加`__webpack_require__.d`輔助函式: ```javascript const EXPORT_DEFAULT_FUN = ` __webpack_require__.d(__webpack_exports__, { "default": () => (__WEBPACK_DEFAULT_EXPORT__) });\n `; function parseFile(file) { // 省略其他程式碼 // ...... let newCode = generate(ast).code; if (hasExport) { newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`; } } ``` 最後生成的程式碼裡面`export`也就處理好了: ![image-20210208161030554](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/252e0ae9b52448a79c7bf1699c46ac6a~tplv-k3u1fbpfcp-zoom-1.image) ### 把`__webpack_require__.r`的呼叫添上吧 前面說了,最終生成的程式碼,每個模組前面都有個`__webpack_require__.r`的呼叫 ![image-20210208161321401](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4fe2ce5490f34b68aa984de0e53de796~tplv-k3u1fbpfcp-zoom-1.image) 這個只是拿來給模組新增一個`__esModule`標記的,我們也給他加上吧,直接在前面`export`輔助方法後面加點程式碼就行了: ```javascript const ESMODULE_TAG_FUN = ` __webpack_require__.r(__webpack_exports__);\n `; function parseFile(file) { // 省略其他程式碼 // ...... let newCode = generate(ast).code; if (hasExport) { newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`; } // 下面新增模組標記程式碼 newCode = `${ESMODULE_TAG_FUN} ${newCode}`; } ``` 再執行下看看,這個程式碼也加上了: ![image-20210208161721369](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/11a010a253bf45e38bdc91d06708bc49~tplv-k3u1fbpfcp-zoom-1.image) ### 建立程式碼模板 到現在,最難的一塊,模組程式碼的解析和轉換我們其實已經完成了。下面要做的工作就比較簡單了,因為最終生成的程式碼裡面,各種輔助方法都是固定的,動態的部分就是前面解析的模組和入口檔案。所以我們可以建立一個這樣的模板,將動態的部分標記出來就行,其他不變的部分寫死。這個模板檔案的處理,你可以將它讀進來作為字串處理,也可以用模板引擎,我這裡採用`ejs`模板引擎: ```javascript // 模板檔案,直接從webpack生成結果抄過來,改改就行 /******/ (() => { // webpackBootstrap /******/ "use strict"; // 需要替換的__TO_REPLACE_WEBPACK_MODULES__ /******/ var __webpack_modules__ = ({ <% __TO_REPLACE_WEBPACK_MODULES__.map(item => { %> '<%- item.file %>' : ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { <%- item.code %> }), <% }) %> }); // 省略中間的輔助方法 /************************************************************************/ /******/ // startup /******/ // Load entry module // 需要替換的__TO_REPLACE_WEBPACK_ENTRY /******/ __webpack_require__('<%- __TO_REPLACE_WEBPACK_ENTRY__ %>'); /******/ // This entry module used 'exports' so it can't be inlined /******/ })() ; //# sourceMappingURL=main.js.map ``` ### 生成最終的程式碼 生成最終程式碼的思路就是: 1. 模板裡面用`__TO_REPLACE_WEBPACK_MODULES__`來生成最終的`__webpack_modules__` 2. 模板裡面用`__TO_REPLACE_WEBPACK_ENTRY__`來替代動態的入口檔案 3. `webpack`程式碼裡面使用前面生成好的`AST`陣列來替換模板的`__TO_REPLACE_WEBPACK_MODULES__` 4. `webpack`程式碼裡面使用前面拿到的入口檔案來替代模板的`__TO_REPLACE_WEBPACK_ENTRY__` 5. 使用`ejs`來生成最終的程式碼 所以程式碼就是: ```javascript // 使用ejs將上面解析好的ast傳遞給模板 // 返回最終生成的程式碼 function generateCode(allAst, entry) { const temlateFile = fs.readFileSync( path.join(__dirname, "./template.js"), "utf-8" ); const codes = ejs.render(temlateFile, { __TO_REPLACE_WEBPACK_MODULES__: allAst, __TO_REPLACE_WEBPACK_ENTRY__: entry, }); return codes; } ``` ### 大功告成 最後將`ejs`生成好的程式碼寫入配置的輸出路徑就行了: ```javascript const codes = generateCode(allAst, config.entry); fs.writeFileSync(path.join(config.output.path, config.output.filename), codes); ``` 然後就可以使用我們自己的`webpack`來編譯程式碼,最後就可以像之前那樣開啟我們的`html`看看效果了: ![image-20210218160539306](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/809d38c44e53493b8fbcda6a374edbf5~tplv-k3u1fbpfcp-zoom-1.image) ## 總結 本文使用簡單質樸的方式講述了`webpack`的基本原理,並自己手寫實現了一個基本的支援`import`和`export`的`default`的`webpack`。 **本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:[https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack](https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack)** 下面再就本文的要點進行下總結: 1. `webpack`最基本的功能其實是將`JS`的高階模組化語句,`import`和`require`之類的轉換為瀏覽器能認識的普通函式呼叫語句。 2. 要進行語言程式碼的轉換,我們需要對程式碼進行解析。 3. 常用的解析手段是`AST`,也就是將程式碼轉換為`抽象語法樹`。 4. `AST`是一個描述程式碼結構的樹形資料結構,程式碼可以轉換為`AST`,`AST`也可以轉換為程式碼。 5. `babel`可以將程式碼轉換為`AST`,但是`webpack`官方並沒有使用`babel`,而是基於[acorn](https://github.com/acornjs/acorn)自己實現了一個[JavascriptParser](https://github.com/webpack/webpack/blob/a07a1269f0a0b23d40de6c9565eeaf962fbc8904/lib/javascript/JavascriptParser.js)。 6. 本文從`webpack`構建的結果入手,也使用`AST`自己生成了一個類似的程式碼。 7. `webpack`最終生成的程式碼其實分為動態和固定的兩部分,我們將固定的部分寫入一個模板,動態的部分在模板裡面使用`ejs`佔位。 8. 生成程式碼動態部分需要藉助`babel`來生成`AST`,並對其進行修改,最後再使用`babel`將其生成新的程式碼。 9. 在生成`AST`時,我們從配置的入口檔案開始,遞迴的解析所有檔案。即解析入口檔案的時候,將它的依賴記錄下來,入口檔案解析完後就去解析他的依賴檔案,在解析他的依賴檔案時,將依賴的依賴也記錄下來,後面繼續解析。重複這種步驟,直到所有依賴解析完。 10. 動態程式碼生成好後,使用`ejs`將其寫入模板,以生成最終的程式碼。 11. 如果要支援`require`或者`AMD`,其實思路是類似的,最終生成的程式碼也是差不多的,主要的差別在`AST`解析那一塊。 ## 參考資料 1. [babel操作AST文件](https://babeljs.io/docs/en/babel-types) 2. [webpack原始碼](https://github.com/webpack/webpack/) 3. [webpack官方文件](https://webpack.js.org/concepts/) **文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。** **歡迎關注我的公眾號[進擊的大前端](https://test-dennis.oss-cn-hangzhou.aliyuncs.com/QRCode/QR430.jpg)第一時間獲取高質量原創~** **“前端進階知識”系列文章:[https://juejin.im/post/5e3ffc85518825494e2772fd](https://juejin.im/post/5e3ffc85518825494e2772fd)** **“前端進階知識”系列文章原始碼GitHub地址: [https://github.com/dennis-jiang/Front-End-Knowledges](https://github.com/dennis-jiang/Front-End-Knowledges)** ![QR1270](https://test-dennis.oss-cn-hangzhou.aliyuncs.com/QRCode/QR1270.png)