手把手教你寫一個迷你 Webpack

一、前言

最近正好在學習 Webpack,覺得 Webpack 這種通過構建模塊依賴圖來打包項目文件的思想很有意思,於是參考了網上的一些文章實現了一個簡陋版本的 mini-webpack,通過入口文件將依賴的模塊打包在一起,生成一份最終運行的代碼。想了解 Webpack 的構建原理還需要補充一些相關的背景知識,下面一起來看看。

二、背景知識

  1. 抽象語法樹(AST)

什麼是抽象語法樹?

平時我們編寫程序的時候,會經常在代碼中根據需要 import 一些模塊,那 Webpack 在構建項目、分析依賴的時候是如何得知我們代碼中是否有 import 文件,import 的是什麼文件的呢?Webpack 並不是人,無法像我們一樣一看到代碼語句就明白其含義,所以我們需要將編寫的代碼轉換成 Webpack 認識的格式讓他它進行處理,這份轉換後生成的東西就是抽象語法樹。下面這張圖能很好地說明什麼是抽象語法樹:

可以看到,抽象語法樹是源代碼的抽象語法結構樹狀表現形式,我們每條編寫的代碼語句都可以被解析成一個個的節點,將一整個代碼文件解析後就會生成一顆節點樹,作爲程序代碼的抽象表示。通過抽象語法樹,我們可以做以下事情:

想看看你的代碼會生成怎樣的抽象語法樹嗎?這裏有一個工具 AST Explorer 能夠在線預覽你的代碼生成的抽象語法樹,感興趣的不妨上去試一試。

  1. Babel

Babel 是一個工具鏈,主要用於將採用 ECMAScript 2015+ 語法編寫的代碼轉換爲向後兼容的 JavaScript 語法,以便能夠運行在當前和舊版本的瀏覽器或其他環境中。通過 Babel 我們可以做以下事情:

一般來說項目使用 Webpack 來打包文件都會配置 babel-loader 將 ES6 的代碼轉換成 ES5 的格式以兼容瀏覽器,這個過程就需要將我們的代碼轉換成抽象語法樹後再進行轉換處理,轉換完成後再將抽象語法樹還原成代碼。

// Babel 輸入:ES2015 箭頭函數
[1, 2, 3].map((n) => n + 1);

// Babel 輸出:ES5 語法實現的同等功能
[1, 2, 3].map(function(n) {
  return n + 1;
});
  1. Webpack 打包原理

Webpack 的構建過程一般會分爲以下幾步:

    // 讀取 webpack.config.js 配置文件:
    const path = require("path")
    module.exports = {
        entry:"./src/index.js"
        mode:"development"
        output:{
          path:path.resolve(__dirname,"./dist"),
          filename:"bundle.js"
        }
    }
    // 基礎結構爲一個IIFE自執行函數
    // 接收一個對象參數,key 爲入口文件的目錄,value爲一個執行入口文件裏面代碼的函數
    (function (modules) {
      // installedModules 用來存放緩存
      const installedModules = {};
      // __webpack_require__用來轉化入口文件裏面的代碼
      function __webpack_require__(moduleIid) { ... }
      // IIFE將 modules 中的 key 傳遞給 __webpack_require__ 函數並返回。
      return __webpack_require__(__webpack_require__.s = './src/index.js');
    }({
      './src/index.js'(function (module, exports) {
        eval('console.log(\'test webpack entry\')');
      }),
    }));

三、具體實現

  1. 安裝相關依賴

我們需要用到以下幾個包:

使用 npm 命令安裝一下:

npm install @babel/parser @babel/traverse @babel/core @babel/preset-env -D
  1. 讀取基本配置

要讀取 Webpack 的基本配置,首先我們得有一個全局的配置文件:

// mini-webpack.config.js
const path = require('path');

module.exports ={
    entry: "./src/index.js",
    mode: "development",
    output: {
      path: path.resolve(__dirname,"./dist"),
      filename: "bundle.js"
    }
}

然後我們新建一個類,用於實現分析編譯等函數,並在構造函數中初始化配置信息:

const options = require('./mini-webpack.config');

class MiniWebpack{
    constructor(options){
        this.options = options;
    }
    // ...
}
  1. 代碼轉換,獲取模塊信息

我們使用 fs 讀取文件內容,使用 parser 將模塊代碼轉換成抽象語法樹,再使用 traverse 遍歷抽象語法樹,針對其中的 ImportDeclaration 節點保存模塊的依賴信息,最終使用 babel.transformFromAst 方法將抽象語法樹還原成 ES5 風格的代碼。

parse = filename ={
    // 讀取文件
    const fileBuffer = fs.readFileSync(filename, 'utf-8');
    // 轉換成抽象語法樹
    const ast = parser.parse(fileBuffer, { sourceType: 'module' });

    const dependencies = {};
    // 遍歷抽象語法樹
    traverse(ast, {
        // 處理ImportDeclaration節點
        ImportDeclaration({node}){
            const dirname = path.dirname(filename);
            const newDirname = './' + path.join(dirname, node.source.value).replace('\\''/');
            dependencies[node.source.value] = newDirname;
        }
    })
    // 將抽象語法樹轉換成代碼
    const { code } = babel.transformFromAst(ast, null, {
        presets:['@babel/preset-env']
    });
    
    return {
        filename,
        dependencies,
        code
    }
}
  1. 分析依賴關係

從入口文件開始,循環解析每個文件與其依賴文件的信息,最終生成以文件名爲 key,以包含依賴關係與編譯後模塊代碼的對象爲 value 的依賴圖譜對象並返回。

analyse = entry ={
    // 解析入口文件
    const entryModule = this.parse(entry);
    const graphArray = [entryModule];
    // 循環解析模塊,保存信息
    for(let i=0;i<graphArray.length;++i){
        const { dependencies } = graphArray[i];
        Object.keys(dependencies).forEach(filename ={
            graphArray.push(this.parse(dependencies[filename]));
        })
    }

    const graph = {};
    // 生成依賴圖譜對象
    graphArray.forEach(({filename, dependencies, code})=>{
        graph[filename] = {
            dependencies,
            code
        };
    })

    return graph;
}
  1. 生成打包代碼

生成依賴圖譜對象,作爲參數傳入一個自執行函數當中。可以看到,自執行函數中有個 require 函數,它的作用是通過調用 eval 執行模塊代碼來獲取模塊內部 export 出來的值。最終我們返回打包的代碼。

generate = (graph, entry) ={
    return `
    (function(graph){
        function require(filename){
            function localRequire(relativePath){
                return require(graph[filename].dependencies[relativePath]);
            }
            const exports = {};
            (function(require, exports, code){
                eval(code);
            })(localRequire, exports, graph[filename].code)

            return exports;
        }
        
        require('${entry}');
    })(${graph})
    `
}
  1. 輸出最終文件

通過獲取 this.options 中的 output 信息,將打包代碼輸出到對應文件中。

fileOutput = (output, code) ={
    const { path: dirPath, filename } = output;
    const outputPath = path.join(dirPath, filename);

    // 如果沒有文件夾的話,生成文件夾
    if(!fs.existsSync(dirPath)){
        fs.mkdirSync(dirPath)
    }
    // 寫入文件中
    fs.writeFileSync(outputPath, code, 'utf-8');
}
  1. 模擬 run 函數

我們將上面的流程集成到一個 run 函數中,通過調用該函數來將整個構建打包流程跑通。

run = () ={
    const { entry, output } = this.options;
    const graph = this.analyse(entry);
    // stringify依賴圖譜對象,防止在模板字符串中調用toString()返回[object Object]
    const graphStr = JSON.stringify(graph);
    const code = this.generate(graphStr, entry);
    this.fileOutput(output, code);
}

8.mini-webpack 大功告成

通過上面的流程,我們的 mini-webpack 已經完成了。我們將文件保存爲 main.js,新建一個 MiniWebpack 對象並執行它的 run 函數:

// main.js
const options = require('./mini-webpack.config');

class MiniWebpack{
    constructor(options){
        // ...
    }

    parse = filename ={
        // ...
    }

    analyse = entry ={
        // ...
    }

    generate = (graph, entry) ={
        // ...
    }

    fileOutput = (output, code) ={
        // ...
    }

    run = () ={
        // ...
    }
}

const miniWebpack = new MiniWebpack(options);
miniWebpack.run();

四、實際演示

我們來實際試驗一下,看看這個 mini-webpack 能不能正常運行。

  1. 新建測試文件

首先在根目錄下創建 src 文件夾,新建 a.jsb.jsindex.js 三個文件

三個文件內容如下:

export default 1;
export default function(){
    console.log('I am b');
}
import a from './a.js';
import b from './b.js';

console.log(a);
console.log(b);
  1. 填入配置文件

配置好入口文件、輸出文件等信息:

const path = require('path');

module.exports ={
    entry: "./src/index.js",
    mode: "development",
    output: {
      path: path.resolve(__dirname,"./dist"),
      filename: "bundle.js"
    }
}
  1. 完善 package.json

我們在 package.json 的 scripts 中新增一個 build 命令,內容爲執行 main.js:

{
  "name""mini-webpack",
  "version""1.0.0",
  "description""",
  "main""index.js",
  "scripts"{
    "test""echo \"Error: no test specified\" && exit 1",
    "build""node main.js"
  },
  "author""",
  "license""ISC",
  "devDependencies"{
    "@babel/core""^7.15.4",
    "@babel/parser""^7.15.4",
    "@babel/preset-env""^7.15.4",
    "@babel/traverse""^7.15.4"
  }
}
  1. 效果演示

我們執行 npm run build 命令,可以看到在根目錄下生成了 dist 文件夾,裏面有個 bundle.js 文件,內容正是我們輸出的打包代碼:

執行下 bundle.js 文件,看看會有什麼輸出:

可以看到,bundle.js 的輸出正是 index.js 文件中兩個 console.log 輸出的值,說明我們的代碼轉換沒有問題,到這裏試驗算是成功了。

五、項目 Git 地址

項目代碼在此:mini-webpack

六、參考文章

  1. 實現一個簡單的 Webpack

  2. Babel 中文文檔

  3. 【你應該瞭解的】抽象語法樹 AST

  4. webpack 構建原理和實現簡單 webpack

公衆號:前端食堂

知乎:童歐巴

掘金:童歐巴

這是一個終身學習的男人,他在堅持自己熱愛的事情,歡迎你加入前端食堂,和這個男人一起開心的變胖~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bw09Zv2fvNloWtIWQYVQnw