avatar
原创2018/03/17

Webpack 打包优化策略

AI 总结

系统梳理 Webpack 打包优化策略,包括 speed-measure-webpack-plugin 速度分析、DLL 动态链接库预编译、HappyPack 多线程编译、terser-webpack-plugin 并行压缩、splitChunks 代码分割、CDN 外链优化等实用手段,帮助开发者显著提升大型项目的构建效率。

速度分析

webpack 有时候打包很慢,而我们在项目中可能用了很多的 plugin 和 loader,想知道到底是哪个环节慢,下面这个插件可以计算 plugin 和 loader 的耗时。

yarn add -D speed-measure-webpack-plugin

配置

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
 
const smp = new SpeedMeasurePlugin()
 
const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()]
})

这个插件主要做了两件事情:

  • 计算整个打包总耗时
  • 分析每个插件和 loader 的耗时情况

体积分析

yarn add -D webpack-bundle-analyzer

配置

const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      //  可以是`server`,`static`或`disabled`。
      //  在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。
      //  在“静态”模式下,会生成带有报告的单个HTML文件。
      //  在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。
      analyzerMode: 'server',
      //  将在“服务器”模式下使用的主机启动HTTP服务器。
      analyzerHost: '127.0.0.1',
      //  将在“服务器”模式下使用的端口启动HTTP服务器。
      analyzerPort: 8866,
      //  路径捆绑,将在`static`模式下生成的报告文件。
      //  相对于捆绑输出目录。
      reportFilename: 'report.html',
      //  模块大小默认显示在报告中。
      //  应该是`stat`,`parsed`或者`gzip`中的一个。
      //  有关更多信息,请参见“定义”一节。
      defaultSizes: 'parsed',
      //  在默认浏览器中自动打开报告
      openAnalyzer: true,
      //  如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成
      generateStatsFile: false,
      //  如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。
      //  相对于捆绑输出目录。
      statsFilename: 'stats.json',
      //  stats.toJson()方法的选项。
      //  例如,您可以使用`source:false`选项排除统计文件中模块的来源。
      //  在这里查看更多选项:https:  //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
      statsOptions: null,
      logLevel: 'info'
    })
  ]
}

多进程

webpack 是运行在 node 环境中,而 node 是单线程的。webpack 的打包过程是 io 密集和计算密集型的操作,如果能同时 fork 多个进程并行处理各个任务,将会有效的缩短构建时间。

最常用的是thread-loader和HappyPack ,其中thread-loader吧,这个也是webpack4官方所推荐的。

yarn add -D thread-loader

配置

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve('src'),
        use: [
          'thread-loader'
          // your expensive loader (e.g babel-loader)
        ]
      }
    ]
  }
}

thread-loader  会将你的  loader  放置在一个  worker  池里面运行,以达到多线程构建。

thread-loader 和 happypack 对于小型项目来说打包速度几乎没有影响,甚至可能会增加开销,所以建议尽量在大项目中采用。

多进程并行压缩代码

通常我们在开发环境,代码构建时间比较快,而构建用于发布到线上的代码时会添加压缩代码这一流程,则会导致计算量大耗时多。

webpack默认提供了UglifyJS插件来压缩JS代码,但是它使用的是单线程压缩代码,也就是说多个 js 文件需要被压缩,它需要一个个文件进行压缩。所以说在正式环境打包压缩代码速度非常慢(因为压缩JS代码需要先把代码解析成用Object抽象表示的AST语法树,再应用各种规则分析和处理AST,导致这个过程耗时非常大)。

常用的做法就是多进程并行压缩

  • parallel-uglify-plugin
  • uglifyjs-webpack-plugin
  • terser-webpack-plugin

parallel-uglify-plugin

当webpack有多个JS文件需要输出和压缩时,原来会使用UglifyJS去一个个压缩并且输出,而ParallelUglifyPlugin插件则会开启多个子进程,把对多个文件压缩的工作分给多个子进程去完成,但是每个子进程还是通过UglifyJS去压缩代码。并行压缩可以显著的提升效率。

uglifyjs-webpack-plugin

const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
 
module.exports = {
  plugins: [
    new UglifyJsPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        ie8: false
      },
      parallel: true
    })
  ]
}

设置parallel: true开启多进程压缩

terser-webpack-plugin

webpack4  已经默认支持  ES6语法的压缩。

而这离不开terser-webpack-plugin

const TerserPlugin = require('terser-webpack-plugin')
 
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: 4
      })
    ]
  }
}

Externals

将一些JS文件存储在 CDN 上,在 index.html 中通过 <script> 标签引入

使用import方式导入且webpack不会打包

//webpack.config.js
module.exports = {
  //...
  externals: {
    //jquery通过script引入之后,全局中即有了 jQuery 变量
    jquery: 'jQuery'
  }
}

预编译资源模块

在使用webpack进行打包时候,对于依赖的第三方库,比如vue,vuex等这些不会修改的依赖,我们可以让它和我们自己编写的代码分开打包,这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。

那么第三方库在第一次打包的时候只打包一次,以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样的可以快速的提高打包的速度。其实也就是预编译资源模块。

webpack中,我们可以结合DllPlugin  和  DllReferencePlugin插件来实现。

DllPlugin

DllPlugin能把第三方库代码分离开,并且每次文件更改的时候,它只会打包该项目自身的代码。所以打包速度会更快。

DLLPlugin  插件是在一个额外独立的webpack设置中创建一个只有dll的bundle,也就是说我们在项目根目录下除了有webpack.config.js,还会新建一个webpack.dll.js文件。

webpack.dll.js的作用是把所有的第三方库依赖打包到一个bundle的dll文件里面,还会生成一个名为  manifest.json文件。该manifest.json的作用是用来让  DllReferencePlugin  映射到相关的依赖上去的。

DllReferencePlugin

什么意思呢?就是说在webpack.dll.js中打包后比如会生成 vendor.dll.js文件和vendor-manifest.json文件,vendor.dll.js文件包含了所有的第三方库文件,vendor-manifest.json文件会包含所有库代码的一个索引,当在使用webpack.config.js文件打包DllReferencePlugin插件的时候,会使用该DllReferencePlugin插件读取vendor-manifest.json文件,看看是否有该第三方库

使用方法:

使用DllPlugin配置一个webpack_dll.config.js来构建 dll 文件:

// webpack_dll.config.js
const path = require('path')
const DllPlugin = require('webpack/lib/DllPlugin')
module.exports = {
  entry: {
    react: ['react', 'react-dom'],
    polyfill: ['core-js/fn/promise', 'whatwg-fetch']
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, 'dist'),
    library: '_dll_[name]' //dll的全局变量名
  },
  plugins: [
    new DllPlugin({
      name: '_dll_[name]', //dll的全局变量名
      path: path.join(__dirname, 'dist', '[name].manifest.json') //描述生成的manifest文件
    })
  ]
}

需要注意DllPlugin的参数中name值必须和output.library值保持一致,并且生成的manifest文件中会引用output.library值。

最终构建出的文件:

|-- polyfill.dll.js
|-- polyfill.manifest.json
|-- react.dll.js
└── react.manifest.json

其中xx.dll.js包含打包的 n 多模块,这些模块存在一个数组里,并以数组索引作为 ID,通过一个变量假设为_xx_dll暴露在全局中,可以通过window._xx_dll访问这些模块。xx.manifest.json文件描述 dll 文件包含哪些模块、每个模块的路径和 ID。然后再在项目的主config文件里使用DllReferencePlugin插件引入xx.manifest.json文件

引入xx.manifest.json文件

//webpack.config.json
const path = require('path')
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin')
module.exports = {
  entry: { main: './main.js' },
  //... 省略output、loader等的配置
  plugins: [
    new DllReferencePlugin({
      manifest: require('./dist/react.manifest.json')
    }),
    new DllReferenctPlugin({
      manifest: require('./dist/polyfill.manifest.json')
    })
  ]
}

最终构建生成 main.js

利用缓存提升二次构建速度

在webpack中利用缓存一般有以下几种思路:

  • babel-loader开启缓存
  • 使用cache-loader
  • 使用hard-source-webpack-plugin

babel-loader

babel-loader在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积冗余,同时也会减慢编译效率。

可以加上cacheDirectory参数开启缓存

{
    test: /\.js$/,
    exclude: /node_modules/,
    use: [{
      loader: "babel-loader",
      options: {
        cacheDirectory: true
      }
    }],
  },

cache-loader

在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里。

hard-source-webpack-plugin

HardSourceWebpackPlugin  为模块提供了中间缓存,缓存默认的存放路径是: node_modules/.cache/hard-source。

配置  hard-source-webpack-plugin后,首次构建时间并不会有太大的变化,但是从第二次开始,构建时间大约可以减少  80%左右。

安装

Terminal
yarn add -D hard-source-webpack-plugin
//
// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
 
module.exports = {
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

缩小构建目标/减少文件搜索范围

// webpack.config.js
const path = require('path');
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        // include: path.resolve('src'),
        use: ['babel-loader']
      }
    ]
  }

babel-loader就会排除对node_modules下对应 js 的解析,提升构建速度

// webpack.config.js
const path = require('path');
module.exports = {
  ...
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js')
    }, //直接指定react搜索模块,不设置默认会一层层的搜寻
    modules: [path.resolve(__dirname, 'node_modules')], //限定模块路径
    extensions: ['.js'], //限定文件扩展名
    mainFields: ['main'] //限定模块入口文件名
  • resolve.modules:告诉  webpack  解析模块时应该搜索的目录
  • resolve.mainFields:当从  npm  包中导入模块时(例如,import * as React from 'react'),此选项将决定在  package.json  中使用哪个字段导入模块。根据  webpack  配置中指定的  target  不同,默认值也会有所不同
  • resolve.mainFiles:解析目录时要使用的文件名,默认是index
  • resolve.extensions:文件扩展名

动态 Polyfill 服务

babel只负责语法转换,比如将ES6的语法转换成ES5。但如果有些对象、方法,浏览器本身不支持,比如:

  • 全局对象:Promise、WeakMap  等。
  • 全局静态函数:Array.from、Object.assign  等。
  • 实例方法:比如  Array.prototype.includes  等。

此时,需要引入babel-polyfill来模拟实现这些对象、方法。

使用

module.exports = {
  entry: ['@babel/polyfill', './app/js']
}

由于是一次性全部导入整个polyfill,所以导致了一个大问题:文件很大。为了解决这个问题,引入动态polyfill。

每次打开页面,浏览器都会向Polyfill Service发送请求,Polyfill Service识别 User Agent,下发不同的 Polyfill,做到按需加载Polyfill的效果

采用官方提供的服务地址 https://polyfill.io/v3/polyfill.min.js

Scope Hoisting

Scope hoisting  直译过来就是「作用域提升」。熟悉  JavaScript  都应该知道「函数提升」和「变量提升」,JavaScript  会把函数和变量声明提升到当前作用域的顶部。「作用域提升」也类似于此,webpack  会把引入的  js  文件“提升到”它的引入者顶部。

Scope Hoisting  可以让  Webpack  打包出来的代码文件更小、运行的更快。

要在 Webpack 中使用 Scope Hoisting 非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:

// webpack.config.js
const webpack = require('webpack')
 
module.exports = (mode) => {
  if (mode === 'production') {
    return {}
  }
 
  return {
    devtool: 'source-map',
    plugins: [new webpack.optimize.ModuleConcatenationPlugin()]
  }
}
点击播放