webpack多页面构建优化不完全指北

2,870 阅读4分钟
原文链接: www.jianshu.com

前言

自从新项目的技术栈启用vue以后,项目的构建工具也自然而然的从原来的内部的工具切换成了webpack,在感受到HMR,各式各样loader的强大后,也随着项目的逐渐变大,依赖的模块越来越多,webpack的构建效率成为了制约团队开发效率的短板。因此,我们来介绍一下多页面下,我们是如何优化webpack的效率的(毕竟本文标题是不完全指北,如果还有其他更好的方法,欢迎留言给我)。

项目背景

我们的项目是基于vue多页面项目,webpack配置文件基于vue-cli进行改写,因此在webpack中存在多个entry,项目的大体结构如下

|---src
    |---pages
        |---xxx1 - 某业务页面1
            |---App.vue - 该业务主入口vue组件
            |---xxx1.html - (与目录同名,业务模板文件)
            |---xxx1.js  - (与目录同名,业务主入口js文件)
        |---xxx2 - 某业务页面2
            |---App.vue - 该业务主入口vue组件
            |---xxx2.html - (与目录同名,业务模板文件)
            |---xxx2.js  - (与目录同名,业务主入口js文件)

下面,我们基于这样的多页面结构具体讲述一下我们是如何对webpack进行构建优化(基于webpack3)

公共代码提取

使用过vue-cli的童鞋都知道,生成模板项目的时候默认使用了** CommonsChunkPlugin来作为code splite工具,本质上通过配置minChunk提出公共部分代码,便于在多页面中缓存(如:页面A和B都有vendor.js,那么访问了页面A,下一次访问页面B,B中的vendor.js直接加载内存中的就好了),从而达到性能提升的目的。当是该Plugin**也有不足,即他是动态编译和进行code splite。怎么理解呢,即每次打包构建,他都会执行一次重复的去执行code splite, 而且因为minChunk策略各不相同,每一次上线以后,提取的公共代码vendor.js内容可能因为版本的不同而不同,但是,像(vue, vuex vue-router)等三方库基本上是稳定的,不需要根据业务的变化而变化。因此,基于此我们可以提取出这些第三方库提前预构建好,而不是让他随着版本再次构建

方法一

最简单的方式莫过于直接将这些js合并压缩混淆挂载在全局节点上,但是如果这样做,我们在业务代码中就只能通过window下的属性来使用它们提供的各个功能,打破了模块化的封装,因此该方案并不好。

方法二

考虑到CommonChunkPlugin的局限性,webpack官方提供了另外一个插件DllPlugin,这个插件需要和DLLReferencePlugin配合使用。

熟悉 Windows 的朋友就应该知道,DLL 所代表的含义。在 Windows 中,有大量的 .dll 文件,称为动态链接库。动态链接库提供了将应用模块化的方式,应用的功能可以在此基础上更容易被复用。

因此我们的目的即使用DLL插件,将不修改的模块公共部分提取出来单独打包
,我们先建立webpack.dll.config.js,这个文件内容很简单。

const path = require('path');
const webpack = require('webpack');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const config = require('../config');

module.exports = {
    entry: {
        vendor: ['vue/dist/vue.esm.js', 'vuex', 'axios', 'vue-router', 'babel-polyfill', 'lodash'] // 所需要的打包前端公共模块
    },
    output: {
        path: path.join(__dirname, '../static/js'), // 打包后文件输出的位置
        filename: '[name].dll.js',
        /**
         * output.library
         * 将会定义为 window.${output.library}
         * 在这次的例子中,将会定义为`window.vendor_library`
         */
        library: '[name]_library'
    },
    plugins: [
        new webpack.DllPlugin({  //主要是使用这个插件去打包js
            /**
             * path
             * 定义 manifest 文件生成的位置
             * [name]的部分由entry的名字替换
             */
            path: path.join(__dirname, '.', '[name]-manifest.json'),
            /**
             * name
             * dll bundle 输出到那个全局变量上
             * 和 output.library 一样即可。
             */
            name: '[name]_library',
            context: path.join(__dirname, '..')
        }),
        new UglifyJsPlugin({    // 使用这个插件可以混淆打包完成的js
            uglifyOptions: {
                compress: {
                    warnings: false
                }
            },
            sourceMap: config.build.productionSourceMap,
            parallel: true
        })
    ]
};

执行 webpack --config build/webpack.dll.config.js后,webpack会自动生成2个文件,其中vendor.dll.js即合并打包后第三方模块。另外一个vendor-mainifest.json存储各个模块和所需公用模块的对应关系。

将第三方模块打完包以后,我们就需要使用DLLReferencePlugin来将它和我们的业务代码进行融合,我们修改webpack.base.config(vue-cli生成配置),添加plugin如下:

plugins: [
        new webpack.DllReferencePlugin({
            context: __dirname,  // 与DllPlugin中的那个context保持一致
            manifest: require('./vendor-manifest.json')
        }),
        ......
]

同时,我们还需要手动的将vendor.dll.js插入类似index.html这样的模板文件才可以生效

<script src="/vendor.dll.js"></script>

这样就完成了使用dll插件提取公共第三方库的操作,一般情况下,我们不会增加或者减少第三方库,但是一旦出现这种情况,我们都需要手动重新去打一个包来进行替换。那么有没有更自动的方式来完成这件事呢?

方法三

AutoDllPlugin出现在了我的视野,这个插件自动同时相当于完成了DllReferencePluginDllPlugin的工作,只需要在webpack.base.config中添加

plugins: [
    new AutoDllPlugin({
            inject: true, // will inject the DLL bundles to html
            context: path.join(__dirname, '..'),
            filename: '[name]_[hash].dll.js',
            path: 'res/js',
            plugins: mode === 'online' ? [
                new UglifyJsPlugin({
                    uglifyOptions: {
                        compress: {
                            warnings: false
                        }
                    },
                    sourceMap: config.build.productionSourceMap,
                    parallel: true
                })
            ] : [],
            entry: {
                vendor: ['vue/dist/vue.esm.js', 'vuex', 'axios', 'vue-router', 'babel-polyfill', 'lodash']
            }
     })
]

,不需要额外的webpack.dll.config.js配置以及不需要手动将打完好的包拷贝到对应的模板文件中。

小结

大多数情况,我们推荐方法3,不过方法3相比方法2,增加了每次启动重新构建一次新的vendor.js,开发阶段首次启动会构建一次新的vendor,增加一些额外的时间(实测下来影响并不大),不过也避免了更新第三方库增减而忘记打包对业务产生的影响

多线程构建

webpack和其他大部分js工具相同都是单线程对项目进行处理,
然而 Webpack 这个工具强就强在流程设计的扩展性如此之强,可以人为的加上多进程处理。
其在编译文件流程如下:

1. 开始编译 (Compiler#run)
2. 开始编译入口文件 (Compilation#addEntry)
    2.1 开始编译文件 (Compilation#buildModule => NormalModule#build)
    2.2 执行 Loader 得到文件结果 (NormalModule#runLoaders)
    2.3 根据结果解析依赖 (NormalModule#parser.parse)
    2.4 处理依赖文件列表 (Compilation#processModuleDependencies)
    2.5 开始编译每个依赖文件 (异步,从这里开始递归操作: 编译文件->解析依赖->编译依赖文件->解析深层依赖...)

这里的关键在于递归操作 2.5 开始编译每个依赖文件 这一步是异步设计,每个依赖文件的编译彼此之间互不影响。不过虽然是异步的,但还是跑在一个线程里。但是这样的设计却带来了多进程的可行性。

编译文件中主要的耗时操作在于 Loader 对源文件的转换操作,而 Loader 的可异步的设计使得转换操作的执行并不被限制在同一线程内。下面对 Loader 进行改造,使其支持多进程并发:

2.2 执行 Loader 得到文件结果
    LoaderWrapper 作为新的 Loader 入口接收文件输入信息
    LoaderWrapper 创建一个子进程 (child_process#fork) (这一步可维护一个进程池)
    子进程中,通过调用原始 Loader,转换输入文件,然后把最终结果传递给父进程
    父进程将收到的结果作为 Loader 结果传递给 Webpack

HappyPack 的实现就是这个流程,我们来使用babel-loader作为例子,来讲解一下HappyPack如何配置

通常情况下,我们使用的babel-loader如下所示

webpack.base.config.js
...
module: {
    rules: [
      ...
      {
        test: /\.js$/,
        include: [resolve('src'), resolve('lib'),resolve('test'), resolve('node_modules/webpack-dev-server/client')], // 通过合理配置include也可以对提升构建性能
        use: [
          {
            loader: 'babel-loader'
          },
        ],
        exclude: /node_modules/ // 通过合理配置exclude也可以对提升构建性能
      }
}

转换成HappyPack,配置改写为

const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({size: os.cpus().length});

// 省略其他配置
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                include: [resolve('src'), resolve('lib'), resolve('test'), resolve('node_modules/webpack-dev-server/client')],
                use: [
                    {
                        loader: 'happypack/loader?id=happybabel'  // 将loader换成happypack并将id指向插件id参数
                    },
                ],
                exclude: /node_modules/
            }
        ]
    },
   plugins: [
        new HappyPack({  // HappyPack插件
            id: 'happybabel',
            loaders: ['babel-loader?cacheDirectory=true'],
            threadPool: happyThreadPool,
        })
    ]
}

HappyPack不只可以对babel-loader进行处理,其他vue-loader,css-loader等都可以用他进行加速优化,只需要如上增加实例以及改写loader即可。使用HappyPack整体优化后,在我们的项目中,构建速度基本可以提高70%。

多页面html-webpack-plugin优化

作为webpack中的第一大插件html-webpack-plugin,大家应该或多或少的使用过,这个插件会根据你的模板代码,通过不同的模板引擎构建出对应的html,ejs甚至ftl文件,在标准的SPA中,该插件性能不会性能瓶颈,但是如果你使用的是多页面,该插件的构建速度绝对是地狱级别的,
如,我只是简单修改了一个vue文件的一个文案,在阶段居然花费了16s,这大大减慢了开发效率,感受不到HMR的优势

image.png

我们找到html-webpack-pluginemit事件钩子,注入事件代码

image.png

我们发现,他会对每一个入口文件都执行一遍emit中所有代码逻辑


image.png

,

因此,我们需要考虑,如何只在自己修改到的入口,执行emit下面的流程就好了。

在浏览了很多issure后,发现已经有现有的轮子帮助我们完成了判断和缓存的功能.
html-webpack-plugin-for-multihtml

修改配置代码代码

const HtmlWebpackPlugin = require('html-webpack-plugin-for-multihtml');
// 省略其他代码

plugins:[
  new HtmlWebpackPlugin({
          template: filePath,
          filename: `${filename}.html`,
          chunks: ['manifest', 'vendor', filename],
          inject: true,
           multihtmlCache: true  // 增加该配置
  })
]

该插件通过在webpack done钩子函数中设置相关变量,来保证原html-webpack-plugin插件中emit仅触发一次全部流程。来达到提速的效果。升级以后,修改文案,HMR的速度从原来的秒级改为毫秒级。

image.png

参考文档

HappyPack - Webpack 的加速器