likes
comments
collection
share

webpack5之性能优化

作者站长头像
站长
· 阅读数 14

webpack系列目录

  1. webpack5之核心配置梳理
  2. webpack5之模块化原理
  3. webpack5之Babel/ESlint/浏览器兼容
  4. webpack5之性能优化
  5. webpack5之Loader和Plugin的实现
  6. webpack5之核心源码解析

性能分析工具

性能优化一直是我们一直关注的问题,对于性能优化分析的角度,我们可以从构建过程构建产物两方面谈起。

构建过程分析

对于构建过程,如果我们希望看到每一个loader、每一个Plugin消耗的打包时间,可以借助于一个插件: speed-measure-webpack-plugin,安装npm install speed-measure-webpack-plugin -D。 使用配置如下

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = (env) => {
  const config = {
    // 配置属性
  }
  return smp.wrap(config)
}

该插件在最新的webpack版本中存在一些兼容性的问题(和部分Plugin不兼容),因此我们只能先加有兼容的插件删除或者注释掉

构建产物分析

对于构建产物,我们可以使用webpack命令生成文件分析产物stats.json和分析工具webpack-bundle-analyzer两种方案

stats.json

我们在package.json的script中添加"build:stats": "webpack --config ./config/webpack.common.js --env production --profile --json=stats.json",我们执行npm run build:stats,现在我们能够在当前目录下生成一个stats.json文件,并对其格式化一下 webpack5之性能优化 该产物记录了构建产物的模块大小,模块之前的引入关系等,我们也可以将生成的stats.json文件放到github.com/webpack/ana… ,进行分析

webpack-bundle-analyzer

我们还可以通过一个查看包大小的分析工具webpack-bundle-analyzer,安装npm install webpack-bundle-analyzer -D,使用配置插件,并且打包

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
{
  plugins: [
    //...
    new BundleAnalyzerPlugin()
  ]
}

webpack5之性能优化

该插件在打包结束后,启一个8888端口上的服务,我们可以直接的浏览器上访问该端口页面看到每个包的大小。

性能优化方案

目前在实际项目中,具体的场景有对应的优化方案,现在我们来看一下的一些常用的性能优化方案

打包代码分离

代码分离的方式有三种

  • 入口起点 : 使用entry配置手动分离代码
  • 防止重复 : 使用Entry Dependencies或者SplitChunksPlugin去重和分离代码
  • 动态导入 : 通过模块的内联函数调用来分离代码

entry配置手动分离代码

entry属性除了可以穿字符串,也可以传对象,相关配置如下

{
  entry: {
    main: resolve('src/main.js'),
    index: resolve('src/index.js')
  },
  output: {
    path: resolve('build'),
    filename: 'js/[name].bundle.js', // 通过[name]占位符来导出不同的bundle文件
    clean: true
  }
}

webpack5之性能优化

可以看到打包后输出index.bundle.js和main.bundle.js这两个文件,并且在index.html中引入这两个bundle文件,该方式一般可以用来打包多页面项目。

Entry Dependencies

现在我们使用一个lodash包分别在index.js里和main.js文件中使用

// index.js
import _ from 'lodash'
console.log(_.join('hello', 'index.js'))
// main.js
import _ from 'lodash'
console.log(_.join('hello', 'main.js'))

webpack5之性能优化

webpack5之性能优化

可以看到lodash都打包进了两个bundle文件中,那么体积就大了一倍,我们能否将重复引入的依赖包给抽离成一个公共的呢,可以看如下配置

entry: {
  main: {import: resolve('src/main.js'), dependOn: 'lodash'},
  index: {import: resolve('src/index.js'), dependOn: 'lodash'},
  lodash: 'lodash'
}

webpack5之性能优化

现在可以看到,通过配置dependOn去指出依赖项lodash,打包后可以看到抽离出lodash.bundle.js文件。如果两个文件又依赖另一个包,比如都是用的dayjs,那么配置和打包如下

entry: {
  main: {import: resolve('src/main.js'), dependOn: 'shared'},
  index: {import: resolve('src/index.js'), dependOn: 'shared'},
  shared: ['lodash', 'dayjs']
}

webpack5之性能优化

现在loadsh和dayjs都打包进了shared.bundle.js里面了。

SplitChunksPlugin

我们可以使用 SplitChunks 的分块策略,我们介绍几个常用的属性

  • chunks : 默认值是async,另一个值是initial,表示对通过的代码进行处理,all表示对同步和异步代码都进行处理
  • minSize : 拆分包的大小,至少为minSize,如果一个包拆分出来达不到minSize,那么这个包就不会拆分
  • maxSize : 将大于maxSize的包,拆分为不小于minSize的包
  • minChunks : 至少被引入的次数,默认是1,如果我们写一个2,但是引入了一次,那么不会被单独拆分
  • name : 设置拆包的名称,可以设置一个名称,也可以设置为false,设置为false后,需要在cacheGroups中设置名称
  • cacheGroups : 用于对拆分的包就行分组,比如一个lodash在拆分之后,并不会立即打包,而是会等到有没有其他符合规则的包一起来打包
    • test : 匹配符合规则的包
    • name : 拆分包的name属性
    • filename : 拆分包的名称,可以自己使用placeholder属性
    • priority : 打包优先级
optimization: {
  //...
  splitChunks: {
    // async异步导入
    // initoal同步导入
    // all 异步/同步
    chunks: 'all',
    minSize: 20000,  // 最小尺寸,拆分出来的一个包的大小最小为minSize 默认 20kb
    maxSize: 20000,  // 将大于maxSize的包,拆成不小于minSize的包 默认 0, 一般会设置和minSize一样
    minChunks: 2,    // 引入的包,至少被导入几次 默认 1次
    cacheGroups: {   // 缓存分组
      vendor: {  // 第三方打包到vendor
        test: /[\/]node_modules[\/]/,  // 匹配node_modules
        filename: 'js/[id]_vendors.js',  // 与name属性区别是 filename可用占位符, name固定名称
        // name: 'js/check_vendors.js',  
        priority: -10  // 当所有打包条件都满足时,按priority优先级来打包,大的先打包
      },
      default: {  // 默认打包,当其他条件不满足
        minChunks: 2,
        filename: 'js/[id]_common.js',  // 一般是多入口会打包common.js
        priority: -20
      }
    }
  }
}

动态导入模块

还有一种代码拆分的方式是动态导入时,webpack提供了两种实现动态导入的方式

  • 使用ECMAScript中的import()语法来完成,也是目前推荐的方式
  • 使用webpack遗留的require.ensure,目前已经不推荐使用

比如一个模块在初始页面打开是并不会用到,而是在点击某一处使用该模块,那么我们希望一开始不需要将这个模块一起加载下来,而是等到需要用到的时候import动态导入后再加载。

我们可以通过配置output.chunkFilename来改变异步打包文件名

output: {
  //...
  chunkFilename: 'js/chunk.[id].[name].js'
}

webpack5之性能优化 

webpack5之性能优化

我们通过import异步加载foo.js,打包后可以看到chunk.604.604.js,但是id和name却是相同,我们可通过魔法注释(magic comments) 来改变name

webpack5之性能优化

webpack5之性能优化

现在重新打包后可以看到异步模块名已经替换成我们配置的名称。

webpack5之性能优化

我们在浏览器中打开后经过2秒后这个依赖包被异步加载出来。我们实际上在很多框架如vue中加载路由会通过import懒加载的方式,就是我们上面所说的这种。

在异步加载模块中,我们还可以通过魔法注释(magic comments)配置一个prefetchpreload

  • prefetch (预获取) : 将来某些导航下可能需要的资源,prefetch会在父chunk加载结束后会开始加载。
  • preload (预加载) : 当前导航下可能需要资源,和父chunk一起请求

目前我们推荐使用prefetch ,配置 /*webpackPrefetch: true*/ 如下

webpack5之性能优化

webpack5之性能优化

webpack5之性能优化

打包后可以看到浏览器中引入的chunk通过<link rel="prefetch" as="script"> 引入,并且在network中也可以看到当前模块在所以模块加载之后在加载并从 prefetch cache 中取出并执行,所以该资源预先下载完了资源,当我们使用到的时候再重缓存中读取加载。

Tree Shaking

Tree Shaking就是摇晃树的意思,将我们没有用到的代码给摇晃(删除)下来,一般Tree Shaking分为javascript和css的tree shaking。而javascript的Tree Shaking方法中又借助了Teser压缩工具,所以我们先介绍一下javascript和css的压缩工具。

Javascript压缩

我们先讲讲如何使用压缩工具,早期我们会使用 uglify-js 来压缩、丑化我们的JavaScript代码,但是目前已经不再维护,并且不支持ES6+的语法,现在我们主要使用了TerserPlugin这个插件配置,这个插件在安装webpack5的时候自动安装上了

  • 在webpack中有一个minimizer属性,在production模式下,默认就是使用TerserPlugin来处理我们的代码的,如果我们对默认的配置不满意,也可以自己来创建TerserPlugin的实例,并且覆盖相关的配置
  • 首先,我们需要打开minimize,让其对我们的代码进行压缩(默认production模式下已经打开了)
  • 其次,我们可以在minimizer创建一个TerserPlugin
    • extractComments : 默认值为true,表示会将注释抽取到一个单独的文件中,在开发中,我们不希望保留这个注释时,可以设置为false
    • parallel : 使用多进程并发运行提高构建的速度,默认值是true,并发运行的默认数量: os.cpus().length - 1,我们也可以设置自己的个数,但是使用默认值即可
    • terserOptions : 设置我们的terser相关的配置
      • compress : 设置压缩相关的选项
      • mangle : 设置丑化相关的选项,可以直接设置为true
      • toplevel : 底层变量是否进行转换
      • keep_classnames : 保留类的名称
      • keep_fnames : 保留函数的名称

TeserPlugin配置如下

const TerserPlugin = require('terser-webpack-plugin')
//...
optimization: {
  minimize: true,  // minimizer配置开关
  minimizer: [
    new TerserPlugin({   // 默认不需要去配置, 压缩js
      parallel: true,   // 使用cpu多核来构建
      extractComments: false,  // 打包后的 LICENSE.txt 注释文件去吃
      terserOptions: {
        compress: {
          arguments: true,
          dead_code: true,
        }, // 设置压缩相关的选项;
        mangle: true, // 设置丑化相关的选项,可以直接设置为true;
        toplevel: true, // 底层变量是否进行转换;
        keep_classnames: false, // 保留类的名称;
        keep_fnames: false, // 保留函数的名称;
      }
    }),
  ],
}

CSS压缩

除了JS压缩之外,我们使用也有对于css的压缩,css压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等,我们可以使用一个插件: css-minimizer-webpack-plugincss-minimizer-webpack-plugin是使用cssnano工具来优化、压缩CSS(也可以单独使用),安装 npm install css-minimizer-webpack-plugin -d 。配置如下

const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')
//...
optimization: {
  minimize: true,  // minimizer配置开关
  minimizer: [
    new TerserPlugin({   // 默认不需要去配置, 压缩js
      //...
    }),
    new CssMinimizerWebpackPlugin({
      parallel: true
    })
  ],
}

打包后可以看到css文件已被压缩

webpack5之性能优化

Javascript Tree Shaking

现在我们再来介绍下什么是Javascript Tree Shaking,Tree Shaking是一个术语,在计算机中表示消除死代码(dead_code),Tree Shaking依赖于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系),tree shaking在webpack实现的过程如下

  • webpack2正式内置支持了ES2015模块,和检测未使用模块的能力
  • 在webpack4正式扩展了这个能力,并且通过 package.json的sideEffects属性作为标记,告知webpack在编译时, 哪里文件可以安全的删除掉
  • webpack5中,也提供了对部分CommonJS的tree shaking的支持

目前webpack实现Tree Shaking采用了两种不同的方案

  • usedExports: 通过标记某些函数是否被使用,之后通过Terser来进行优化的
  • sideEffects: 跳过整个模块/文件,直接查看该文件是否有副作用

usedExports

为了可以看到 usedExports带来的效果,我们需要设置为development模式,因为在production模式下,webpack默认的一些优化会带来很大额影响。

现在准备我们的priceFormat.js文件和入口文件main.jsmain.js只引入priceFormat.js文件中的add函数

// priceFormat.js
const add = (a, b) => {
  return a + b
}

const minus = (a, b) => {
  return a - b
}

// console.log(add(1, 2))

export {
  add,
  minus
}
import { add } from './js/priceFormat'
var dom = document.createElement('div')
dom.innerHTML = 'div' + add(1, 2)
document.body.appendChild(dom)

配置webpack并重新打包

{
  mode: 'development',
  devtool: 'source-map',
  optimization: {
    usedExports: true
  }
}

webpack5之性能优化

usedExports设置为true时,会有一段注释: unused harmony export minus,这段注释的意义是告知Terser在优化时,可以删除掉这段代码。

现在这个时候,我们将minimize设置true,并重新打包

{
  mode: 'development',
  devtool: 'source-map',
  optimization: {
    usedExports: true,
    minimize: true
  }
}

现在在来看打包文件,minus函数已经被消除,所以usedExports实现Tree Shaking是结合Terser来完成的。

sideEffects

  • sideEffects用于告知webpack compiler哪些模块时有副作用的,副作用的意思是这里面的代码有执行一些特殊的任务,不能仅仅通过export来判断这段代码的意义
  • 在package.json中设置sideEffects的值,默认为true,表示所有引入的文件都是存在副作用的,不会删除掉
    • 如果我们将sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports
    • 如果有一些我们希望保留,可以设置为数组

webpack5之性能优化

现在我们在package.json中将sideEffects设置成false,表示都没有副作用,之后在main.js中引入 foo.js文件,并重新打包

// foo.js
console.log('foo')
// main.js
import './js/foo'

webpack5之性能优化

我们可以看到打包后的文件里面并没有出现foo.js的代码,因为它认为foo.js不是一个副作用文件,因此在打包过程中将其删除了。

我们也可以将sideEffects设置为数组,并将副作用的文件存放进去,打包就不会删除。

webpack5之性能优化

比如我们将sideEffects设置为["./src/js/foo.js"],重新打包后

webpack5之性能优化

现在我们可以看到打包文件内出现了foo的代码,因此配置已生效。在引入css模块时,我们也可以在rules配置sideEffects,这样css模块就会被认为是副作用文件不会被删除,配置如下

{
  test: /.less$/,
  use: [
    isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
    'css-loader',
    'postcss-loader',
    'less-loader'
  ],
  sideEffects: true 
},

CSS Tree Shaking

除了JS的Tree Shaking外,CSS也有Tree Shaking。CSS的Tree Shaking需要借助于一些其他的插件,在早期的时候,我们会使用 PurifyCss 插件来完成CSS的tree shaking,但是目前该库已经不再维护了,目前我们可以使用另外一个库来完成CSS的Tree Shaking,PurgeCSS,也是一个帮助我们删除未使用的CSS 的工具,安装 npm install purgecss-webpack-plugin -D 。相关配置如下

const PurgecssWebpackPlugin = require('purgecss-webpack-plugin')
const glob = require('glob')  // webpack自带安装的插件 用来匹配文件夹或者文件
{
  plugins: {
    //...
    new PurgecssWebpackPlugin({
      paths: glob.sync(`${resolve('./src')}/**/*`, {nodir: true}),  // 匹配src目录下所有文件,不包括文件夹
      safelist: function() {  // 安全的白名单,不会被tree shaking
        return {
          standard: ['html', 'body']
        }
      }
    }),
  }
}

该插件目前并不能对vue模版写法中style里面的css tree shaking,有小伙伴知道如何配置么!

动态链接库

DLL是一种软件链接库,我们可以共享,不经常改变的代码,将其抽取成一个共享的库,这个库在之后编译的过程中,会被引入到其他项目的代码中。DLL库的使用分为两步:

1.打包一个DLL库

我们新建一个项目,添加webpack配置文件,我们使用一个webpack内置的 DllPlugin 插件,我们将vue打包成一个DLL库

const { DllPlugin } = require('webpack')
const path = require('path')
const resolve = (src) => {
  return path.resolve(__dirname, src)
}
module.exports = {
  mode: 'production',
  entry: {
    vue: ['vue']
  },
  output: {
    path: resolve('./dll'),
    filename: 'dll_[name].js',
    library: 'dll_[name]'
  },
  plugins: [
    new DllPlugin({
      name: 'dll_[name]',
      path: resolve('./dll/[name].manifest.json')
    })
  ]
}

重新打包后我们得到一个dll构建目录,里面有 dll_vue.js 和 vue.manifest.json 文件

2.项目中引入DLL库

我们将构建目录复制到需要引用的项目下,并使用如下配置,该配置中我们使用webpack内置的 DllReferencePlugin 插件,除此外我们还需要安装一个 add-asset-html-webpack-plugin 依赖包,该依赖用于将静态资源引入到html中,这里其实做了两个操作,一个是将静态资源赋值到构建目录中,第二是在html注入script并引用该静态资源,安装 npm install add-asset-html-webpack-plugin -d 。

const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')
const { DllReferencePlugin } = require('webpack')

module.exports = {
  //...
  plugins: [
    //...
    new DllReferencePlugin({
      context: resolve('./'),
      manifest: resolve('./dll/vue.manifest.json')
    }),
    new AddAssetHtmlPlugin({
      outputPath: './auto',
      filepath: resolve('./dll/dll_vue.js')
    })
  ]
}

CDN引入

CDN的全称是Content Delivery Network,即内容分发网络。CDN是构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。在实际项目中,我们使用CDN的主要方式主要有两种

  1. 打包的所有静态资源,放到CDN服务器,用户所有资源都是通过CDN服务器加载
  2. 一些第三方资源放到CDN服务器上

一般大厂会购买自己的CDN服务,但是正常我们还是会使用第二种方法,比如我们使用了lodash和dayjs这两个库,在生产环境,我们可以配置externals属性,传入一个对象,key为引入的安装包,value是安装包使用到的全局变量

externals: {
  lodash: '_',
  dayjs: 'dayjs'
}

webpack5之性能优化

我们在模版index.html通过ejs语法配置上依赖包的cdn链接,重新打包后vender.js中就没有了我们依赖的包的内容了。

GIZP压缩

HTTP压缩是一种内置在服务器和客户端之间的,以改进传输速度和带宽利用率的方式,HTTP压缩的流程什么呢

  1. HTTP数据在服务器发送前就已经被压缩了(可以在webpack中完成)
  2. 兼容的浏览器在向服务器发送请求时,会告知服务器自己支持哪些压缩格式
  3. 服务器在浏览器支持的压缩格式下,直接返回对应的压缩后的文件,并且在响应头中告知浏览器

目前的压缩格式非常的多,如下可见

  • compress – UNIX的“compress”程序的方法(历史性原因,不推荐大多数应用使用,应该使用gzip或 deflate)
  • deflate – 基于deflate算法(定义于RFC 1951)的压缩,使用zlib数据格式封装
  • gzip – GNU zip格式(定义于RFC 1952),是目前使用比较广泛的压缩算法
  • br – 一种新的开源压缩算法,专为HTTP内容的编码而设计

webpack中相当于是实现了HTTP压缩的第一步操作,我们可以使用 CompressionPlugin ,安装 npm install compression-webpack-plugin -D ,配置如下

const CompressionWebpackPlugin = require('compression-webpack-plugin')  // 压缩打包文件插件
{
  plugins: [
    //...
    new CompressionWebpackPlugin({
      threshold: 0,
      test: /.(css|js)$/i,
      minRatio: 0.8,
      algorithm: 'gzip'
    })
  ]
}

Chunk内联Html

另外有一个插件,可以辅助将一些chunk出来的模块,内联到html中,减少http请求数量

  • 比如runtime的代码,代码量不大,但是是必须加载的
  • 那么我们可以直接内联到html中

这个插件是在react-dev-utils中实现的,但是不会删除runtime文件,我们可以安装另一个插件, script-ext-html-webpack-plugin,安装 npm install script-ext-html-webpack-plugin -D。配置如下

const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin') // 辅助将一些chunk出来的模块,内联到html中(会删除runtime-chunk)
{
  plugins: [
    new ScriptExtHtmlWebpackPlugin({
      inline: /runtime.*.js(.gz)?$/  //正则匹配runtime文件名
    })
  ]
}

上述正则如果我们配置了gzip压缩,那么生成的gz文件也会被匹配被删除掉。

Hash值合理配置

在我们给打包的文件进行命名的时候,会使用placeholder,placeholder中有几个属性比较相似: hashchunkhashcontenthash,hash本身是通过MD4的散列函数处理后,生成一个128位的hash值(32个十六进制)

  • hash值的生成和整个项目有关系,比如我们有两个入口文件index.js和main.js,比如我们使用了hash,然后修改了main.js,那么我们的index.js文件名也是会改变。
  • chunkhash能解决上面的问题,如果我们修改了其中其中一个入口文件,那么另一个入口文件名就不会改变。
  • 但是如果比如index.js中引入了css文件,css是通过单独抽离出来的,当我们修改了index.js文件时,css文件名也是会改变,这个时候我们可以使用contenthashcontenthash跟内容有关,内容不变文件名不变

所以我们一般会对runtime chunk或者css chunkcontenthash,入口文件用chunkhash

Scope Hoisting

Scope Hoisting从webpack3开始增加的一个新功能,功能是对作用域进行提升,并且让webpack打包后的代码更小、运行更快

默认情况下webpack打包会有很多的函数作用域,包括一些(比如最外层的)IIFE: 无论是从最开始的代码运行,还是加载一个模块,都需要执行一系列的函数,Scope Hoisting可以将函数合并到一个模块中来运行。

使用Scope Hoisting非常的简单,webpack已经内置了对应的模块:

  • production模式下,默认这个模块就会启用
  • development模式下,我们需要自己来打开该模块,配置如下。
new webpack.optimize.ModuleConcatenationPlugin()