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

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

痛苦的開發體驗

漫長的等待

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

混亂之治

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

歸納一下就是:build太慢,構建代碼混亂,小夥伴們的開發效率低下vue

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

提高構建效率

提高打包效率,能夠簡單的歸納爲兩條路:node

  1. 提高單位時間內的打包速度
  2. 清理沒必要要打包的文件

多管齊下:happyPack

如同這個插件的名字同樣,用完以後確實能讓人happy,打包速度提高的不是一星半點,原理就是開啓多個node子進程並行的用各類loader去處理待打包的源文件,換言之即提高單位時間內的打包速度webpack

happyPack原理示意

引用happyPack官方的說法:git

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".github

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.web

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

  • 項目使用webpack2本地啓動耗時
  • 使用webpack4本地啓動耗時
  • webpack4 + happyPack(babel-loader) 本地啓動耗時
  • webpack4 + happyPack(babel-loader + eslint-loader) 本地啓動耗時

從實驗結果能夠看到,使用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 本地啓動耗時

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

稍稍優化一下性能

除了減小代碼的打包時間,使用dll還有助於網頁性能的優化。一般咱們會把第三方庫提取到文件名爲vendors的代碼塊裏,這樣作的好處是防止公共依賴被重複打包,同時其變化頻率較低,在生產環境下具備相對穩定的哈希值,可充分利用瀏覽器的緩存策略減小對vendors文件的請求。但可能致使單個js文件體積過大,當從新請求資源時會產生比較明顯的阻塞。使用dll以後,由於大量的第三方庫被提早提取,vendors的體積相應減少,請求vendors文件的網絡開銷也相應下降

不使用dll,vendors的體積

使用dll後vendors的體積

有些同窗可能會有疑惑,雖然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以後生成了哪些文件

對於多入口的狀況,每一個入口文件都會生成一個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,總體流程以下:

進入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的工做過程,收穫了很多知識。在此總結出來記錄一下這次大一統的過程。

相關文章
相關標籤/搜索