Webpack升级优化小记

栏目: 编程语言 · 发布时间: 6年前

内容简介:最近学习了webpack4的使用,并尝试了对项目webpack进行升级和优化,记录一下此次升级的一些实践过程。项目在2016年引入了webpack作为打包工具,并使用vue-cli搭建build相关的代码,之后再无较大更新。随着项目迭代至今,代码量早已不是当年寥寥的几千行,本地启动开发环境也从当年的十几秒暴增至现在200s以上,每次run dev或者rebuild都伴随着长时间目光呆滞的等待在两年多的时间跨度里,项目的构建代码被无数人反复修改,充斥着冗余、杂乱以及只有上帝才能理解的逻辑。同事在做主题功能时

最近学习了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去处理待打包的源文件,换言之即 提升单位时间内的打包速度

Webpack升级优化小记

引用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升级优化小记
  • 使用webpack4本地启动耗时
    Webpack升级优化小记
  • webpack4 + happyPack(babel-loader) 本地启动耗时
    Webpack升级优化小记
  • webpack4 + happyPack(babel-loader + eslint-loader) 本地启动耗时
    Webpack升级优化小记

从实验结果可以看到,使用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升级优化小记

从71s到45s,这又是一个不小的进步, 时间进一步缩短了近40% ,相比较最初的webpack2编译耗时, 效率增加了71% ,即便是用公司的本子,效率也至少能增加50%以上。看到这个结果,笔者的第一反应是:卧槽!!!好吧,这种慨叹除了包含对结果的惊讶,更多的是没想到以前的构建低效的令人发指。

稍稍优化一下性能

除了减少代码的打包时间,使用dll还有助于网页性能的优化。通常我们会把第三方库提取到文件名为vendors的代码块里,这样做的好处是防止公共依赖被重复打包,同时其变化频率较低,在生产环境下具有相对稳定的哈希值,可充分利用浏览器的缓存策略减少对vendors文件的请求。但可能导致单个js文件体积过大,当重新请求资源时会产生比较明显的阻塞。使用dll之后,因为大量的第三方库被提前提取,vendors的体积相应减小,请求vendors文件的网络开销也相应降低

不使用dll,vendors的体积

Webpack升级优化小记

使用dll后vendors的体积

Webpack升级优化小记

有些同学可能会有疑惑,虽然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升级优化小记

对于多入口的情况,每个入口文件都会生成一个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升级优化小记

进入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的工作过程,收获了不少知识。在此总结出来记录一下此次大一统的过程。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Domain-Driven Design Distilled

Domain-Driven Design Distilled

Vaughn Vernon / Addison-Wesley Professional / 2016-6-2 / USD 36.99

Domain-Driven Design (DDD) software modeling delivers powerful results in practice, not just in theory, which is why developers worldwide are rapidly moving to adopt it. Now, for the first time, there......一起来看看 《Domain-Driven Design Distilled》 这本书的介绍吧!

MD5 加密
MD5 加密

MD5 加密工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换