玩轉webpack(一)上篇:webpack的基本架構和構建流程

歡迎你們前往騰訊雲社區,獲取更多騰訊海量技術實踐乾貨哦~javascript

做者介紹:陳柏信,騰訊前端開發,目前主要負責手Q遊戲中心業務開發,以及項目相關的技術升級、架構優化等工做。html

前言

webpack 是一個強大的模塊打包工具,之因此強大的一個緣由在於它擁有靈活、豐富的插件機制。可是 webpack 的文檔不太友好,就我的的學習經從來說,官方的文檔並不詳細,網上的學習資料又少有完整的概述和例子。因此,在研究了一段時間的 webpack 源碼以後,本身但願寫個系列文章,結合本身的實踐一塊兒來談談 webpack 插件這個主題,也但願可以幫助其餘人更全面地瞭解 webpack。前端

這篇文章是系列文章的第一篇,將會講述 webpack 的基本架構以及構建流程。vue

P.S. 如下的分析都基於 webpack 3.6.0java

webpack的基本架構

webpack 的基本架構,是基於一種相似事件的方式。下面的代碼中,對象可使用 plugin 函數來註冊一個事件,暫時能夠理解爲咱們熟悉的 addEventListener。但爲了區分概念,後續的討論中會將事件名稱爲 任務點,好比下面有四個任務點 compilation, optimize, compile, before-resolvenode

compiler.plugin("compilation", (compilation, callback) => {
    // 當Compilation實例生成時

    compilation.plugin("optimize", () => {
        // 當全部modules和chunks已生成,開始優化時
    })
})

compiler.plugin("compile", (params) => {
    // 當編譯器開始編譯模塊時

    let nmf = params.normalModuleFactory
    nmf.plugin("before-resolve", (data) => {
        // 在factory開始解析模塊前
    })
})

webpack 內部的大部分功能,都是經過這種註冊任務點的形式來實現的,這在後面中咱們很容易發現這一點。因此這裏直接拋出結論:webpack 的核心功能,是抽離成不少個內部插件來實現的。
那這些內部插件是如何對 webpack 產生做用的呢?在咱們開始運行 webpack 的時候,它會先建立一個 Compiler 實例,而後調用 WebpackOptionsApply 這個模塊給 Compiler 實例添加內部插件:webpack

// https://github.com/webpack/webpack/blob/master/lib/webpack.js#L37

compiler = new Compiler();
// 其餘代碼..
compiler.options = new WebpackOptionsApply().process(options, compiler);

WebpackOptionsApply 這個插件內部會根據咱們傳入的 webpack 配置來初始化須要的內部插件:git

// https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js

JsonpTemplatePlugin = require("./JsonpTemplatePlugin");
NodeSourcePlugin = require("./node/NodeSourcePlugin");
compiler.apply(
    new JsonpTemplatePlugin(options.output),
    new FunctionModulePlugin(options.output),
    new NodeSourcePlugin(options.node),
    new LoaderTargetPlugin(options.target)
);

// 其餘代碼..

compiler.apply(new EntryOptionPlugin());
compiler.applyPluginsBailResult("entry-option", options.context, options.entry);

compiler.apply(
    new CompatibilityPlugin(),
    new HarmonyModulesPlugin(options.module),
    new AMDPlugin(options.module, options.amd || {}),
    new CommonJsPlugin(options.module),
    new LoaderPlugin(),
    new NodeStuffPlugin(options.node),
    new RequireJsStuffPlugin(),
    new APIPlugin(),
    new ConstPlugin(),
    new UseStrictPlugin(),
    new RequireIncludePlugin(),
    new RequireEnsurePlugin(),
    new RequireContextPlugin(options.resolve.modules, options.resolve.extensions, options.resolve.mainFiles),
    new ImportPlugin(options.module),
    new SystemPlugin(options.module)
);

每個內部插件,都是經過監放任務點的方式,來實現自定義的邏輯。好比 JsonpTemplatePlugin 這個插件,是經過監聽 mainTemplate 對象的 require-ensure 任務點,來生成 jsonp 風格的代碼:github

// https://github.com/webpack/webpack/blob/master/lib/JsonpTemplatePlugin.js

mainTemplate.plugin("require-ensure", function(_, chunk, hash) {
    return this.asString([
        "var installedChunkData = installedChunks[chunkId];",
        "if(installedChunkData === 0) {",
        this.indent([
            "return new Promise(function(resolve) { resolve(); });"
        ]),
        "}",
        "",
        "// a Promise means \"currently loading\".",
        "if(installedChunkData) {",
        this.indent([
            "return installedChunkData[2];"
        ]),
        "}",
        "",
        "// setup Promise in chunk cache",
        "var promise = new Promise(function(resolve, reject) {",
        this.indent([
            "installedChunkData = installedChunks[chunkId] = [resolve, reject];"
        ]),
        "});",
        "installedChunkData[2] = promise;",
        "",
        "// start chunk loading",
        "var head = document.getElementsByTagName('head')[0];",
        this.applyPluginsWaterfall("jsonp-script", "", chunk, hash),
        "head.appendChild(script);",
        "",
        "return promise;"
    ]);
});

如今咱們理解了 webpack 的基本架構以後,可能會產生疑問,每一個插件應該監聽哪一個對象的哪一個任務點,又如何對實現特定功能呢?web

要徹底解答這個問題很難,緣由在於 webpack 中構建過程當中,會涉及到很是多的對象和任務點,要對每一個對象和任務點都進行討論是很困難的。可是,咱們仍然能夠挑選完整構建流程中涉及到的幾個核心對象和任務點,把 webpack 的構建流程講清楚,當咱們須要實現某個特定內容的時候,再去找對應的模塊源碼查閱任務點。

那麼下面咱們就來聊一聊 webpack 的構建流程。

webpack的構建流程

爲了更清楚和方便地討論構建流程,這裏按照我的理解整理了 webpack 構建流程中比較重要的幾個對象以及對應的任務點,而且按照構建順序畫出了流程圖:

  • 圖中每一列頂部名稱表示該列中任務點所屬的對象

  • 圖中每一行表示一個階段

  • 圖中每一個節點表示任務點名稱

  • 圖中每一個節點括號表示任務點的參數,參數帶有callback是異步任務點

  • 圖中的箭頭表示任務點的執行順序

  • 圖中虛線表示存在循環流程

上面展現的只是 webpack 構建的一部分,好比與 Module 相關的對象只畫出了 NormalModuleFactory,與 Template 相關的對象也只畫出了 MainTemplate等。緣由在於上面的流程圖已經足以說明主要的構建步驟,另外有沒畫出來的對象和任務點跟上述的相似,好比 ContextModuleFactoryNormalModuleFactory 是十分類似的對象,也有類似的任務點。有興趣的同窗能夠自行拓展探索流程圖。*

流程圖中已經展現了一些核心任務點對應的對象以及觸發順序,可是咱們仍然不明白這些任務點有什麼含義。因此剩下的內容會詳細講解 webpack 一些任務點詳細的動做,按照我的理解將流程圖分紅了水平的三行,表示三個階段,分別是:

  1. webpack的準備階段

  2. modules和chunks的生成階段

  3. 文件生成階段

webpack的準備階段

這個階段的主要工做,是建立 CompilerCompilation 實例。

首先咱們從 webpack 的運行開始講起,在前面咱們大概地講過,當咱們開始運行 webpack 的時候,就會建立 Compiler 實例而且加載內部插件。這裏跟構建流程相關性比較大的內部插件是 EntryOptionPlugin,咱們來看看它到底作了什麼:

// https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js
compiler.apply(new EntryOptionPlugin());
compiler.applyPluginsBailResult("entry-option", options.context, options.entry); // 立刻觸發任務點運行 EntryOptionPlugin 內部邏輯

// https://github.com/webpack/webpack/blob/master/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {
    apply(compiler) {
        compiler.plugin("entry-option", (context, entry) => {
            if(typeof entry === "string" || Array.isArray(entry)) {
                compiler.apply(itemToPlugin(context, entry, "main"));
            } else if(typeof entry === "object") {
                Object.keys(entry).forEach(name => compiler.apply(itemToPlugin(context, entry[name], name)));
            } else if(typeof entry === "function") {
                compiler.apply(new DynamicEntryPlugin(context, entry));
            }
            return true;
        });
    }
};

EntryOptionPlugin 的代碼只有寥寥數行可是很是重要,它會解析傳給 webpack 的配置中的 entry 屬性,而後生成不一樣的插件應用到 Compiler 實例上。這些插件多是 SingleEntryPlugin, MultiEntryPlugin 或者 DynamicEntryPlugin。但不論是哪一個插件,內部都會監聽 Compiler 實例對象的 make 任務點,以 SingleEntryPlugin 爲例:

// https://github.com/webpack/webpack/blob/master/lib/SingleEntryPlugin.js

class SingleEntryPlugin {
    // 其餘代碼..
    apply(compiler) {
        // 其餘代碼..
        compiler.plugin("make", (compilation, callback) => {
            const dep = SingleEntryPlugin.createDependency(this.entry, this.name);
            compilation.addEntry(this.context, dep, this.name, callback);
        });
    }
}

這裏的 make 任務點將成爲後面解析 modules 和 chunks 的起點。

除了 EntryOptionPlugin,其餘的內部插件也會監聽特定的任務點來完成特定的邏輯,但咱們這裏再也不仔細討論。當 Compiler 實例加載完內部插件以後,下一步就會直接調用 compiler.run 方法來啓動構建,任務點 run 也是在此時觸發,值得注意的是此時基本只有 options 屬性是解析完成的:

// 監放任務點 run
compiler.plugin("run", (compiler, callback) => {
    console.log(compiler.options) // 能夠看到解析後的配置
    callback()
})

另外要注意的一點是,任務點 run 只有在 webpack 以正常模式運行的狀況下會觸發,若是咱們以監聽(watch)的模式運行 webpack,那麼任務點 run 是不會觸發的,可是會觸發任務點 watch-run

接下來, Compiler 對象會開始實例化兩個核心的工廠對象,分別是 NormalModuleFactoryContextModuleFactory。工廠對象顧名思義就是用來建立實例的,它們後續用來建立 NormalModule 以及 ContextModule 實例,這兩個工廠對象會在任務點 compile 觸發時傳遞過去,因此任務點 compile 是間接監聽這兩個對象的任務點的一個入口:

// 監放任務點 compile
compiler.plugin("compile", (params) => {
    let nmf = params.normalModuleFactory
    nmf.plugin("before-resolve", (data, callback) => {
        // ...
    })
})

下一步 Compiler 實例將會開始建立 Compilation 對象,這個對象是後續構建流程中最核心最重要的對象,它包含了一次構建過程當中全部的數據。也就是說一次構建過程對應一個 Compilation 實例。在建立 Compilation 實例時會觸發任務點 compilaiionthis-compilation

// https://github.com/webpack/webpack/blob/master/lib/Compiler.js

class Compiler extends Tapable {

    // 其餘代碼..

    newCompilation(params) {
        const compilation = this.createCompilation();
        compilation.fileTimestamps = this.fileTimestamps;
        compilation.contextTimestamps = this.contextTimestamps;
        compilation.name = this.name;
        compilation.records = this.records;
        compilation.compilationDependencies = params.compilationDependencies;
        this.applyPlugins("this-compilation", compilation, params);
        this.applyPlugins("compilation", compilation, params);
        return compilation;
    }
}

這裏爲何會有 compilationthis-compilation 兩個任務點?實際上是跟子編譯器有關,Compiler 實例經過 createChildCompiler 方法能夠建立子編譯器實例 childCompiler,建立時 childCompiler 會複製 compiler 實例的任務點監聽器。任務點 compilation 的監聽器會被複制,而任務點 this-compilation 的監聽器不會被複制。 更多關於子編譯器的內容,將在下一篇文章中討論。

compilationthis-compilation 是最快可以獲取到 Compilation 實例的任務點,若是你的插件功能須要儘早對 Compilation 實例進行一些操做,那麼這兩個任務點是首選:

// 監聽 this-compilation 任務點
compiler.plugin("this-compilation", (compilation, params) => {
    console.log(compilation.options === compiler.options) // true
    console.log(compilation.compiler === compiler) // true
    console.log(compilation)
})

Compilation 實例建立完成以後,webpack 的準備階段已經完成,下一步將開始 modules 和 chunks 的生成階段。

modules 和 chunks 的生成階段

**這個階段的主要內容,是先解析項目依賴的全部 modules,再根據 modules 生成 chunks。
module 解析,包含了三個主要步驟:建立實例、loaders應用以及依賴收集。
chunks 生成,主要步驟是找到 chunk 所須要包含的 modules。**

當上一個階段完成以後,下一個任務點 make 將被觸發,此時內部插件 SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin的監聽器會開始執行。監聽器都會調用 Compilation 實例的 addEntry 方法,該方法將會觸發第一批 module 的解析,這些 module 就是 entry 中配置的模塊。

咱們先講一個 module 解析完成以後的操做,它會遞歸調用它所依賴的 modules 進行解析,因此當解析中止時,咱們就可以獲得項目中全部依賴的 modules,它們將存儲在 Compilation 實例的 modules 屬性中,並觸發任務點 finish-modules

// 監聽 finish-modules 任務點
compiler.plugin("this-compilation", (compilation) => {
    compilation.plugin("finish-modules", (modules) => {
        console.log(modules === compilation.modules) // true
        modules.forEach(module => {
            console.log(module._source.source()) // 處理後的源碼
        })
    })
})

下面將以 NormalModule 爲例講解一下 module 的解析過程,ContextModule 等其餘模塊實例的處理是相似的。
第一個步驟是建立 NormalModule 實例。這裏須要用到上一個階段講到的 NormalModuleFactory 實例, NormalModuleFactorycreate 方法是建立 NormalModule 實例的入口,內部的主要過程是解析 module 須要用到的一些屬性,好比須要用到的 loaders, 資源路徑 resource 等等,最終將解析完畢的參數傳給 NormalModule 構建函數直接實例化:

// https://github.com/webpack/webpack/blob/master/lib/NormalModuleFactory.js

// 以 require("raw-loader!./a") 爲例
// 而且對 .js 後綴配置了 babel-loader
createdModule = new NormalModule(
    result.request,      // <raw-loader>!<babel-loader>!/path/to/a.js
    result.userRequest,  // <raw-loader>!/path/to/a.js
    result.rawRequest,   // raw-loader!./a.js
    result.loaders,      // [<raw-loader>, <babel-loader>]
    result.resource,     // /path/to/a.js
    result.parser
);

這裏在解析參數的過程當中,有兩個比較實用的任務點 before-resolveafter-resolve,分別對應瞭解析參數前和解析參數後的時間點。舉個例子,在任務點 before-resolve 能夠作到忽略某個 module 的解析,webpack 內部插件 IgnorePlugin 就是這麼作的:

// https://github.com/webpack/webpack/blob/master/lib/IgnorePlugin.js

class IgnorePlugin {
    checkIgnore(result, callback) {
        // check if result is ignored
        if(this.checkResult(result)) {
            return callback(); // callback第二個參數爲 undefined 時會終止module解析
        }
        return callback(null, result);
    }

    apply(compiler) {
        compiler.plugin("normal-module-factory", (nmf) => {
            nmf.plugin("before-resolve", this.checkIgnore);
        });
        compiler.plugin("context-module-factory", (cmf) => {
            cmf.plugin("before-resolve", this.checkIgnore);
        });
    }
}

在建立完 NormalModule 實例以後會調用 build 方法繼續進行內部的構建。咱們熟悉的 loaders 將會在這裏開始應用NormalModule 實例中的 loaders 屬性已經記錄了該模塊須要應用的 loaders。應用 loaders 的過程相對簡單,直接調用loader-runner 這個模塊便可:

// https://github.com/webpack/webpack/blob/master/lib/NormalModule.js

const runLoaders = require("loader-runner").runLoaders;
// 其餘代碼..
class NormalModule extends Module {
    // 其餘代碼..
    doBuild(options, compilation, resolver, fs, callback) {
        this.cacheable = false;
        const loaderContext = this.createLoaderContext(resolver, options, compilation, fs);

        runLoaders({
            resource: this.resource,
            loaders: this.loaders,
            context: loaderContext,
            readResource: fs.readFile.bind(fs)
        }, (err, result) => {
            // 其餘代碼..
        });
    }
}

webpack 中要求 NormalModule 最終都是 js 模塊,因此 loader 的做用之一是將不一樣的資源文件轉化成 js 模塊。好比 html-loader 是將 html 轉化成一個 js 模塊。在應用完 loaders 以後,NormalModule 實例的源碼必然就是 js 代碼,這對下一個步驟很重要。

下一步咱們須要獲得這個 module 所依賴的其餘模塊,因此就有一個依賴收集的過程。webpack 的依賴收集過程是將 js 源碼傳給 js parser(webpack 使用的 parser 是 acorn):

// https://github.com/webpack/webpack/blob/master/lib/NormalModule.js

class NormalModule extends Module {
    // 其餘代碼..
    build(options, compilation, resolver, fs, callback) {
        // 其餘代碼..
        return this.doBuild(options, compilation, resolver, fs, (err) => {
            // 其餘代碼..
            try {
                this.parser.parse(this._source.source(), {
                    current: this,
                    module: this,
                    compilation: compilation,
                    options: options
                });
            } catch(e) {
                const source = this._source.source();
                const error = new ModuleParseError(this, source, e);
                this.markModuleAsErrored(error);
                return callback();
            }
            return callback();
        });
    }
}

parser 將 js 源碼解析後獲得對應的AST(抽象語法樹, Abstract Syntax Tree)。而後 webpack 會遍歷 AST,按照必定規則觸發任務點。 好比 js 源碼中有一個表達式:a.b.c,那麼 parser 對象就會觸發任務點 expression a.b.c更多相關的規則 webpack 在官網有羅列出來,你們能夠對照着使用。

有了AST對應的任務點,依賴收集就相對簡單了,好比遇到任務點 call require,說明在代碼中是有調用了require函數,那麼就應該給 module 添加新的依賴。webpack 關於這部分的處理是比較複雜的,由於 webpack 要兼容多種不一樣的依賴方式,好比 AMD 規範、CommonJS規範,而後還要區分動態引用的狀況,好比使用了 require.ensure, require.context。但這些細節對於咱們討論構建流程並非必須的,由於不展開細節討論。

parser 解析完成以後,module 的解析過程就完成了。每一個 module 解析完成以後,都會觸發 Compilation 實例對象的任務點 succeed-module,咱們能夠在這個任務點獲取到剛解析完的 module 對象。正如前面所說,module 接下來還要繼續遞歸解析它的依賴模塊,最終咱們會獲得項目所依賴的全部 modules。此時任務點 make 結束。

繼續往下走,Compialtion 實例的 seal 方法會被調用並立刻觸發任務點 seal。在這個任務點,咱們能夠拿到全部解析完成的 module:

// 監聽 seal 任務點
compiler.plugin("this-compilation", (compilation) => {
    console.log(compilation.modules.length === 0) // true
    compilation.plugin("seal", () => {
        console.log(compilation.modules.length > 0) // true
    })
})

有了全部的 modules 以後,webpack 會開始生成 chunks。webpack 中的 chunk 概念,要不就是配置在 entry 中的模塊,要不就是動態引入(好比 require.ensure)的模塊。這些 chunk 對象是 webpack 生成最終文件的一個重要依據。

每一個 chunk 的生成就是找到須要包含的 modules。這裏大體描述一下 chunk 的生成算法:

  1. webpack 先將 entry 中對應的 module 都生成一個新的 chunk

  2. 遍歷 module 的依賴列表,將依賴的 module 也加入到 chunk 中

  3. 若是一個依賴 module 是動態引入的模塊,那麼就會根據這個 module 建立一個新的 chunk,繼續遍歷依賴

  4. 重複上面的過程,直至獲得全部的 chunks

在全部 chunks 生成以後,webpack 會對 chunks 和 modules 進行一些優化相關的操做,好比分配id、排序等,而且觸發一系列相關的任務點:

// https://github.com/webpack/webpack/blob/master/lib/Compilation.js

class Compilation extends Tapable {
    // 其餘代碼 ..
    seal(callback) {
        // 生成 chunks 代碼..
        self.applyPlugins0("optimize");

        while(self.applyPluginsBailResult1("optimize-modules-basic", self.modules) ||
            self.applyPluginsBailResult1("optimize-modules", self.modules) ||
            self.applyPluginsBailResult1("optimize-modules-advanced", self.modules)) { /* empty */ }
        self.applyPlugins1("after-optimize-modules", self.modules);

        while(self.applyPluginsBailResult1("optimize-chunks-basic", self.chunks) ||
            self.applyPluginsBailResult1("optimize-chunks", self.chunks) ||
            self.applyPluginsBailResult1("optimize-chunks-advanced", self.chunks)) { /* empty */ }
        self.applyPlugins1("after-optimize-chunks", self.chunks);

        self.applyPluginsAsyncSeries("optimize-tree", self.chunks, self.modules, function sealPart2(err) {
            if(err) {
                return callback(err);
            }

            self.applyPlugins2("after-optimize-tree", self.chunks, self.modules);

            while(self.applyPluginsBailResult("optimize-chunk-modules-basic", self.chunks, self.modules) ||
                self.applyPluginsBailResult("optimize-chunk-modules", self.chunks, self.modules) ||
                self.applyPluginsBailResult("optimize-chunk-modules-advanced", self.chunks, self.modules)) { /* empty */ }
            self.applyPlugins2("after-optimize-chunk-modules", self.chunks, self.modules);

            const shouldRecord = self.applyPluginsBailResult("should-record") !== false;

            self.applyPlugins2("revive-modules", self.modules, self.records);
            self.applyPlugins1("optimize-module-order", self.modules);
            self.applyPlugins1("advanced-optimize-module-order", self.modules);
            self.applyPlugins1("before-module-ids", self.modules);
            self.applyPlugins1("module-ids", self.modules);
            self.applyModuleIds();
            self.applyPlugins1("optimize-module-ids", self.modules);
            self.applyPlugins1("after-optimize-module-ids", self.modules);

            self.sortItemsWithModuleIds();

            self.applyPlugins2("revive-chunks", self.chunks, self.records);
            self.applyPlugins1("optimize-chunk-order", self.chunks);
            self.applyPlugins1("before-chunk-ids", self.chunks);
            self.applyChunkIds();
            self.applyPlugins1("optimize-chunk-ids", self.chunks);
            self.applyPlugins1("after-optimize-chunk-ids", self.chunks);

            // 其餘代碼..
        })
    }
}

這些任務點通常是 webpack.optimize 屬性下的插件會使用到,好比 CommonsChunkPlugin 會使用到任務點 optimize-chunks,但這裏咱們不深刻討論。

至此,modules 和 chunks 的生成階段結束。接下來是文件生成階段。

本文來源於 小時光茶社 微信公衆號

相關閱讀

玩轉webpack(一)下篇:webpack的基本架構和構建流程
Webpack + vue 之抽離 CSS 的正確姿式
apt 與 JavaPoet 自動生成代碼

相關文章
相關標籤/搜索