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

_蔣鵬飛發表於2021-02-19

本文開始我會圍繞webpackbabel寫一系列的工程化文章,這兩個工具我雖然天天用,但是對他們的原理理解的其實不是很深入,寫這些文章的過程其實也是我深入學習的過程。由於webpackbabel的體系太大,知識點眾多,不可能一篇文章囊括所有知識點,目前我的計劃是從簡單入手,先實現一個最簡單的可以執行的webpack,然後再看看plugin, loadertree shaking等功能。目前我計劃會有這些文章:

  1. 手寫最簡webpack,也就是本文
  2. webpackplugin實現原理
  3. webpackloader實現原理
  4. webpacktree shaking實現原理
  5. webpackHMR實現原理
  6. babelast原理

所有文章都是原理或者原始碼解析,歡迎關注~

本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

注意:本文主要講webpack原理,在實現時並不嚴謹,而且只處理了importexportdefault情況,如果你想在生產環境使用,請自己新增其他情況的處理和邊界判斷

為什麼要用webpack

筆者剛開始做前端時,其實不知道什麼webpack,也不懂模組化,都是html裡面直接寫script,引入jquery直接幹。所以如果一個頁面的JS需要依賴jquerylodash,那html可能就長這樣:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://unpkg.com/jquery@3.5.1"></script>
    <script src="https://unpkg.com/lodash@4.17.20"></script>
    <script src="./src/index.js"></script>
  </head>
  <body>
  </body>
</html>

這樣寫會導致幾個問題:

  1. 單獨看index.js不能清晰的找到他到底依賴哪些外部庫
  2. script的順序必須寫正確,如果錯了就會導致找不到依賴,直接報錯
  3. 模組間通訊困難,基本都靠往window上注入變數來暴露給外部
  4. 瀏覽器嚴格按照script標籤來下載程式碼,有些沒用到的程式碼也會下載下來
  5. 當前端規模變大,JS指令碼會顯得很雜亂,專案管理混亂

webpack的一個最基本的功能就是來解決上述的情況,允許在JS裡面通過import或者require等關鍵字來顯式申明依賴,可以引用第三方庫,自己的JS程式碼間也可以相互引用,這樣在實質上就實現了前端程式碼的模組化。由於歷史問題,老版的JS並沒有自己模組管理方案,所以社群提出了很多模組管理方案,比如ES2015importCommonJSrequire,另外還有AMDCMD等等。就目前我見到的情況來說,import因為已經成為ES2015標準,所以在客戶端廣泛使用,而requireNode.js的自帶模組管理機制,也有很廣泛的用途,而AMDCMD的使用已經很少見了。

但是webpack作為一個開放的模組化工具,他是支援ES6CommonJSAMD等多種標準的,不同的模組化標準有不同的解析方法,本文只會講ES6標準的import方案,這也是客戶端JS使用最多的方案。

簡單例子

按照業界慣例,我也用hello world作為一個簡單的例子,但是我將這句話拆成了幾部分,放到了不同的檔案裡面。

先來建一個hello.js,只匯出一個簡單的字串:

const hello = 'hello';

export default hello;

然後再來一個helloWorld.js,將helloworld拼成一句話,並匯出拼接的這個方法:

import hello from './hello';

const world = 'world';

const helloWorld = () => `${hello} ${world}`;

export default helloWorld;

最後再來個index.js,將拼好的hello world插入到頁面上去:

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. 先把依賴裝上吧,這沒什麼好說的:

    // package.json
    {
      "devDependencies": {
        "webpack": "^5.4.0",
        "webpack-cli": "^4.2.0"
      },
    }
    
  2. 為了使用方便,再加個build指令碼吧:

    // package.json
    {
      "scripts": {
        "build": "webpack"
      },
    }
    
  3. 最後再簡單寫下webpack的配置檔案就好了:

    // 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這個檔案裡面。

    這個配置檔案上還有兩個配置modedevtool只是我用來方便除錯編譯後的程式碼的,mode指定用哪種模式編譯,預設是production,會對程式碼進行壓縮和混淆,不好讀,所以我設定為development;而devtool是用來控制生成哪種粒度的source map,簡單來說,想要更好除錯,就要更好的,更清晰的source map,但是編譯速度變慢;反之,想要編譯速度快,就要選擇粒度更粗,更不好讀的source mapwebpack提供了很多可供選擇的source map具體的可以看他的文件

  4. 然後就可以在dist下面建個index.html來引用編譯後的程式碼了:

    // index.html
    
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
      </head>
      <body>
        <script src="main.js"></script>
      </body>
    </html>
    
  5. 執行下yarn build就會編譯我們的程式碼,然後開啟index.html就可以看到效果了。

    image-20210203154111168

深入原理

前面講的這個例子很簡單,一般也滿足不了我們實際工程中的需求,但是對於我們理解原理卻是一個很好的突破口,畢竟webpack這麼龐大的一個體系,我們也不能一口吃個胖子,得一點一點來。

webpack把程式碼編譯成了啥?

為了弄懂他的原理,我們可以直接從編譯後的程式碼入手,先看看他長啥樣子,有的朋友可能一提到去看原始碼,心理就沒底,其實我以前也是這樣的。但是完全沒有必要懼怕,他編譯後的程式碼瀏覽器能夠執行,那肯定就是普通的JS程式碼,不會藏著這麼黑科技。

下面是編譯完的程式碼截圖:

image-20210203155553091

雖然我們只有三個簡單的JS檔案,但是加上webpack自己的邏輯,編譯後的檔案還是有一百多行程式碼,所以即使我把具體邏輯摺疊起來了,這個截圖還是有點長,為了能夠看清楚他的結構,我將它分成了4個部分,標記在了截圖上,下面我們分別來看看這幾個部分吧。

  1. 第一部分其實就是一個物件__webpack_modules__,這個物件裡面有三個屬性,屬性名字是我們三個模組的檔案路徑,屬性的值是一個函式,我們隨便展開一個./src/helloWorld.js看下:

    image-20210203161613636

    我們發現這個程式碼內容跟我們自己寫的helloWorld.js非常像:

    image-20210203161902647

    他只是在我們的程式碼前先呼叫了__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

    來看一下這個流程吧:

    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.jsCommonJS實現思路一模一樣,具體的可以看我之前寫的這篇文章:深入Node.js的模組載入機制,手寫require函式

  3. 第三塊程式碼其實就是我們前面看到過的幾個輔助函式的定義,具體幹啥的,其實他的註釋已經寫了:

    1. __webpack_require__.d:核心其實是Object.defineProperty,主要是用來將我們模組匯出的內容新增到全域性的__webpack_module_cache__快取上。

      image-20210203164427116

    2. __webpack_require__.o:其實就是Object.prototype.hasOwnProperty的一個簡寫而已。

      image-20210203164450385

    3. __webpack_require__.r:這個方法就是給每個模組新增一個屬性__esModule,來表明他是一個ES6的模組。

      image-20210203164658054

    4. 第四塊就一行程式碼,呼叫__webpack_require__載入入口模組,啟動執行。

這樣我們將程式碼分成了4塊,每塊的作用都搞清楚,其實webpack乾的事情就清晰了:

  1. import這種瀏覽器不認識的關鍵字替換成了__webpack_require__函式呼叫。
  2. __webpack_require__在實現時採用了類似CommonJS的模組思想。
  3. 一個檔案就是一個模組,對應模組快取上的一個物件。
  4. 當模組程式碼執行時,會將export的內容新增到這個模組物件上。
  5. 當再次引用一個以前引用過的模組時,會直接從快取上讀取模組。

自己實現一個webpack

現在webpack到底幹了什麼事情我們已經清楚了,接下來我們就可以自己動手實現一個了。根據前面最終生成的程式碼結果,我們要實現的程式碼其實主要分兩塊:

  1. 遍歷所有模組,將每個模組程式碼讀取出來,替換掉importexport關鍵字,放到__webpack_modules__物件上。
  2. 整個程式碼裡面除了__webpack_modules__和最後啟動的入口是變化的,其他程式碼,像__webpack_require____webpack_require__.r這些方法其實都是固定的,整個程式碼結構也是固定的,所以完全可以先定義好一個模板。

使用AST解析程式碼

由於我們需要將import這種程式碼轉換成瀏覽器能識別的普通JS程式碼,所以我們首先要能夠將程式碼解析出來。在解析程式碼的時候,可以將它讀出來當成字串替換,也可以使用更專業的AST來解析。AST全稱叫Abstract Syntax Trees,也就是抽象語法樹,是一個將程式碼用樹來表示的資料結構,一個程式碼可以轉換成ASTAST又可以轉換成程式碼,而我們熟知的babel其實就可以做這個工作。要生成AST很複雜,涉及到編譯原理,但是如果僅僅拿來用就比較簡單了,本文就先不涉及複雜的編譯原理,而是直接將babel生成好的AST拿來使用。

注意: webpack原始碼解析AST並不是使用的babel,而是使用的acorn,webpack繼承acornParser,自己實現了一個JavascriptParser,本文寫作時採用了babel,這也是一個大家更熟悉的工具

比如我先將入口檔案讀出來,然後用babel轉換成AST可以直接這樣寫:

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

這雖然是一個完整的AST,但是看起來並不清晰,關鍵資料其實是body欄位,這裡的body也只是展示了型別名字。所以照著這個寫程式碼其實不好寫,這裡推薦一個線上工具https://astexplorer.net/,可以很清楚的看到每個節點的內容:

image-20210207154116026

從這個解析出來的AST我們可以看到,body主要有4塊程式碼:

  1. ImportDeclaration:就是第一行的import定義
  2. VariableDeclaration:第三行的一個變數申明
  3. FunctionDeclaration:第五行的一個函式定義
  4. ExpressionStatement:第十三行的一個普通語句

你如果把每個節點展開,會發現他們下面又巢狀了很多其他節點,比如第三行的VariableDeclaration展開後,其實還有個函式呼叫helloWorld()

image-20210207154741847

使用traverse遍歷AST

對於這樣一個生成好的AST,我們可以使用@babel/traverse來對他進行遍歷和操作,比如我想拿到ImportDeclaration進行操作,就直接這樣寫:

// 使用babel traverse來遍歷ast上的節點
traverse(ast, {
  ImportDeclaration(path) {
    console.log(path.node);
  },
});

上面程式碼可以拿到所有的import語句:

image-20210207162114290

import轉換為函式呼叫

前面我們說了,我們的目標是將ES6的import

import helloWorld from "./helloWorld";

轉換成普通瀏覽器能識別的函式呼叫:

var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

為了實現這個功能,我們還需要引入@babel/types,這個庫可以幫我們建立新的AST節點,所以這個轉換程式碼寫出來就是這樣:

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.variableDeclarationt.variableDeclarator,這些都是用來建立對應的節點的,具體的API可以看這裡。注意這個程式碼裡面我有很多寫死的地方,比如importFilePath生成邏輯,還應該處理多種字尾名的,還有最終生成的變數名_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__,最後的數字我也是直接寫了0,按理來說應該是根據不同的import順序來生成的,但是本文主要講webpack的原理,這些細節上我就沒花過多時間了。

上面的程式碼其實是修改了我們的AST,修改後的AST可以用@babel/generator又轉換為程式碼:

const generate  = require('@babel/generator').default;

const newCode = generate(ast).code;
console.log(newCode);

這個列印結果是:

image-20210207172310114

可以看到這個結果裡面import helloWorld from "./helloWorld";已經被轉換為var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

替換import進來的變數

前面我們將import語句替換成了一個變數定義,變數名字也改為了__helloWorld__WEBPACK_IMPORTED_MODULE_0__,自然要將呼叫的地方也改了。為了更好的管理,我們將AST遍歷,操作以及最後的生成新程式碼都封裝成一個函式吧。

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,
  };
}

然後啟動執行的時候就可以調這個函式了

parseFile(config.entry);

拿到的結果跟之前的差不多:

image-20210207173744463

好了,現在需要將使用import的地方也替換了,因為我們已經知道了這個地方是將它作為函式呼叫的,也就是要將

const helloWorldStr = helloWorld();

轉為這個樣子:

const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)();

這行程式碼的效果其實跟_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()是一樣的,為啥在前面包個(0, ),我也不知道,有知道的大佬告訴下我唄。

所以我們在traverse裡面加一個CallExpression

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟前面的差不多,省略了
    },
    CallExpression(p) {
      // 如果呼叫的是import進來的函式
      if (p.node.callee.name === importVarName) {
        // 就將它替換為轉換後的函式名字
        p.node.callee.name = `${importCovertVarName}.default`;
      }
    },
  });

這樣轉換後,我們再重新生成一下程式碼,已經像那麼個樣子了:

image-20210207175649607

遞迴解析多個檔案

現在我們有了一個parseFile方法來解析處理入口檔案,但是我們的檔案其實不止一個,我們應該依據模組的依賴關係,遞迴的將所有的模組都解析了。要實現遞迴解析也不復雜,因為前面的parseFile的依賴dependcies已經返回了:

  1. 我們建立一個陣列存放檔案的解析結果,初始狀態下他只有入口檔案的解析結果
  2. 根據入口檔案的解析結果,可以拿到入口檔案的依賴
  3. 解析所有的依賴,將結果繼續加到解析結果陣列裡面
  4. 一直迴圈這個解析結果陣列,將裡面的依賴檔案解析完
  5. 最後將解析結果陣列返回就行

寫成程式碼就是這樣:

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;
}

然後就可以呼叫這個方法解析所有檔案了:

const allAst = parseFiles(config.entry);
console.log(allAst);

看看解析結果吧:

image-20210208152330212

這個結果其實跟我們最終需要生成的__webpack_modules__已經很像了,但是還有兩塊沒有處理:

  1. 一個是import進來的內容作為變數使用,比如

    import hello from './hello';
    
    const world = 'world';
    
    const helloWorld = () => `${hello} ${world}`;
    
  2. 另一個就是export語句還沒處理

替換import進來的變數(作為變數呼叫)

前面我們已經用CallExpression處理過作為函式使用的import變數了,現在要處理作為變數使用的其實用Identifier處理下就行了,處理邏輯跟之前的CallExpression差不多:

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟以前一樣的
    },
    CallExpression(p) {
			// 跟以前一樣的
    },
    Identifier(p) {
      // 如果呼叫的是import進來的變數
      if (p.node.name === importVarName) {
        // 就將它替換為轉換後的變數名字
        p.node.name = `${importCovertVarName}.default`;
      }
    },
  });

現在再執行下,import進來的變數名字已經變掉了:

image-20210208153942630

替換export語句

從我們需要生成的結果來看,export需要進行兩個處理:

  1. 如果一個檔案有export default,需要新增一個__webpack_require__.d的輔助方法呼叫,內容都是固定的,加上就行。
  2. export語句轉換為普通的變數定義。

對應生成結果上的這兩個:

image-20210208154959592

要處理export語句,在遍歷ast的時候新增ExportDefaultDeclaration就行了:

  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

然後就是根據hasExport變數判斷在AST轉換為程式碼的時候要不要加__webpack_require__.d輔助函式:

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

__webpack_require__.r的呼叫添上吧

前面說了,最終生成的程式碼,每個模組前面都有個__webpack_require__.r的呼叫

image-20210208161321401

這個只是拿來給模組新增一個__esModule標記的,我們也給他加上吧,直接在前面export輔助方法後面加點程式碼就行了:

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

建立程式碼模板

到現在,最難的一塊,模組程式碼的解析和轉換我們其實已經完成了。下面要做的工作就比較簡單了,因為最終生成的程式碼裡面,各種輔助方法都是固定的,動態的部分就是前面解析的模組和入口檔案。所以我們可以建立一個這樣的模板,將動態的部分標記出來就行,其他不變的部分寫死。這個模板檔案的處理,你可以將它讀進來作為字串處理,也可以用模板引擎,我這裡採用ejs模板引擎:

// 模板檔案,直接從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來生成最終的程式碼

所以程式碼就是:

// 使用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生成好的程式碼寫入配置的輸出路徑就行了:

const codes = generateCode(allAst, config.entry);

fs.writeFileSync(path.join(config.output.path, config.output.filename), codes);

然後就可以使用我們自己的webpack來編譯程式碼,最後就可以像之前那樣開啟我們的html看看效果了:

image-20210218160539306

總結

本文使用簡單質樸的方式講述了webpack的基本原理,並自己手寫實現了一個基本的支援importexportdefaultwebpack

本文可執行程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

下面再就本文的要點進行下總結:

  1. webpack最基本的功能其實是將JS的高階模組化語句,importrequire之類的轉換為瀏覽器能認識的普通函式呼叫語句。
  2. 要進行語言程式碼的轉換,我們需要對程式碼進行解析。
  3. 常用的解析手段是AST,也就是將程式碼轉換為抽象語法樹
  4. AST是一個描述程式碼結構的樹形資料結構,程式碼可以轉換為ASTAST也可以轉換為程式碼。
  5. babel可以將程式碼轉換為AST,但是webpack官方並沒有使用babel,而是基於acorn自己實現了一個JavascriptParser
  6. 本文從webpack構建的結果入手,也使用AST自己生成了一個類似的程式碼。
  7. webpack最終生成的程式碼其實分為動態和固定的兩部分,我們將固定的部分寫入一個模板,動態的部分在模板裡面使用ejs佔位。
  8. 生成程式碼動態部分需要藉助babel來生成AST,並對其進行修改,最後再使用babel將其生成新的程式碼。
  9. 在生成AST時,我們從配置的入口檔案開始,遞迴的解析所有檔案。即解析入口檔案的時候,將它的依賴記錄下來,入口檔案解析完後就去解析他的依賴檔案,在解析他的依賴檔案時,將依賴的依賴也記錄下來,後面繼續解析。重複這種步驟,直到所有依賴解析完。
  10. 動態程式碼生成好後,使用ejs將其寫入模板,以生成最終的程式碼。
  11. 如果要支援require或者AMD,其實思路是類似的,最終生成的程式碼也是差不多的,主要的差別在AST解析那一塊。

參考資料

  1. babel操作AST文件
  2. webpack原始碼
  3. webpack官方文件

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章原始碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270

相關文章