5-webpack构建速度和体积优化策略

初级分析:使用webpack内置的stats

利用webpack内置的stats对象

它可以帮我们分析基本的一些信息,比如构建总共的时间,构建资源的大小

package.json 中使用 stats

指定输出的是一个json对象,生成一个json文件

"scripts": {
  "build:stats": "webpack --config webpack.prod.js --json > stats.json"
}

node.js中使用

const webpack = require(‘webpack‘)
const config = require(‘./webpack.config.js‘)(‘production‘)

webpack(config, (err, stats) => {
  if (err) {
    return console.error(err)
  }
  if (stats.hasErrors()) {
    return console.error(stats.toString(‘errors-only‘))
  }
  console.log(stats)
})

这两种方式颗粒度太粗,看不出问题所在。想要分析实际的问题,比如哪个组件比较大,哪个loader耗的时间比较长,是无法很好的分析出来的。

速度分析:使用speed-measure-webpack-plugin

更好的分析webpack构建的速度,怎么找出构建速度问题所在。

使用speed-measure-webpack-plugin

可以看到每个loader和插件执行耗时,重点的关注耗时较长的loader或插件,针对这些做优化。

const SpeedMeatureWebpackPlugin = require(‘speed-measure-webpack-plugin‘)
const smp = new SpeedMeatureWebpackPlugin()

const webpackConfig = smp.wrap({
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
})

速度分析插件作用

分析整个打包总耗时

每个loader和插件的耗时情况

体积分析:使用webpack-bundle-analyzer

它可以把我们的项目打包出来的文件会进行一个分析,能很方便的看出体积的大小。面积越大体积越大,我们可以重点关注这些进行优化。

const BundleAnalyzerPlugin = require(‘webpack-bundle-analyzer‘).BundleAnalyzerPlugin
  
 module.exports = {
   plugins: [
     new BundleAnalyzerPlugin()
   ]
 }

构建完成后会在8888端口展示体积大小。

可以分析哪些问题

依赖的第三方模块文件大小。

业务的组件代码图片大小,针对大的js可以做js的按需加载等优化操作。

使用高版本的webpack和Node.js

在 webpack 里做速度的优化。

在软件这一块,性能往往不是最大的问题,软件不断的迭代过程中,可以不断的提升性能,对于构建而言同样是适用的,所以推荐采用高版本的 webpack 和 node.js。

使用webpack4:优化原因

  • V8带来的优化,很多对原生方法的优化(for of 代替 forEach、Map 和Set 代替 Object、includes 代替 indexOf)

  • 默认使用更快的 md4 的 hash 算法

  • webpacks AST 可以直接从 loader 传递给 AST,减少解析时间

  • 使用字符串的方法替代正则表达式

采用更高版本的node.js

高版本的node.js对原生的js API或数据结构是有做一些优化的。

验证高版本node.js比低版本node.js性能更快,针对相同的api、相同的代码做比较。

// 设置10000个key,运行100次

const runCount = 100
const keyCount = 10000

let map = new Map()

let keys = new Array(keyCount)
for (let i = 0; i < keyCount; i++) keys[i] = {}

for (let key of keys) map.set(key, true)

let startTime = process.hrtime()

for (let i = 0; i < runCount; i++) {
  for (let key of keys) {
    let value = map.get(key)
    if (value !== true) throw new Error()
  }
}

let elapsed = process.hrtime(startTime)
let [seconds, nanoseconds] = elapsed
console.log(elapsed)

let milliseconds = Math.round(seconds * 1e3 + nanoseconds * 1e-6)

console.log(`${process.version} ${milliseconds} ms`)

includes和indexOf的性能差异

const ARR_SIZE = 1000000
const hugeArr = new Array(ARR_SIZE).fill(1)

// includes
const includesTest = () => {
  const arrCopy = []
  console.time(‘includes‘)
  let i = 0
  while (i < hugeArr.length) {
    arrCopy.includes(i++)
  }
  console.timeEnd(‘includes‘)
}

// indexOf
const indexOfTest = () => {
  const arrCopy = []
  console.time(‘indexOf‘)
  for (let item of hugeArr) {
    arrCopy.indexOf(item)
  }
  console.timeEnd(‘indexOf‘)
}

includesTest()
indexOfTest()

多进程/多实例构建

多进程/多实例构建:资源并行解析可选方案

多进程/多实例:使用HappyPack解析资源

原理:每次 webapck 解析一个模块,HappyPack 会将它及它的依赖分配给 worker 线程中。

每次 webpack 解析一个模块,webpack 自身开启一个进程去解析这个模块。HappyPack 会将这个模块进行划分,比如有多个模块,在 webpack compiler run 方法之后,到达 HappyPack,它会做一些初始化,创建一个线程池,线程池会将构建任务里的模块进行分配 ,比如将某个模块以及它的依赖分配给 HappyPack 其中的一个线程,以此类推,那么一个 HappyPack 的线程池可能会包括多个线程,这些线程会各自的处理这些模块以及它的依赖。处理完成之后,会有一个通信的过程,将处理好的资源传输给 HappyPack 的主进程,完成整个构建的过程。

module.exports = {
	module: {
		rules: [
			{
        test: /\.js$/,
        include: path.resolve(‘src‘),
        use: [
          // ‘babel-loader‘, 
          ‘happypack/loader‘ 
        ]
      }
		]
	},
	plugins: [
    new HappyPack({
      id: ‘jsx‘,
      threads: 4,
      loaders: [‘babel-loader?cacheDirectory=true‘]
    }),
    new HappyPack({
      id: ‘styles‘,
      threads: 2,
      loaders: [‘style-loader‘, ‘css-loader‘, ‘less-loader‘]
    })
  ]
}

多进程/多实例:使用thread-loader解析资源

webpack4 原生提供 thread-loader 这个模块,它可以很好的替换 HappyPack,来做多进程/多实例的工作。

原理:跟 HappyPack 是差不多的。每次 webpack 解析一个模块,thread-loader 会将它及它的依赖分配给worker 线程中。

在其他的loader之前放上thread-loader,做一系列的解析,最后会通过thread-loader进行处理。

module.exports = {
	module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: ‘thread-loader‘,
            options: {
              workers: 3
            }
          },
          ‘babel-loader‘
        ]
      }
    ]
  }
}

多进程/多实例并行压缩代码

方法一:使用 webpack-parallel-uglify-plugin 插件

const ParallelUglifyPluging = require(‘webpack-parallel-uglify-plugin‘)

module.exports = {
	plugins: [
    new ParallelUglifyPluging({
      uglifyJS: {
        output: {
          beautify: false,
          comments: false
        },
        compress: {
          warning: false,
          drop_console: true,
          collapse_vars: true,
          reduce_vars: true
        }
      }
    })
  ]
}

方法二:uglifyjs-webpack-plugin 开启 parallel 参数

webpack3 推荐采用的插件,不支持 es6 代码的压缩。

const UglifyjsWebpackPlugin = require(‘uglifyjs-webpack-plugin‘)

module.exports = {
	plugins: [
    new UglifyjsWebpackPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        mangle: true,
        output: null,
        tiplevel: false,
        nameCache: null,
        ie8: false,
        keep_fnames: false
      },
      parallel: true
    })
  ]
}

方法三:terser-webpack-plugin 开启 parallel 参数

webpack4 默认使用的,支持es6代码的压缩。

const TerserPlugin = require(‘terser-webpack-plugin‘)

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true
      })
    ]
  }
}

进一步分包:预编译资源模块

分包:设置Externals

思路:将react, react-dom基础包通过cdn引入,不打入bundle中。

方法:使用html-webpack-externals-plugin。

缺点:一个基础库需要指定一个cdn,实际的项目中有很多包,需要引入的script标签太多 。

通过split-chunks-plugin插件分离基础包,

缺点:它每次还是会对基础包进行分析。

进一步分包:预编译资源模块

分包来说,更好的方式。

思路:将react、react-dom、redux、react-redux基础包和业务基础包打包成一个文件。

方法:使用 DLLPlugin 进行分包,DllReferencePlugin 对 manifest.json 引用。manifest.json 是对分离出来的包的描述。

使用 DLLPlugin 进行分包

创建一个单独的构建配置文件,一般命名为 webpack.ddl.js,DLLPlugin 也会提高打包的速度。

const path = require(‘path‘)
const webpack = require(‘webpack‘)

module.exports = {
  entry: {
    library: [
      ‘react‘,
      ‘react-dom‘
    ]
  },
  output: {
    path: path.join(__dirname, ‘build/library‘),
    filename: ‘[name].[chunkhash].dll.js‘,
    library: ‘[name]‘
  },
  plugins: [
    new webpack.DllPlugin({
      name: ‘[name].[hash]‘,
      path: path.join(__dirname, ‘build/library/[name].json‘),
    })
  ]
}

使用 DLLReferencePlugin 引用 manifest.json

在 webpack.config.js 中引入

module.exports = {
  plugins: [
    new webpack.DLLReferencePlugin({
      manifest: require(‘./build/library/manifest.json‘)
    })
  ]
}

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

缓存目的:提升二次构建速度。

缓存思路:

  • babel-loader 开启缓存

  • terser-webpack-plugin 开启缓存

    const TerserPlugin = require(‘terser-webpack-plugin‘)
    
    module.exports = {
      optimization: {
        minimize: true,
        minimizer: [
          new TerserPlugin({
            cache: true
          })
        ]
      }
    }
  • 使用 cache-loader 或者 hard-source-webpack-plugin

    • 针对模块的缓存的开启

      const HardSourceWebpackPlugin = require(‘hard-source-webpack-plugin‘)
      
      module.exports = {
        plugins: [
          new HardSourceWebpackPlugin()
        ]
      }

有缓存的话node_modules下面会有一个cache目录

缩小构建目标

缩小构建目标

目的:尽可能的少构建模块。

比如 babel-loader 不解析 node_modules。

module.exports = {
	module: {
    rules: [
      {
        test: /\.js$/,
        use: ‘babel-loader‘,
        exclude: ‘node_modules‘
      }
    ]
  }
}

减少文件搜索范围

优化 resolve.modules 配置(减少模块搜索层级)

resolve.modules 是模块解析的过程,webpack 解析时,模块的查找过程和 nodejs 的模块查找是比较类似的,会从当前的项目找,没找到会去找 node_modules。会依次去子目录找模块是否存在。

优化 resolve.mainFields 配置

找入口文件的时候,会根据 package.json 里的 main 字段查找,因为发布到 npm 的组件的 package.json 会遵守一定的规范,都会有 main 这个字段,可以设置查找的时候直接读取 main 这个字段,这样也会减少一些不必要的分析过程。比如 package.json 里面没有这个 main ,那它再去读取根项目下的 index.js,没有再去找 lib 下面的 index.js,这就是它默认的查找过程,我们把这个默认的查找过程链路做一个优化,只找 package.json 中 main 字段指定的入口文件。

优化 resolve.extensions 配置

模块路径的查找,比如 import 一个文件,没有写后缀,webpack 会先去找 .js,没有会找 .json,默认情况下webpack 只支持 js 和 json 的读取。extensions 数组里可以再设置其他的文件,如 .jsx .vue .ts 等。不过这个数组里面的内容越多的话,查找消耗的时间也会越多,因此我们可以缩小 extensions 查找的范围,比如只设置查找 .js,其他文件需要写的时候写全文件后缀。避免 webpack 做不必要的查找。

合理使用 alias

别名,简短的缩写。比如模块的路径,我们找 react,它可能找了一圈,最后肯定是会找到 node_modules 里面去,它会经历一系列的查找过程,我们可以把这一系列的过程直接给它写好,告诉它比如你遇到了 react,就直接从指定的这个路径去找。这个也大大的缩短了查找的时间。

module.exports = {
    // 子模块的查找策略
    resolve: {
      alias: {
        ‘react‘: path.resolve(__dirname, ‘./node_modules/react/umd/react.production.min.js‘),
        ‘react-dom‘: path.resolve(__dirname, ‘./node_modules/react-dom/umd/react-dom.production.min.js‘)
      },
      modules: [path.resolve(__dirname, ‘node_modules‘)],
      extensions: [‘.js‘],
      mainFields: [‘main‘]
    }
  }

使用Tree Shaking擦除无用的JavaScript和CSS

无用的css如何删除掉?

PurifyCSS: 遍历代码,识别已经用到的 CSS class。

uncss: HTML 需要通过 jsdom 加载,所有的样式通过 PostCSS 解析,通过 document.querySelector 来识别在 html 文件里面不存在的选择器。

在 webpack 中如何使用 PurifyCSS?

使用 purgecss-webpack-plugin,它不能独立使用,需要提取 css 为一个文件后才能使用。在 webpack4里需要和 mini-css-extract-plugin 配合使用,在 webpack3 里需要和 extract-text-webpack-plugin 配合使用。

const path = require(‘path‘)
const glob = require(‘glob‘)
const MiniCssExtractPlugin = require(‘mini-css-extract-plugin‘)
const PurgecssWebpackPlugin = require(‘purgecss-webpack-plugin‘)

const PATHS = {
  src: path.join(__dirname, ‘src‘)
}

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          ‘css-loader‘
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: ‘[name].[contenthash:8].css‘
    }),
    new PurgecssWebpackPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`,  { nodir: true })
    })
  ]
}

使用webpack进行图片压缩

图片资源相对是较大的,我们可以通过在线工具手动进行图片的批量压缩。构建工具一部分的职责就是将平时我们手动完成的事做成自动化。

图片压缩

要求:基于 Node 库的 imagemin 或者 tinypng API。

使用:配置 image-webpack-loader。

module.exports = {
	module: {
    rules: [
      {
        test: /\.(png|svg|jpg|gif)$/,
        use: [
          {
            loader: ‘file-loader‘,
            options: {
              name: ‘[name].[hash:8].[ext]‘
            }
          },
          {
            loader: ‘image-webpack-loader‘,
            options: {
              // bypassOnDebug: true, // 
              // disable: true, //  and newer
              mozjpeg: {
                progressive: true,
                quality: 65
              },
              // optipng.enabled: false will disable optipng
              optipng: {
                enabled: false,
              },
              pngquant: {
                quality: [0.65, 0.90],
                speed: 4
              },
              gifsicle: {
                interlaced: false,
              },
              // the webp option will enable WEBP
              webp: {
                quality: 75
              }
            }
          }
        ]
      }
    ]
  }
}

Imagemin 的优点分析

  • 有很多定制选项

  • 可以引入更多第三方优化插件,例如pngquant

  • 可以处理多种图片格式

Imagemin 的压缩原理

  • pngquant: 是一款 PNG 压缩器,通过将图像转换为具有 alpha 通道(通常比 24/32 位 PNG 文件小 60-80%)的更高效的 8 位 PNG 格式,可显著减小文件大小。

  • pngcrush: 其主要目的是通过尝试不同的压缩级别和 PNG 过滤方法来降低 PNG IDAT 数据流的大小。

  • optipng: 其设计灵感来自于 pngcrush。optipng 可将图像文件重新压缩为更小尺寸,而不会丢失任何信息。

  • tinypng: 也是将 24 位 png 文件转化为更小有索引的 8 位图片,同时所有非必要的 metadata 也会被剥离掉。

使用动态Polyfill服务

Babel 默认只转换新的 JavaScript 句法(syntax),而不转换新的 API,比如 Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol、Promise 等全局对象,以及一些定义在全局对象上的方法(比如Object.assign )都不会转码。比如 ES6 在 Array 对象上新增了 Array.from 方法。Babel 就不会转码这个方法。如果想让这个方法运行,必须使用 babel-polyfill。

构建体积优化:动态polyfill

babel-polyfill

打包后体积:88.49k,占比 29.6%

Promise 的浏览器支持情况

Polyfill 方案

Polyfill Service原理

识别 User Agent,下发不同的 Polyfill。

如何使用动态 Polyfill service

polyfill.io 官方提供的服务

https://polyfill.io/v3/polyfill.min.js

基于官方自建polyfill服务

//huayang.qq.com/polyfill/v3/polyfill.min.js?unknown=polyfill&features=Promise,Map,Set

体积优化策略总结:

  • Scope Hoisting

  • Tree Shaking

  • 公共资源分离

  • 图片压缩

  • 动态 polyfill