Webpack升級優化小記:happyPack+dll初體驗

天幕之藍Lan發表於2018-12-09

最近學習了webpack4的使用,並嘗試了對專案webpack進行升級和優化,記錄一下此次升級的一些實踐過程。

痛苦的開發體驗

漫長的等待

專案在2016年引入了webpack作為打包工具,並使用vue-cli搭建build相關的程式碼,之後再無較大更新。隨著專案迭代至今,程式碼量早已不是當年寥寥的幾千行,本地啟動開發環境也從當年的十幾秒暴增至現在200s以上,每次run dev或者rebuild都伴隨著長時間目光呆滯的等待

混亂之治

在兩年多的時間跨度裡,專案的構建程式碼被無數人反覆修改,充斥著冗餘、雜亂以及只有上帝才能理解的邏輯。同事在做主題功能時不得不另起爐灶,單獨開了一個用webpack4構建的小工程隱藏在css資料夾下的某個角落,等待著未來某一天專案隨webpack4的大一統而重見天日。webpack2和webpack4並存迫使team裡每個小夥伴都要開兩個終端,一個跑專案,另一個跑樣式

概括一下就是:build太慢,構建程式碼混亂,小夥伴們的開發效率低下

怎麼解決?少說廢話,麻利兒的升級webpack4

提升構建效率

提升打包效率,可以簡單的概括為兩條路:

  1. 提升單位時間內的打包速度
  2. 清理不必要打包的檔案

多管齊下:happyPack

如同這個外掛的名字一樣,用完之後確實能讓人happy,打包速度提升的不是一星半點,原理就是開啟多個node子程式並行的用各種loader去處理待打包的原始檔,換言之即提升單位時間內的打包速度

happyPack原理示意

引用happyPack官方的說法:

HappyPack sits between webpack and your primary source files (like JS sources) where the bulk of loader transformations happen. Every time webpack resolves a module, HappyPack will take it and all its dependencies and distributes those files to multiple worker "threads".

Those threads are actually simple node processes that invoke your transformer. When the compiled version is retrieved, HappyPack serves it to its loader and eventually your chunk.

拿自己的本子做實驗,比公司的電腦效能要好一些,公司的本按webpack2的配置跑一直都在200s以上,重啟電腦後初次build甚至直逼5分鐘

  • 專案使用webpack2本地啟動耗時
    Webpack升級優化小記:happyPack+dll初體驗
  • 使用webpack4本地啟動耗時
    Webpack升級優化小記:happyPack+dll初體驗
  • webpack4 + happyPack(babel-loader) 本地啟動耗時
    Webpack升級優化小記:happyPack+dll初體驗
  • webpack4 + happyPack(babel-loader + eslint-loader) 本地啟動耗時
    Webpack升級優化小記:happyPack+dll初體驗

從實驗結果可以看到,使用happyPack之後編譯速度提升非常明顯,時間上縮短了近55%,優化效果是顯著的

happyPack支援很多常用的loader(happyPack相容性列表),可以在webpack配置中使用多個happyPack例項,用不同的loader分開處理,例如對.js檔案先後進行eslint-loader和babel-loader,並且可以通過happyPack建立ThreadPool使這些happyPack例項共享一個執行緒池,提升資源的利用率。

關於happyPack的配置和使用,官方文件上寫的很清晰,百度一下也有大量的教程性文章可以參考,這裡不再詳細介紹

用dll剝離第三方庫

專案中難免會使用一些第三方的庫,除非版本升級,一般情況下,這些庫的程式碼不會發生較大變動,這也就意味著這些庫沒有必要每次都參與到構建和rebuild的過程中。如果能把這部分程式碼提取出來並提前構建好,那麼在構建專案的時候就可以直接跳過第三方庫,進一步提升效率,換言之即清理不必要打包的檔案

dllPlugin+dllReferencePlugin

dll是微軟實現共享函式庫概念的一種方式(百度百科說的),本身不可被執行,供其他程式呼叫。這裡借鑑了dll的思想,webpack提供了內建外掛dllPlugin+dllReferencePlugin,可以輕鬆搞定這件事,只需要做好這幾件事就可以了:

  1. 獨立出一套webpack配置webpack.dll.conf,用dllPlugin定義要打包的dll檔案
  2. 執行webpack.dll.conf生成xxx.dll.js及相應的manifest檔案manifest-xxx.json,並在專案模板index.html中引入各個xxx.dll.js
  3. 在專案的webpack配置中,通過dllReferencePlugin及manifest-xxx.json告訴webpack哪些包已經提前構建好了,不再需要重複構建

webpack4 + happyPack(xxx-loader) + dll 本地啟動耗時

Webpack升級優化小記:happyPack+dll初體驗

從71s到45s,這又是一個不小的進步,時間進一步縮短了近40%,相比較最初的webpack2編譯耗時,效率增加了71%,即便是用公司的本子,效率也至少能增加50%以上。看到這個結果,筆者的第一反應是:臥槽!!!好吧,這種慨嘆除了包含對結果的驚訝,更多的是沒想到以前的構建低效的令人髮指。

稍稍優化一下效能

除了減少程式碼的打包時間,使用dll還有助於網頁效能的優化。通常我們會把第三方庫提取到檔名為vendors的程式碼塊裡,這樣做的好處是防止公共依賴被重複打包,同時其變化頻率較低,在生產環境下具有相對穩定的雜湊值,可充分利用瀏覽器的快取策略減少對vendors檔案的請求。但可能導致單個js檔案體積過大,當重新請求資源時會產生比較明顯的阻塞。使用dll之後,因為大量的第三方庫被提前提取,vendors的體積相應減小,請求vendors檔案的網路開銷也相應降低

不使用dll,vendors的體積

Webpack升級優化小記:happyPack+dll初體驗
使用dll後vendors的體積
Webpack升級優化小記:happyPack+dll初體驗

有些同學可能會有疑惑,雖然vendors的體積降低了,但是減少的部分只是換了個地方,被提取到xxx.dll.js檔案裡而已,該請求的還是要請求,總的開銷並沒有減少。其實dll本身可以通過配置多個入口繼續拆分,通過瀏覽器的併發請求進一步優化請求dll檔案的效能。

{
    entry: {
        vue: ['vue', 'vuex', 'vue-router'], // vue全家桶dll: vue.dll.js
        ec: ['echarts', 'echarts-wordcloud'], // echarts相關dll: ec.dll.js
        commons: [
            // 其他第三方庫: commons.dll.js
        ]
    }
}
複製程式碼

當然,即使是在開發環境下,3.88M的vendors包仍然很大,這裡只是展現一下通過dll剝離第三方庫的效果,關於程式碼分割及其相關的優化不在這裡詳細討論。

一些小坑

關於外掛的配置及使用,需要注意的是webpack.dll.conf中,output暴露出的library名稱需要與DllPlugin的name相同,官方文件中也有強調

{
    output: {
        filename: '[name].dll.js',
        path: path.resolve(__dirname, '..', 'lib/dll'),
        library: '[name]_[hash]'
        // vendor.dll.js中暴露出的全域性變數名,DllPlugin中會使用此名稱作為manifest的name,
        // 故這裡需要和webpack.DllPlugin中的 name: '[name]_[hash]' 保持一致。
    },
    plugins: [
        new webpack.DllPlugin({
            path: utils.resolve('lib/dll/manifest-[name].json'),
            name: "[name]_[hash]" // 和library保持一致
        })
    ]
}
複製程式碼

此外,vue預設使用runtime包,在開發環境下,如果需要vue編譯模板,比如這樣使用:

new Vue({
    template: '<div>{{ hi }}</div>'
})
複製程式碼

則必須引入完整版的vue包,在webpack的alias配置中需要這樣寫(參考vue文件):

module.exports = {
  // ...
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js' // 用 webpack 1 時需用 'vue/dist/vue.common.js'
    }
  }
}
複製程式碼

這也就意味著webpack.dll.conf中對vue的引用要與專案中保持一致,否則在構建專案時不會跳過對vue的打包

關於dllPlugindllReferencePlugin這兩個外掛具體的配置和使用,官方文件給出了使用示例,百度一下也有大量的教程性文章可以參考,這裡不再詳細介紹

dll加速與manifest探究

如果只是想了解如何提升構建效率,那麼這部分可以直接跳過了

在筆者完成配置後,並不是一下就達到了45s的水平,第一次啟動時效果並不是很好,沒有明顯的效率提升,那折騰半天弄啥咧?加上dll之後打包時間並沒有明顯的縮短,說明仍然有第三方庫進入了打包流程。webpack中有一個manifest的概念,筆者只知道與模組的對映和載入有關,並不清楚具體的內容,所以當時也只是猜測與此相關,沿著這條路繼續往下排查。果然,在使用dllReferencePlugin時少引了幾個manifest.json檔案,這純粹是因為筆者疏忽大意,沒仔細看文件(所以好好看文件很重要啊),卻也藉此機會簡單的研究了一下manifest是什麼鬼,以及為啥使用dll能加速。

打包dll出來的是什麼

檢視一下執行完webpack.dll.conf之後生成了哪些檔案

Webpack升級優化小記:happyPack+dll初體驗

對於多入口的情況,每個入口檔案都會生成一個dll檔案及一個json檔案,以vue為例,看看vue.dll.js和manifest-vue.json這兩個檔案裡都是什麼東東

vue.dll.js:

var vue_01cf92ee1ec06f1bc497 = 
    (function(modules) { // webpackBootstrap
        var installedModules = {};
        function __webpack_require__(moduleId) {
            // __webpack_require__ source code
        }
        
        return __webpack_require__(__webpack_require__.s = 0)
    })
    ({
        "./node_modules/vue/dist/vue.esm.js":
            (function (module, __webpack_exports__, __webpack_require__)) {
                "use strict";
                eval("xxx"); // webpack require vue
            }),
        // 其他模組...
        // ...
        0: (function (module, exports, __webpack_require__) {
            eval("module.exports = __webpack_require__;\n\n//# sourceURL=webpack://%5Bname%5D_%5Bhash%5D/dll_vue?");
        })
    })
複製程式碼

上面這段立即執行函式看起來稍微有點費勁,我們換一種寫法並保留部分細節

var requireModules = function(modules) { // webpackBootstrap
    var installedModules = {};
    function __webpack_require__(moduleId) {
        if (installedModules[moduleId]) { // 檢測模組是否已經載入
            return installedModules[moduleId].exports;
        }
    
        var module = installedModules[moduleId] = { // 建立模組
            i: moduleId,
            l: false,
            exports: {}
        };
    
        // 載入模組
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        // 標記模組已經被載入
        module.l = true;
        // 返回模組匯出的內容
        return module.exports;
    }
    
    // 定義__webpack_require__的屬性和方法
    // __webpack_require__.xxx = xxx
    // ...
    
    return __webpack_require__(__webpack_require__.s = 0); // 執行modules[0],暴露出vue.dll.js內部模組的載入器
}

var modules = {
    "./node_modules/vue/dist/vue.esm.js":                               // 模組id
        function (module, __webpack_exports__, __webpack_require__)) {  // 模組載入函式
            eval("xxx");                                                // webpack載入vue
        },
    // 其他模組
    // ...
    
    // 暴露載入器
    0: function (module, exports, __webpack_require__) {                // 整個vue.dll.js模組
        // 暴露vue.dll.js的內部模組載入器,供外部呼叫並載入vue相關的模組
        eval("module.exports = __webpack_require__;\n\n//# sourceURL=webpack://%5Bname%5D_%5Bhash%5D/dll_vue?");
    }
}

var vue_01cf92ee1ec06f1bc497 = requireModules(modules);
複製程式碼

dll檔案中做了如下幾件事情:

  • 定義了各個子模組載入函式的對映表,即字面量物件modules
  • 定義了內部載入器__webpack_require__及模組快取installedModules
  • 通過requireModule函式將內部載入器暴露給了全域性變數vue_01cf92ee1ec06f1bc497,供外部載入模組時呼叫

當index.html中引入了vue.dll.js之後,dll內部模組的載入器就被暴露在global下,webpack載入模組時就可以直接呼叫vue_01cf92ee1ec06f1bc497,最終結果等效為:

    var vue_01cf92ee1ec06f1bc497 = __webpack_require__; // 被閉包在內部的載入器
複製程式碼

所以vue.dll.js本身不能執行內部模組的程式碼,只是提供給外部去呼叫,這也正是dll檔案的定義

光有dll還不行,專案的webpack需要知道dll暴露出了一個叫vue_01cf92ee1ec06f1bc497的載入器,以及這個載入器內部包含了哪些模組,而manifest檔案就包含了這些資訊。

manifest-vue.json:

{
    "name": "vue_01cf92ee1ec06f1bc497",
    "content": {
        "./node_modules/vue/dist/vue.esm.js": {
            "id": "./node_modules/vue/dist/vue.esm.js",
            "buildMeta": {
                "exportsType": "namespace",
                "providedExports": ["default"]
            }
        }
    }
}
複製程式碼

manifest中保留了模組來源的詳細資訊,並將其作為模組檢索的id,同時還指明瞭載入這些模組需要用哪個__webpack_require__載入器,在程式執行時__webpack_require__能夠通過模組id載入對應的模組,參考webpack官方的解釋:

As the compiler enters, resolves, and maps out your application, it keeps detailed notes on all your modules. This collection of data is called the "Manifest" and it's what the runtime will use to resolve and load modules once they've been bundled and shipped to the browser. No matter which module syntax you have chosen, those import or require statements have now become webpack_require methods that point to module identifiers. Using the data in the manifest, the runtime will be able to find out where to retrieve the modules behind the identifiers.

告訴專案dll在哪,裡面有什麼

有了manifest,怎麼告訴專案我有dll,不需要重複打包呢?DLLReferencePlugin把manifest檔案傳遞給了專案的webpack,告訴它哪些模組是可以直接引用的,打包過程可以跳過。DllReferencePlugin.js中讀取了manifest檔案,把dll暴露的載入器以外部依賴的形式掛載到webpack的模組工廠。

讀取manifest:

compiler.hooks.beforeCompile.tapAsync( // webpack建立compilation前的鉤子,讀取dll中的模組資訊(manifest)
    "DllReferencePlugin",
    (params, callback) => {
        if ("manifest" in this.options) {
            const manifest = this.options.manifest;
            if (typeof manifest === "string") {
                params.compilationDependencies.add(manifest);
                compiler.inputFileSystem.readFile(manifest, (err, result) => { // 讀取manifest檔案
                    params["dll reference " + manifest] = parseJson(result.toString("utf-8"));
                    return callback();
                });
                return;
            }
        }
        return callback();
    }
);
複製程式碼

建立外部依賴:

// webpack建立compilation後的鉤子,告訴webpack我有個dll以及dll裡都有哪些模組
compiler.hooks.compile.tap("DllReferencePlugin", params => {
    // 讀取manifest中的配置
    let manifest = this.options.manifest;
    if (typeof manifest === 'string') {
        manifest = params["dll reference " + manifest];
    }
    let name = this.options.name || manifest.name;
    let sourceType = this.options.sourceType || manifest.sourceType;
    let content = this.options.content || manifest.content;

    // 建立外部依賴
    const externals = {};
    const source = "dll-reference " + name; // 告訴webpack暴露出的全域性變數,並以dll-reference作為字首表示這是一個dll資源
    externals[source] = name; // 資源名稱:vue_01cf92ee1ec06f1bc497
    const normalModuleFactory = params.normalModuleFactory;
    // 引入外部模組工廠外掛,以外部依賴的方式掛載dll
    new ExternalModuleFactoryPlugin(sourceType || "var", externals).apply(
        normalModuleFactory
    );
    // 引入代理模組工廠外掛,為dll中的每個模組建立代理
    new DelegatedModuleFactoryPlugin({
        source: source,
        type: this.options.type,
        scope: this.options.scope,
        context: this.options.context || compiler.options.context,
        content,
        extensions: this.options.extensions
    }).apply(normalModuleFactory);
});
複製程式碼

可以看到webpack是通過manifest.name來匹配dll資源的,這也是為什麼在webpack.dll.conf中,DllPlugin的name屬性必須要與output的library屬性一致的原因

webpack建立模組的過程在normalModuleFactory中完成,它包含了一些內建的鉤子函式,用於在模組解析、建立時新增處理邏輯。這裡引入了兩個關鍵的外掛ExternalModuleFactoryPluginDelegatedModuleFactoryPlugin,它們在normalModuleFactory的鉤子函式中做了什麼呢?

建立dll模組,加速打包

在專案webpack的compilation真正開始前,已經得到了所有dll的資訊,剩下的就交給webpack的normalModuleFactory自己去處理了。ExternalModuleFactoryPluginDelegatedModuleFactoryPlugin這兩個外掛分別在factory鉤子(建立模組工廠)、module鉤子(建立模組)中新增了自己的回撥函式,讓webpack在解析模組時會先去從外部依賴中查詢,如果找到了就直接建立一個模組代理物件,在build階段不再使用loader處理模組,否則建立一個普通模組物件,在build階段用loader載入資源。

結合DllReferencePlugin,整體流程如下:

Webpack升級優化小記:happyPack+dll初體驗

進入normalModuleFactory的流程之後,首先在factory鉤子中獲取建立外部模組的工廠函式,ExternalModuleFactoryPlugin外掛在factory鉤子中定義了工廠函式:

// ExternalModuleFactoryPlugin.js
normalModuleFactory.hooks.factory.tap( // factory鉤子
    "ExternalModuleFactoryPlugin",
    factory => (data, callback) => { // 返回一個建立外部模組的工廠函式
        const context = data.context;
        const dependency = data.dependencies[0];

        const handleExternal = (value, type, callback) => {
            // 輸入引數的整理
            // ...
            
            callback(
                null,
                new ExternalModule(value, type || globalType, dependency.request) // 為dll建立一個外部模組
            );
            return true;
        };

        const handleExternals = (externals, callback) => {
            // 對Array、Object等不同型別externals的處理
            // ...

            if (
                typeof externals === "object" &&
                Object.prototype.hasOwnProperty.call(externals, dependency.request)
            ) {
                return handleExternal(externals[dependency.request], callback); // 如果請求的資源是外部資源,則建立外部模組物件
            }
            callback();
        };

        handleExternals(this.externals, (err, module) => {
            if (err) return callback(err);
            if (!module) return handleExternal(false, callback);
            return callback(null, module); // 通過傳入的callback,將剛剛建立的外部模組傳回到webpack的模組構建流程中
        });
    }
);
複製程式碼

factory鉤子返回了這個工廠函式,它會被normalModuleFactory立即呼叫,vue_01cf92ee1ec06f1bc497就被作為一個外部模組掛載到normalModuleFactory中

工廠建立好之後,normalModuleFactory就會進入模組解析的過程(resolver),在解析結束之後為解析結果預設建立一個NormalModule物件,並將其作為引數傳入module鉤子函式。在module鉤子中,DelegatedModuleFactoryPlugin會判斷傳入的NormalModule是否存在於dll,如果存在則建立一個代理物件並返回,否則直接返回NormalModule

normalModuleFactory.hooks.module.tap(
    "DelegatedModuleFactoryPlugin",
    module => {
        if (module.libIdent) {
            const request = module.libIdent(this.options);
            if (request && request in this.options.content) { // option.content就是manifest中的content
                const resolved = this.options.content[request];
                return new DelegatedModule( // 為dll中的模組建立代理
                    this.options.source, // vue_01cf92ee1ec06f1bc497
                    resolved,
                    this.options.type,
                    request,
                    module
                );
            }
        }
        return module;
    }
);
複製程式碼

檢視DelegatedModule類的定義,可以看到needRebuild方法直接返回了false,build方法直接將模組標記為built,並加入相關依賴,沒有執行loader,因此在程式碼構建時dll中的模組被跳過,不會參與打包過程

class DelegatedModule extends Module {
    constructor(request, type, userRequest) {}

    needRebuild(fileTimestamps, contextTimestamps) {
        return false; // 跳過rebuild過程
    }

    build(options, compilation, resolver, fs, callback) {
        this.built = true; // 標記模組為“已構建”狀態
        this.buildMeta = Object.assign({}, this.delegateData.buildMeta);
        this.buildInfo = {};
        this.delegatedSourceDependency = new DelegatedSourceDependency(
            this.sourceRequest
        );
        this.addDependency(this.delegatedSourceDependency); // 加入代理的相關依賴
        this.addDependency(
            new DelegatedExportsDependency(this, this.delegateData.exports || true)
        );
        callback();
    }

    // 其他方法
    // ...
}
複製程式碼

相比較而言,普通模組則會參與打包和rebuild的過程

class NormalModule extends Module {
    constructor(request, type, userRequest) {}

    needRebuild(fileTimestamps, contextTimestamps) {
        // rebuild判定程式碼
        // ...
    }

    build(options, compilation, resolver, fs, callback) {
        return this.doBuild();
    }

    doBuild(options, compilation, resolver, fs, callback) {
        runLoaders(); // 執行loaders,構建模組
    }

    // 其他方法
    // ...
}
複製程式碼

至此,manifest完成了自己的使命,dll則靜靜的等待runtime時被呼叫

結語

通過這次webpack的升級,完成了專案webpack4的大一統,解決了小夥伴們各種頭疼的問題,並且得到了小夥伴們積極的反饋,構建過程比以前清爽不少,構建效率也大幅提升。在升級過程中,還順帶了解一下dll的工作過程,收穫了不少知識。在此總結出來記錄一下此次大一統的過程。

相關文章