了不起的 Webpack 構建流程學習指南

在這裏插入圖片描述
最近原創文章回顧:

Webpack 是前端很火的打包工具,它本質上是一個現代 JavaScript 應用程序的靜態模塊打包器(module bundler)。當 Webpack 處理應用程序時,它會遞歸地構建一個依賴關係圖(dependency graph),其中包含應用程序需要的每個模塊,然後將所有模塊打包成一個或多個 bundle

其實就是:Webpack 是一個 JS 代碼打包器。

至於圖片、CSS、Less、TS等其他文件,就需要 Webpack 配合 loader 或者 plugin 功能來實現~

了不起的 Webpack 構建流程學習指南.png

一、Webpack 構建流程分析

1. Webpack 構建過程

首先先簡單瞭解下 Webpack 構建過程:

  1. 根據配置,識別入口文件;
  2. 逐層識別模塊依賴(包括 Commonjs、AMD、或 ES6 的 import 等,都會被識別和分析);
  3. Webpack 主要工作內容就是分析代碼,轉換代碼,編譯代碼,最後輸出代碼;
  4. 輸出最後打包後的代碼。

2. Webpack 構建原理

看完上面的構建流程的簡單介紹,相信你已經簡單瞭解了這個過程,那麼接下來開始詳細介紹 Webpack 構建原理,包括從啓動構建到輸出結果一系列過程:

(1)初始化參數

解析 Webpack 配置參數,合併 Shell 傳入和 webpack.config.js 文件配置的參數,形成最後的配置結果。

(2)開始編譯

上一步得到的參數初始化 compiler 對象,註冊所有配置的插件,插件監聽 Webpack 構建生命週期的事件節點,做出相應的反應,執行對象的 run 方法開始執行編譯。

(3)確定入口

從配置文件( webpack.config.js )中指定的 entry 入口,開始解析文件構建 AST 語法樹,找出依賴,遞歸下去。

(4)編譯模塊

遞歸中根據文件類型loader 配置,調用所有配置的 loader 對文件進行轉換,再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理。

(5)完成模塊編譯並輸出

遞歸完後,得到每個文件結果,包含每個模塊以及他們之間的依賴關係,根據 entry 配置生成代碼塊 chunk

(6)輸出完成

輸出所有的 chunk 到文件系統。

注意:在構建生命週期中有一系列插件在做合適的時機做合適事情,比如 UglifyPlugin 會在 loader 轉換遞歸完對結果使用 UglifyJs 壓縮覆蓋之前的結果

二、手寫 Webpack 構建工具

到這裏,相信大家對 Webpack 構建流程已經有所瞭解,但是這還不夠,我們再來試着手寫 Webpack 構建工具,來將上面文字介紹的內容,應用於實際代碼,那麼開始吧~

1. 初始化項目

在手寫構建工具前,我們先初始化一個項目:

$ yarn init -y

並安裝下面四個依賴包:

  1. @babel/parser : 用於分析通過 fs.readFileSync  讀取的文件內容,並返回 AST (抽象語法樹) ;
  2. @babel/traverse : 用於遍歷 AST, 獲取必要的數據;
  3. @babel/core : babel 核心模塊,提供 transformFromAst 方法,用於將 AST 轉化爲瀏覽器可運行的代碼;
  4. @babel/preset-env : 將轉換後代碼轉化成 ES5 代碼;
$ yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env

初始化項目目錄及文件:
image.png

代碼存放在倉庫:https://github.com/pingan8787/Leo-JavaScript/tree/master/Cute-Webpack/Write-Webpack

由於本部分核心內容是實現 Webpack 構建工具,所以會從《2. Webpack 構建原理》的“(3)確定入口”步驟開始下面介紹。

大致代碼實現流程如下:

webpack構建流程.jpg

從圖中可以看出,手寫 Webpack 的核心是實現以下三個方法:

  • createAssets : 收集和處理文件的代碼;
  • createGraph :根據入口文件,返回所有文件依賴圖;
  • bundle : 根據依賴圖整個代碼並輸出;

2. 實現 createAssets 函數

2.1 讀取通過入口文件,並轉爲 AST

首先在 ./src/index 文件中寫點簡單代碼:

// src/index.js

import info from "./info.js";
console.log(info);

實現 createAssets 方法中的 文件讀取AST轉換 操作:

// leo_webpack.js

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
// 由於 traverse 採用的 ES Module 導出,我們通過 requier 引入的話就加個 .default
const babel = require("@babel/core");

let moduleId = 0;
const createAssets = filename => {
    const content = fs.readFileSync(filename, "utf-8"); // 根據文件名,同步讀取文件流
  
  	// 將讀取文件流 buffer 轉換爲 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源碼類型
    })
    console.log(ast);
}

createAssets('./src/index.js');

上面代碼:
通過 fs.readFileSync() 方法,以同步方式讀取指定路徑下的文件流,並通過 parser 依賴包提供的 parse() 方法,將讀取到的文件流 buffer 轉換爲瀏覽器可以認識的代碼(AST),AST 輸出如下:

image.png

另外需要注意,這裏我們聲明瞭一個 moduleId 變量,來區分當前操作的模塊。
在這裏,不僅將讀取到的文件流 buffer 轉換爲 AST 的同時,也將 ES6 代碼轉換爲 ES5 代碼了。

2.2 收集每個模塊的依賴

接下來聲明 dependencies 變量來保存收集到的文件依賴路徑,通過 traverse() 方法遍歷 ast,獲取每個節點依賴路徑,並 pushdependencies 數組中。

// leo_webpack.js

function createAssets(filename){
    // ...
    const dependencies = []; // 用於收集文件依賴的路徑

  	// 通過 traverse 提供的操作 AST 的方法,獲取每個節點的依賴路徑
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });
}

2.3 將 AST 轉換爲瀏覽器可運行代碼

在收集依賴的同時,我們可以將 AST 代碼轉換爲瀏覽器可運行代碼,這就需要使用到 babel ,這個萬能的小傢伙,爲我們提供了非常好用的 transformFromAstSync() 方法,同步的將 AST 轉換爲瀏覽器可運行代碼:

// leo_webpack.js

function createAssets(filename){
    // ...
    const { code } = babel.transformFromAstSync(ast,null, {
        presets: ["@babel/preset-env"]
    });
    let id = moduleId++; // 設置當前處理的模塊ID
    return {
        id,
        filename,
        code,
        dependencies
    }
}

到這一步,我們在執行 node leo_webpack.js ,輸出如下內容,包含了入口文件的路徑 filename  、瀏覽器可執行代碼 code 和文件依賴的路徑 dependencies 數組:

$ node leo_webpack.js

{ 
  filename: './src/index.js',
  code: '"use strict";\n\nvar _info = _interopRequireDefault(require("./info.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_info["default"]);', 
  dependencies: [ './info.js' ] 
}

2.4 代碼小結

// leo_webpack.js

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
// 由於 traverse 採用的 ES Module 導出,我們通過 requier 引入的話就加個 .default
const babel = require("@babel/core");

let moduleId = 0;
function createAssets(filename){
    const content = fs.readFileSync(filename, "utf-8"); // 根據文件名,同步讀取文件流
  
  	// 將讀取文件流 buffer 轉換爲 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源碼類型
    })
    const dependencies = []; // 用於收集文件依賴的路徑

  	// 通過 traverse 提供的操作 AST 的方法,獲取每個節點的依賴路徑
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });

  	// 通過 AST 將 ES6 代碼轉換成 ES5 代碼
    const { code } = babel.transformFromAstSync(ast,null, {
        presets: ["@babel/preset-env"]
    });
  
    let id = moduleId++; // 設置當前處理的模塊ID
    return {
      	id,
        filename,
        code,
        dependencies
    }
}

3. 實現 createGraph 函數

createGraph() 函數中,我們將遞歸所有依賴模塊,循環分析每個依賴模塊依賴,生成一份依賴圖譜。
爲了方便測試,我們補充下 consts.jsinfo.js 文件的代碼,增加一些依賴關係:

// src/consts.js
export const company = "平安";

// src/info.js
import { company } from "./consts.js";
export default `你好,${company}`;

接下來開始實現 createGraph() 函數,它需要接收一個入口文件的路徑( entry )作爲參數:

// leo_webpack.js

function createGraph(entry) {
    const mainAsset = createAssets(entry); // 獲取入口文件下的內容
    const queue = [mainAsset]; // 入口文件的結果作爲第一項
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);
        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath); // 轉換文件路徑爲絕對路徑
            const child = createAssets(absolutePath);
            asset.mapping[relativePath] = child.id; // 保存模塊ID 
            queue.push(child); // 遞歸去遍歷所有子節點的文件
        })
    }
    return queue;
}

上面代碼:

首先通過 createAssets() 函數讀取入口文件的內容,並作爲依賴關係的隊列(依賴圖譜) queue 數組的第一項,接着遍歷依賴圖譜 queue 每一項,再遍歷將每一項中的依賴 dependencies 依賴數組,將依賴中的每一項拼接成依賴的絕對路徑(absolutePath ),作爲 createAssets() 函數調用的參數,遞歸去遍歷所有子節點的文件,並將結果都保存在依賴圖譜 queue 中。

注意, mapping 對象是用來保存文件的相對路徑和模塊 ID 的對應關係,在 mapping 對象中,我們使用依賴文件的相對路徑作爲 key ,來存儲保存模塊 ID。

然後我們修改啓動函數:

// leo_webpack.js

- const result = createAssets('./src/index.js');
+ const graph = createGraph("./src/index.js");
+ console.log(graph);

這時我們將得到一份包含所有文件依賴關係的依賴圖譜:

image.png

這個依賴圖譜,包含了所有文件模塊的依賴,以及模塊的代碼內容。下一步只要實現 bundle() 函數,將結果輸出即可。

4. 實現 bundle 函數

從前面介紹,我們知道,函數 createGraph() 會返回一個包含每個依賴相關信息(id / filename / code / dependencies)的依賴圖譜 queue,這一步就將使用到它了。

bundle() 函數中,接收一個依賴圖譜 graph 作爲參數,最後輸出編譯後的結果。

4.1 讀取所有模塊信息

我們首先聲明一個變量 modules,值爲字符串類型,然後對參數 graph 進行遍歷,將每一項中的 id 屬性作爲 key ,值爲一個數組,包括一個用來執行代碼 code 的方法和序列化後的 mapping,最後拼接到 modules 中。

// leo_webpack.js

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
}

上面代碼:

modules 中每一項的值中,下標爲 0 的元素是個函數,接收三個參數 require / module / exports ,爲什麼會需要這三個參數呢?

原因是:構建工具無法判斷是否支持require / module / exports 這三種模塊方法,所以需要自己實現(後面步驟會實現),然後方法內的 code 才能正常執行。

4.2 返回最終結果

接着,我們來實現 bundle() 函數返回值的處理:

// leo_webpack.js

function bundle(graph) {
    //...
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

上面代碼:

最終 bundle 函數返回值是一個字符串,包含一個自執行函數(IIFE),其中函數參數是一個對象, keymodulesvalue 爲前面拼接好的 modules 字符串,即 {modules: modules字符串}

在這個自執行函數中,實現了 require 方法,接收一個 id 作爲參數,在方法內部,分別實現了 localRequire / module / modules.exports 三個方法,並作爲參數,傳到 modules[id] 中的 fn 方法中,最後初始化 require() 函數(require(0);)。

4.3 代碼小結

// leo_webpack.js

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

5. 執行代碼

當我們上面方法都實現以後,就開始試試吧:

// leo_webpack.js

const graph = createGraph("./src/index.js");
const result = bundle(graph);
console.log(result)

這時候可以看到終端輸出類似這樣的代碼,是字符串,這裏爲了方便查看而複製到控制檯了:

image.png

這就是打包後的代碼咯~

那麼如何讓這些代碼執行呢?用 eval() 方法咯:

// leo_webpack.js

const graph = createGraph("./src/index.js");
const result = bundle(graph);
eval(result);

這時候就能看到控制檯輸出 你好,平安 。那麼我們就完成一個簡單的 Webpack 構建工具啦~

能看到這裏的朋友,爲你點個贊~

三、總結

本文主要介紹了 Webpack 的構建流程和構建原理,並在此基礎上,和大家分享了手寫 Webpack 的實現過程,希望大家對 Webpack 構建流程能有更深瞭解,畢竟面試賊喜歡問啦~

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推薦 https://github.com/pingan8787/Leo_Reading/issues
ES小冊 js.pingan8787.com
語雀知識庫 Cute-FrontEnd

bg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章