一看就懂之webpack原理解析與實現一個簡單的webpack

前情回顧

一看就懂之webpack基礎配置
一看就懂之webpack高級配置與優化css

1、簡介

本文主要講述的是webpack的 工做原理,及其 打包流程,一步步分析其打包過程,而後模擬實現一個簡單的webpack, 主要是爲了更深入地瞭解其打包流程,爲了充分體現其 山寨的意義,故名稱定爲 web-pack

2、webpack的一些特色

  1. webpack的配置文件是一個.js文件,其採用的是node語法主要是導出一個配置對象,而且其採用commonjs2規範進行導出,即以module.exports={}的方式導出配置對象,之因此採用這種方式是爲了方便解析配置文件對象,webpack會找到配置文件而後以require的方式便可讀取到配置文件對象
  2. webpack中全部的資源均可以經過require的方式引入,好比require一張圖片,require一個css文件、一個scss文件等。
  3. webpack中的loader是一個函數,主要爲了實現源碼的轉換,因此loader函數會以源碼做爲參數,好比,將ES6轉換爲ES5,將less轉換爲css,而後再將css轉換爲js,以便能嵌入到html文件中plugin是一個類,類中有一個apply()方法,主要用於Plugin的安裝,能夠在其中監聽一些來自編譯器發出的事件,在合適的時機作一些事情

3、webpack打包原理解析

webpack經過 自定義了一個能夠在node和瀏覽器環境都能執行 __webpack_require__函數來模擬Node.js中的require語句, 將源碼中的全部require語句替換爲__webpack_require__,同時從入口文件開始遍歷查找入口文件依賴,而且 將入口文件及其依賴文件的路徑和對應源碼映射到一個modules對象上,當__webpack_require__執行的時候, 首先傳入的是入口文件的id,就 會從這個modules對象上去取源碼並執行,因爲源碼中的require語句都被替換爲了__webpack_require__函數,因此 每當遇到__webpack_require__函數的時候都會從modules對象上獲取到對應的源碼並執行,從而實現模塊的打包而且 保證源碼執行順序不變

4、webpack打包流程分析

webpack啓動文件:html

webpack首先會找到項目中的webpack.config.js配置文件,並 以require(configPath)的方式,獲取到整個config配置對象,接着建立webpack的編譯器對象,而且 將獲取到的config對象做爲參數傳入編譯器對象中,即在建立Compiler對象的時候 將config對象做爲參數傳入Compiler類的構造函數中,編譯器建立完成後調用其run()方法執行編譯。

編譯器構造函數:node

編譯器構造函數要作的事:建立編譯器的時候,會將config對象傳入編譯器的構造函數內,因此 要將config對象進行保存,而後還須要保存兩個特別重要的數據:
一個是 入口文件的id,即 入口文件相對於根目錄的相對路徑,由於webpack打包輸出的文件內是一個 匿名自執行函數,其 執行的時候, 首先是從入口文件開始的,會調用 __webpack_require__(entryId)這個函數,因此 須要告訴webpack入口文件的路徑
另外一個是 modules對象,對象的屬性爲 入口文件及其全部依賴文件相對於根目錄的相對路徑,由於一個模塊被__webpack_require__( 某個模塊的相對路徑)的時候, webpack會根據這個相對路徑從modules對象中獲取對應的源碼並執行,對象的屬性值爲 一個函數,函數內容爲 當前模塊的eval(源碼)

總之,modules對象保存的就是入口文件及其依賴模塊的路徑和源碼對應關係,webpack打包輸出文件bundle.js執行的時候就會執行匿名自執行函數中的__webpack_require__(entryId),從modules對象中找到入口文件對應的源碼執行,執行入口文件的時候,發現其依賴,又繼續執行__webpack_require__(dependId),再從modules對象中獲取dependId的源碼執行,直到所有依賴都執行完成。webpack

編譯器構造函數中還有一個很是重要的事情要處理,那就是 安裝插件,即 遍歷配置文件中配置的plugins插件數組,而後 調用插件對象的apply()方法,apply()方法 會被傳入compiler編譯器對象,能夠經過傳入的compiler編譯器對象進行 監聽編譯器發射出來的事件,插件就能夠選擇在特定的時機完成一些事情。

編譯器run:web

編譯器的run()方法內主要就是: buildModuleemitFile。而buildModule要作的就是 傳入入口文件的絕對路徑,而後根據入口文件路徑 獲取到入口文件的源碼內容,而後 對源碼進行解析
其中獲取源碼過程分爲兩步: 首先直接讀出文件中的源碼內容,而後根據配置的loader進行匹配,匹配成功後交給對應的loader函數進行處理,loader處理完成後再返回最終處理過的源碼
源碼的解析,主要是: 將由loader處理過的源碼內容 轉換爲AST抽象語法樹,而後 遍歷AST抽象語法樹, 找到源碼中的require語句,並 替換成webpack本身的require方法,即 webpack_require,同時 將require()的路徑替換爲相對於根目錄的相對路徑,替換完成後從新生成替換後的源碼內容,在遍歷過程當中找到該模塊全部依賴, 解析完成後返回替換後的源碼和查找到的因此依賴,若是存在依賴則遍歷依賴, 讓其依賴模塊也執行一遍buildModule(),直到入口文件全部依賴都buildModule完成。
入口文件及其依賴模塊都build完成後,就能夠emitFile了,首先讀取輸出模板文件,而後傳入entryId和modules對象做爲數據進行渲染,主要就是 遍歷modules對象生成webpack匿名自執行函數的參數對象,同時 填入webpack匿名自執行函數執行後要執行的__webpack_require__(entryId)入口文件id。

5、實現一個簡單的webpack

① 讓web-pack命令可執行正則表達式

爲了讓web-pack命令可執行,咱們 須要在其package.json中配置bin屬性名爲命令名稱即web-pack, 屬性值爲web-pack啓動文件,即"./bin/index.js",這樣web-pack安裝以後或者執行npm link命令以後,就會在/usr/local/bin目錄下生產對應的命令,使得web-pack命令能夠在全局使用,如:

// package.jsonnpm

{
    "bin": {
        "web-pack": "./bin/index.js"
    },
}

② 讓web-pack啓動文件能夠在命令行直接執行json

雖然web-pack命令能夠執行了,可是該命令連接的文件是"./bin/index.js",即 輸入web-pack命令執行的是"./bin/index.js"這個js文件,而js文件是沒法直接在終端環境下執行的,因此 須要告訴終端該文件的執行環境爲node,因此須要在"./bin/index.js"文件開頭添加上 #! /usr/bin/env node,即用node環境執行"./bin/index.js"文件中的內容,如:

// ./bin/index.jssegmentfault

#! /usr/bin/env node

③ 獲取配置文件,建立編譯器並執行數組

// ./bin/index.js

#! /usr/bin/env node
const path = require("path");
const config = require(path.resolve("webpack.config.js")); // 獲取到項目根目錄下的webpack.config.js的配置文件
const Compiler = require("../lib/Compiler.js");// 引入Compiler編譯器類
const compiler = new Compiler(config); // 傳入config配置對象並建立編譯器對象
compiler.run(); // 編譯器對象調用run()方法執行

④ 編譯器構造函數

以前說過,編譯器的構造函數主要就是 保存config對象保存入口模塊id保存全部模塊依賴(路徑和源碼映射)插件安裝

// ../lib/Compiler.js

class Compiler {
    constructor(config) {
        this.config = config; // ① 保存配置文件對象
        this.entryId; // ② 保存入口模塊id
        this.modules = {} // ③ 保存全部模塊依賴(路徑和源碼映射)
        this.entry = config.entry; // 入口路徑,即配置文件配置的入口文件的路徑
        this.root = process.cwd(); // 運行web-pack的工做路徑,即要打包項目的根目錄
        // ④遍歷配置的插件並安裝
        const plugins = this.config.plugins; // 獲取使用的plugins
        if(Array.isArray(plugins)) {
            plugins.forEach((plugin) => {
                plugin.apply(this); // 調用plugin的apply()方法安裝插件
            });
        }
    }
}

⑤ 編譯器run()方法

編譯器run()方法,主要就是完成 buildModuleemitFile,buildModule的時候 須要從入口文件開始,即須要 傳入文件的絕對路徑,若是入口文件有依賴,那麼 buildModule()會被遞歸調用,即build依賴模塊,因爲 還須要保存入口文件id,因此須要有一個變量來 告訴傳入的模塊是不是入口文件

// add run()方法

class Compiler {
    run() {
        this.buildModule(path.resolve(this.root, this.entry), true); // 傳入入口文件的絕對路徑,而且第二個參數爲ture,便是入口模塊
        this.emitFile(); // 模塊build完成後發射文件,即將打包結果寫入輸出文件中
    }
}

⑥ 實現buildModule()方法

buildModule方法主要就是 獲取源碼內容,而且 對源碼內容進行解析,解析完成後 拿到解析後的源碼以及 當前模塊的依賴,將解析後的源碼保存到modules對象中,而且 遍歷依賴,繼續buildModule,如:

// add buildModule()方法

class Compiler {
    buildModule(modulePath, isEntry) { // 構造模塊
        const source = this.getSource(modulePath); // 根據模塊絕對路徑獲取到對應的源碼內容
        const moduleName = "./" + path.relative(this.root, modulePath); // 獲取當前build模塊相對於根目錄的相對路徑
        if (isEntry) { // 若是是入口模塊
            this.entryId = moduleName; // 保存入口的相對路徑做爲entryId
        }
        const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName)); // 解析源碼獲取解析後的源碼以及當前模塊的依賴數組
        this.modules[moduleName] = sourceCode; // 保存解析後的源碼內容到modules中
        dependencies.forEach((dep) => { // 遍歷當前模塊的依賴,若是有依賴則繼續build依賴模塊
            this.buildModule(path.join(this.root, dep), false); // 依賴模塊爲非入口模塊,故傳入false,不須要保存到entryId中
        });
    }
}

⑦ 實現獲取源碼內容getSource()方法

獲取源碼主要作的就是, 讀取源碼內容遍歷配置的rules,再根據rule中的test正則表達式 與源碼的文件格式進行匹配,若是匹配成功則交給對應的loader進行處理,若是有多個loader則 從最後一個loader開始遞歸調用依次執行全部的loader

// add getSource()方法

class Compiler {
    getSource(modulePath) {
        let content = fs.readFileSync(modulePath, "utf8"); // 讀取源碼內容
        const rules = this.config.module.rules; // 獲取到配置文件中配置的rules
        for (let i = 0; i< rules.length; i++) { // 遍歷rules
            const rule = rules[i];
            const {test, use} = rule;
            let len = use.length -1; // 獲取處理當前文件的最後一個loader的索引號
            if (test.test(modulePath)) { // 根據源碼文件的路徑於loader配置進行匹配,交給匹配的loader進行處理
                function startLoader() { // 開始執行loader
                    // 引入loader,loader是一個函數,並將源碼內容做爲參數傳遞給loader函數進行處理
                    const loader = require(use[len--]);
                    content = loader(content);
                    if (len >= 0) { // 若是有多個loader則繼續執行下一個loader,
                        startLoader(); // 從最後一個loader開始遞歸調用全部loader
                    }
                }
                startLoader(); // 開始執行loader
            }
         }
     }
}

⑧ 解析源碼並獲取當前源碼的依賴

解析源碼主要就是 將源碼轉換爲AST抽象語法樹,而後 對AST抽象語法樹進行遍歷找到require調用表達式節點,並將其替換爲__webpack_require__,而後 找到require的參數節點,這是一個 字符串常量節點,將require的參數替換爲相對於根目錄下的路徑, 操做AST語法樹節點時候不能直接賦值爲一個字符串常量,應該 用字符串常量生成一個字符串常量節點進行替換。找到require節點的時候同時也就找到了當前模塊的依賴,並 將依賴保存起來返回,以便 遍歷依賴

// add parse()方法

const babylon = require("babylon"); // 將源碼解析爲AST抽象語法樹
const traverse = require("@babel/traverse").default; // 遍歷AST語法樹節點
const types = require("@babel/types"); // 生成一個各類類型的AST節點
const generator = require("@babel/generator").default; // 將AST語法樹從新轉換爲源碼

class Compiler {
    parse(source, parentPath) {
        const dependencies = []; // 保存當前模塊依賴
        const ast = babylon.parse(source); // 將源碼解析爲AST抽象語法樹
        traverse(ast, {
            CallExpression(p) { // 找到require表達式
                const node = p.node; // 對應的節點
                if (node.callee.name == "require") { // 把require替換成webpack本身的require方法,即__webpack_require__即
                    node.callee.name = "__webpack_require__"; 
                    let moduleName = node.arguments[0].value; // 獲取require的模塊名稱
                    if (moduleName) {
                        const extname = path.extname(moduleName) ? "" : ".js";
                        moduleName = moduleName + extname; // 若是引入的模塊沒有寫後綴名,則給它加上後綴名
                        moduleName = "./" + path.join(parentPath, moduleName);
                        dependencies.push(moduleName); // 保存模塊依賴
                        // 將依賴文件的路徑替換爲相對於入口文件所在目錄
                        node.arguments = [types.stringLiteral(moduleName)];// 生成一個字符串常量節點進行替換,這裏的arguments參數節點就是require的文件路徑對應的字符串常量節點
                    }
                }
            }
        });
        const sourceCode = generator(ast).code; // 從新生成源碼
        return {sourceCode, dependencies};
    }
}

⑨ emitFile發射文件

獲取到輸出模板內容,這裏採用 ejs模板,而後 傳入entryId(入口文件Id)和modules對象(路徑和源碼映射對象)對模板進行渲染出最終的輸出內容,而後寫入輸出文件中,即bundle.js中。

// template.ejs

(function(modules) { // webpackBootstrap
     // The module cache
     var installedModules = {};
     // The require function
     function __webpack_require__(moduleId) {
         // Check if module is in cache
         if(installedModules[moduleId]) {
             return installedModules[moduleId].exports;
         }
         // Create a new module (and put it into the cache)
         var module = installedModules[moduleId] = {
             i: moduleId,
             l: false,
             exports: {}
         };
         // Execute the module function
         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
         // Flag the module as loaded
         module.l = true;
         // Return the exports of the module
         return module.exports;
     }
     // Load entry module and return exports
     return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
 })
 ({
    <%for(let key in modules) {%>
        "<%-key%>":
            (function(module, exports, __webpack_require__) {
                eval(`<%-modules[key]%>`);
            }),
        <%}%>
 });

// add emitFile()方法

const ejs = require("ejs");
class Compiler {
    emitFile() { // 發射打包後的輸出結果文件
        // 獲取輸出文件路徑
        const outputFile = path.join(this.config.output.path, this.config.output.filename);
        // 獲取輸出文件模板
        const templateStr = this.getSource(path.join(__dirname, "template.ejs"));
        // 渲染輸出文件模板
        const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules});
        this.assets = {};
        this.assets[outputFile] = code;
        // 將渲染後的代碼寫入輸出文件中
        fs.writeFileSync(outputFile, this.assets[outputFile]);
    }
}
這裏沒有對輸出文件是否存在進行判斷,因此 須要提早建立好一個空的輸出文件

⑩ 編寫loader

爲了便於測試,這裏編寫一個簡單的loader來處理css即style-loader,咱們已經知道loader其實就是一個函數,其會接收源碼進行相應的轉換,也就是會將css源碼傳遞給style-loader進行處理,而 css的執行須要放到style標籤內,故須要經過js建立一個style標籤,並將css源碼嵌入到style標籤內,如:

// style-loader

function loader(source) {
    const style = `
        let style = document.createElement("style");
        style.innerHTML = ${JSON.stringify(source)};
        document.head.appendChild(style);
    `;
    return style;
}
module.exports = loader;

⑪ 編寫Plugin

爲了便於測試,這裏編寫一個 簡單的插件結構不處理具體的內容,只是讓插件能夠正常運行,咱們已經知道插件是一個類,裏面有一個apply()方法,webpack插件主要是 經過tapable模塊,tapable模塊會提供各類各樣的鉤子, 能夠建立各類鉤子對象,而後 在編譯的時候經過調用鉤子對象的call()方法發射事件,而後插件監聽到這些事件就能夠作一些特定的事情。

// plugin.js

class Plugin {
    apply(compiler) {
        compiler.hooks.emit.tap("emit", function() { // 經過編譯器對象獲取emit鉤子並監聽emit事件
            console.log("received emit hook.");
        });
    }
}
module.exports = Plugin;
tapable原理就是 發佈訂閱機制,調用tap的時候就是註冊事件, 會將事件函數存入數組中,當調用call()方法的時候,就會 遍歷存入的事件函數依次執行,即事件的發射。

6、完整的編譯器源碼

const fs = require("fs");
const path = require("path");
// babylon 將源碼轉換爲AST語法樹
const babylon = require("babylon");
// @babel/traverse 遍歷AST節點
const traverse = require("@babel/traverse").default;
// @babel/types 生成一個各類類型的AST節點
const types = require("@babel/types");
// @babel/generator 將AST語法樹從新轉換爲源碼
const generator = require("@babel/generator").default;

const ejs = require("ejs");

const {SyncHook} = require("tapable");

class Compiler {
    constructor(config) {
        this.config = config; // 保存配置文件對象
        // 保存入口文件的路徑
        this.entryId; // "./src/index.js"
        // 存放全部的模塊依賴,包括入口文件和入口文件的依賴,由於全部模塊都要執行
        this.modules = {}
        this.entry = config.entry; // 入口路徑,即配置文件配置的入口文件的路徑
        this.root = process.cwd(); // 運行wb-pack的工做路徑,即要打包項目的根目錄
        this.hooks = {
            entryOption: new SyncHook(),
            compile: new SyncHook(),
            afterCompile: new SyncHook(),
            afterPlugins: new SyncHook(),
            run: new SyncHook(),
            emit: new SyncHook(),
            done: new SyncHook()
        }
        // 遍歷配置的插件並安裝
        const plugins = this.config.plugins; // 獲取使用的plugins
        if(Array.isArray(plugins)) {
            plugins.forEach((plugin) => {
                plugin.apply(this); // 調用plugin的apply()方法
            });
        }
        this.hooks.afterPlugins.call(); // 執行插件安裝結束後的鉤子
    }
    // 獲取源碼內容,獲取源碼的過程當中會根據loader的配置對匹配的文件交給相應的loader處理
    getSource(modulePath) {
        console.log("get source start.");
        // 獲取源碼內容
        let content = fs.readFileSync(modulePath, "utf8");
        // 遍歷loader
        const rules = this.config.module.rules;
        for (let i = 0; i< rules.length; i++) {
            const rule = rules[i];
            const {test, use} = rule;
            let len = use.length -1;
            if (test.test(modulePath)) { // 根據源碼文件的路徑於loader配置進行匹配,交給匹配的loader進行處理
                function startLoader() {
                    // 引入loader,loader是一個函數,並將源碼內容做爲參數傳遞給loader函數進行處理
                    const loader = require(use[len--]);
                    content = loader(content);
                    // console.log(content);
                    if (len >= 0) { // 若是有多個loader則繼續執行下一個loader
                        startLoader();
                    }
                }
                startLoader();
            }
        }
        return content;
    }
    // 解析源碼內容並獲取其依賴
    parse(source, parentPath) {
        console.log("parse start.");
        console.log(`before parse ${source}`);
        // ① 將源碼內容解析爲AST抽象語法樹
        const ast = babylon.parse(source);
        // console.log(ast);
        const dependencies = []; // 保存模塊依賴
        // ② 遍歷AST抽象語法樹
        traverse(ast, {
            CallExpression(p) { // 找到require語句
                const node = p.node; // 對應的節點
                if (node.callee.name == "require") { // 把require替換成webpack本身的require方法,即__webpack_require__即
                    node.callee.name = "__webpack_require__"; 
                    let moduleName = node.arguments[0].value; // 獲取require的模塊名稱
                    if (moduleName) {
                        const extname = path.extname(moduleName) ? "" : ".js";
                        moduleName = moduleName + extname; // 若是引入的模塊沒有寫後綴名,則給它加上後綴名
                        moduleName = "./" + path.join(parentPath, moduleName);
                        // console.log(moduleName);
                        dependencies.push(moduleName);
                        // 將依賴文件的路徑替換爲相對於入口文件所在目錄
                        console.log(`moduleName is ${moduleName}`);
                        console.log(`types.stringLiteral(moduleName) is ${JSON.stringify(types.stringLiteral(moduleName))}`);
                        console.log(node);
                        console.log(node.arguments);
                        node.arguments = [types.stringLiteral(moduleName)];
                    }
                }
            }
        });
        // 處理完AST後,從新生成源碼
        const sourceCode = generator(ast).code;
        console.log(`after parse ${sourceCode}`);
        // 返回處理後的源碼,和入口文件依賴
        return {sourceCode, dependencies};

    }
    // 獲取源碼,交給loader處理,解析源碼進行一些修改替換,找到模塊依賴,遍歷依賴繼續解析依賴
    buildModule(modulePath, isEntry) { // 建立模塊的依賴關係
        console.log("buildModule start.");
        console.log(`modulePath is ${modulePath}`);
        // 獲取模塊內容,即源碼
        const source = this.getSource(modulePath);
        // 獲取模塊的相對路徑
        const moduleName = "./" + path.relative(this.root, modulePath); // 經過模塊的絕對路徑減去項目根目錄路徑,便可拿到模塊相對於根目錄的相對路徑
        if (isEntry) {
            this.entryId = moduleName; // 保存入口的相對路徑做爲entryId
        }
        // 解析源碼內容,將源碼中的依賴路徑進行改造,並返回依賴列表
        // console.log(path.dirname(moduleName));// 去除擴展名,返回目錄名,即"./src"
        const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName));
        console.log("source code");
        console.log(sourceCode);
        console.log(dependencies);
        this.modules[moduleName] = sourceCode; // 保存源碼
        // 遞歸查找依賴關係
        dependencies.forEach((dep) => {
            this.buildModule(path.join(this.root, dep), false);//("./src/a.js", false)("./src/index.less", false)
        });
    }
    emitFile() { // 發射打包後的輸出結果文件
        console.log("emit file start.");
        // 獲取輸出文件路徑
        const outputFile = path.join(this.config.output.path, this.config.output.filename);
        // 獲取輸出文件模板
        const templateStr = this.getSource(path.join(__dirname, "template.ejs"));
        // 渲染輸出文件模板
        const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules});
        this.assets = {};
        this.assets[outputFile] = code;
        // 將渲染後的代碼寫入輸出文件中
        fs.writeFileSync(outputFile, this.assets[outputFile]);
    }
    run() {
        this.hooks.compile.call(); // 執行編譯前的鉤子
        // 傳入入口文件的絕對路徑
        this.buildModule(path.resolve(this.root, this.entry), true); 
        this.hooks.afterCompile.call(); // 執行編譯結束後的鉤子
        // console.log(this.modules, this.entryId);
        this.emitFile();
        this.hooks.emit.call(); // 執行文件發射完成後的鉤子
        this.hooks.done.call(); // 執行打包完成後的鉤子
    }
}
module.exports = Compiler;
相關文章
相關標籤/搜索