Skip to content

性能优化可以分为两块。

一块是通过webpack优化项目打包,进而提升项目性能。这一部分大致有splitChunksDLL以及gzip等,本文标题均以1-开头。

另一块则是通过配置项或插件对webpack的打包过程进行检测优化,本文标题均以2-开头。

1.项目优化

1-1.splitChunksPlugin

本章的代码仓库,已上传至github

我们都知道webpack入口文件开始,依次遍历依赖,打包成一个个的chunk。如果我们不对这些大chunk做处理,直接部署到服务器,那么是不能有效的利用浏览器缓存的,会对宽带造成极大浪费。

譬如现在有一个比较大的chunk,名为A。它内部会包含一些业务代码a、第三方库代码b或者一些不经常变动的代码c。如果我们不拆分A,那么在改动业务代码的时候,Achunkhash必然是会变的,就会导致浏览器缓存失去作用,重新发起请求。如果将A拆分成abca的改动并不会影响到bc,这俩部分依旧会读取浏览器缓存,这样就极大的节省了宽带。

webpack4.0版本开始使用splitChunksPlugin,以替代旧版的commonsChunkPlugin。而且webpack4.0已经默认内置了splitChunksPlugin插件,只需要我们在optimization.splitChunks中配置即可。

1-1-1.默认配置

在实际项目中,可能你明明没有配置过splitChunks相关配置,可打包过程中还是会有0.js1.js等文件,这是因为webpack4.0默认打包符合一些条件的chunk。它内置了一些默认规则:

js
module.exports = {
  optimization: {
    splitChunks: {
      // 哪些chunk会被分离 可选值有all initial async。
      chunks: 'async',
      // 当chunk块的体积大于该值时,被分离
      minSize: 30000,
      // 当目标代码被包含的chunk数量大于该值时,被分离。譬如 A>>>a B>>>a,则a的chunks为2
      minChunks: 1,
      // 异步chunk最大拆包数
      maxAsyncRequests: 5,
      // 入口同步chunk最大拆包数
      maxInitialRequests: 3,
      // 默认情况下,webpack将使用块的来源和名称生成名称,例如vendors~main.js
      automaticNameDelimiter: '~',
      // 主要用来防止同名的split-chunks被打包进同一个chunk包里
      name: true,
      // cacheGroups中的每个配置都可以使用上面的属性。webpack会自动覆盖。以cacheGroups中的为准。
      cacheGroups: {
        // 相对上面的默认缓存组来说,这里的高速缓存组有两个额外的属性test和priority,这俩值可以帮助用户更加精确的拆分包。
        vendors: {
          // 通常是一个正则 该正则用来匹配路径
          test: /[\\/]node_modules[\\/]/,
          // 该配置的权重 值越高越优先
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          // 允许重用现有的块,而不是在模块完全匹配时创建新的块
          reuseExistingChunk: true
        }
      }
    }
  }
}

在默认规则下,会根据如下条件进行代码优化(原文看这里):

  • 优化只针对动态引入的模块,即 async类型脚本,因为对原始(initialsync 类型的 bundle 进行拆分会产生新的 bundle,这个新产生的 bundle 需要被正确地在页面引入才能工作,这超出了 Webpack 作为脚本编译的范畴(将脚本插入页面是 html-webpack-plugin 干的事情)。这条规则确实是符合实际操作的,但是从默认配置的代码中推断,有些不符合。

  • 新产生的 chunk 来自 node_modules 或可被多个地方复用。

  • chunk 需要大于 30kb

  • chunks 的最大同时请求数小于等于5。换句话说,如果拆分后导致 bundle 需要同时异步加载的 chunk 数量大于 5 个或更多时,则不会进行拆分,因为增加了请求数,得不偿失。

  • 拆分后需要尽量做到对于入口文件中最大同时请求数控制在 3 个以内。

在满足最后两个条件时,决定了 chunks 应越大越好,而不是越多。

1-1-2.默认配置下的打包

首先说明下个人在学习过程中总结的两个概念:

  • 同步chunk: 利用import from或者import语法直接引入的方式,所分离的chunk
  • 异步chunk: 利用import()语法引入的方式,所分离的chunk

初始化项目,webpack的版本为4.29.0webpack-cli的版本为3.2.1。所有例子均以本部分的代码为基础。目录结构如下:

.
├── package.json
├── src
│   ├── assets
│   │   └── js
│   │       ├── a.js
│   │       ├── b.js
|   |       └── c.js
│   ├── main.js
|   └── entry.js
├── webpack
│   └── webpack.config.js
└── yarn.lock

webpack.config.js的代码如下:

js
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
// webpack拆包的分析插件 能更清楚的观察到拆包细节
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
  entry: {
    main: path.resolve(__dirname, '../src/main.js'),
    entry: path.resolve(__dirname, '../src/entry.js')
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, '../src')
    },
    extensions: ['.js', '.json']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[chunkhash:6].js',
    chunkFilename: 'js/[name].[chunkhash:6].js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new BundleAnalyzerPlugin()
  ]
}

main.js的代码如下:

js
// 同步chunk
import _ from 'lodash'
// 同步chunk
import axios from 'axios'

const btn = document.createElement('button')
btn.innerText = 'import a.js'
btn.onclick = function () {
  // 异步chunk-a a>>>jquery
  import('@/assets/js/a.js')
  // 异步chunk-b b>>>jquery
  import('@/assets/js/b.js')
  // 异步chunk-c  加载
  import('@/assets/js/c.js')
  // import('axios')
}
document.body.appendChild(btn)

entry.js的代码如下:

js
import _ from 'lodash'

a.jsb.js的代码都如下:

js
import $ from 'jquery'

c.js的代码如下:

js
import axios from 'axios'

在执行打包命令前,先分析下上述代码,在打包后,会有多少个拆包。

  • 一个main包,内部包含lodashaxios以及main本身的代码。
  • 一个entry包,内部包含lodash
  • main中三个动态引入,所以有三个异步包。而且异步chunk c当中不会有axios,这是因为main的同步chunk已经引入了axios。额外一句,如果上面异步chunk-c使用的是import('axios'),那么只会有两个异步包,chunk-c不会打出来。
  • main有两个动态引入都引入了jquery,所以会再拆一个包。

根据上面的分析,最终结果会有6个包。直接运行yarn start,打包结果如下图:

可以看出在默认配置下我的上述分析是正确的。

TIP

1.异步chunk与同步chunk并不共享chunks数量。minChunks指的是initial和async的各自chunk数量,即使chunks设置为all。

2.在默认splitChunks的设置下,如果主chunk中已经引入了某第三方node_modules代码,那么异步chunk中的该对应第三方库代码不会被拆包,该第三方库代码会在主chunk中。

3.异步chunk总是默认被拆包的。

1-1-3.第三方库拆包

1-1-2的默认配置下,可以看到主chunk中有很多的第三方库代码。为了更好的缓存,我们将其提取成单独的chunk

js
cacheGroups: {
  // 默认分离异步chunk中的node_module代码 这里改成同步
  libs: {
    test: /[\\/]node_modules[\\/]/,
    chunks: 'initial',
    priority: 10
  },
  // 分离下lodash
  lodash: {
    test: /[\\/]node_modules[\\/]lodash[\\/]/,
    chunks: 'initial',
    priority: 20
  }
}

再分析下打包结果,应该是8个chunk

  • main
  • main包中的同步chunklodash axios
  • main包中的异步chunka b c
  • ab的公共chunk
  • entry包(它里面的lodash不会再单独分包,因为main已经分了)

执行命令后,具体打包情况如下:

1-1-4.不同入口间的公共代码拆包

在多页面应用当中,多个入口文件之间可能存在大量重复的代码。譬如一般我们的项目当中都会有一个工具库utils.js,它用来存放一些全局通用的方法。如果这个文件特别大的话,那么就很有拆包的必要了。

在本例中,mainentry两个入口文件之间都引入了lodash这个第三方库,我们来用这个来类比。

js
cacheGroups: {
  common: {
    minChunks: 2,
    chunks: 'initial',
    priority: 20
  }
}

同样,先分析结果:

  • main
  • main包的异步chunk: a b c
  • ab的公共代码包
  • entry
  • main以及entry之间的公共代码包

执行命令,分析图如下:

TIP

项目当中的两条分包准则:

1.第三方库的分包,推荐使用test检测。由于webpack已经内置了对异步chunk中的第三方库的分包,所以一般我们可以根据实际需要配置同步chunk即可。即chunksinitial,也可以粗暴点,设置成all,但要注意priority应大于webpack内置的默认值。

2.公共代码的分包,使用minChunks来检测。同样的,webpack也内置了对异步chunk中的公共代码的分包。我们可以根据需要配置同步chunk中的公共代码分包。

这两条不仅适用单页面应用,更适用于多页面应用。

1-1-4.同入口间的公共代码拆包

在上例中,我们看到了如何在不同入口间的公共代码拆包。看起来很简单。现在尝试下同入口间的公共代码拆包。

先改造下我们的文件,在assetsjs文件夹下增加一个d.js,它内部的代码如下:

js
import axios from 'axios'
console.log('d.js')

然后在main.js引入:

js
import _ from 'lodash'
import axios from 'axios'
// 引入d.js
import '@/assets/js/d.js'

const btn = document.createElement('button')
btn.innerText = 'import a.js'
btn.onclick = function () {
  import('@/assets/js/a.js')
  import('@/assets/js/b.js')
  import('@/assets/js/c.js')
}
document.body.appendChild(btn)

console.log('main.js')

这样,在main.js当中,主chunk引入了axiosd.js中也引入了axios。如果直接使用1-1-3中的缓存组配置,进行打包,发现是不会有额外的包分出来的。

解决办法是,minChunks设置为1

TIP

刚开始到这里,我是有一些疑问的。在a.jsb.js中都有jquery这个公共库,这个包的自动拆分是使用了webpack的默认配置。

default: { minChunks: 2, priority: -20, reuseExistingChunk: true }

为什么minChunks: 2不能拆分出同入口文件件的公共代码。因为在我看来main.js是一个chunkd.js是一个chunk。已经满足了minChunks:2

于是我得出了一个结论,minChunks在计算同步chunk与异步chunk时的计数方式不同。

当是同步chunk时,其主chunk(即入口chunk)计为1个。当是异步chunk时,一个异步chunk记为1个。

1-1-5.maxAsyncRequests与maxInitialRequests

官网或者其他一些资料,在介绍这俩概念时有点太让人不解了。我个人理解如下:

  • maxAsyncRequests:异步chunk的最大拆包数。
  • maxInitialRequests:入口chunk的最大拆包数。

TIP

注意:异步chunk本身也算是一次拆包。

譬如:异步文件demo.js中有vueelement-ui包,拆包结果为0.hash.jsvue.hash.js以及element.hash.js。其中0.hash.js中是demo.js本身的代码。 那么此时这个异步chunk的拆包数是3,而不是20.hash.js也要计算在内。

a.jsb.js中添加引入vue

js
cacheGroups: {
  libs: {
    test: /[\\/]node_modules[\\/]/,
    priority: 10
  },
  // 当jquery和vue都加上maxAsyncRequests为1时 会发现jquery和vue会被打进同一个包内。
  jquery: {
    test: /[\\/]node_modules[\\/]jquery[\\/]/,
    maxAsyncRequests: 1,
    priority: 20
  },
  vue: {
    test: /[\\/]node_modules[\\/]vue[\\/]/,
    maxAsyncRequests: 1,
    priority: 20
  }
}

这部分写的不太好。例子刚开始设计的时候没有考虑到,导致现在来叙述这部分时,措辞过长。所以这部分推荐一个链接

1-1-6.runtimeChunk

optimization.runtimeChunk用来抽离每个chunk中的webpack加载代码。webpackruntime的概念,会在每次编译完成后,在chunk中生成一堆加载逻辑代码,为了更有效的利用浏览器缓存,可以将这部分也抽离出来。

  • 单页面应用可设置为singleobject.name
  • 多页面应用设置为multiple

设置完毕后,webpack会针对每一个入口文件生成一个对应的runtime文件。

1-2.gzip

将资源进行gzip压缩后,体积更小,传输效率更高。通常情况下是由服务端譬如nginx将资源进行压缩并返回给客户端,然后浏览器解析gzip压缩后的文件。目前主流浏览器均已支持gzip压缩。

webpack中设置gzip,主要是为了将资源提前压缩,这样在部署后就可以降低nginx的消耗,提升网站响应效率。

webpack需要使用compression-webpack-plugin插件,将资源进行压缩。核心配置如下:

js
const CompressionWebpackPlugin = require('compression-webpack-plugin')

module.exports = {
  plugins: [
    new CompressionWebpackPlugin({
      // 压缩后的文件命名 默认为'[path].[base].gz'
      filename: '[path].[base].gz',
      // 采用的压缩算法
      algorithm: 'gzip',
      // 目标资源
      test: /\.(js|css)$/,
      // 阈值 大于该值的文件才会经历压缩 这里设置成1024b
      thresold: 10240,
      // 压缩比例 1-10 数值越大 压缩效果越好 但也会更加耗时
      minRatio: 8,
      // 压缩文件后 是否删除源文件 默认不删除
      deleteOriginalAssets: true
    })
  ]
}

当我们使用compression-webpack-plugin进行压缩后,在部署时还需要nginx的配合。常见配置如下:

nginx
#开启gzip压缩 on开启 off关闭
gzip on;
#开启nginx_static后,对于任何文件都会先查找是否有对应的gz文件。如果有,直接使用,nginx不再对该文件gzip。
gzip_static on;
gzip_min_length 1k;
gzip_buffers 4 32k;
gzip_http_version 1.1;
gzip_comp_level 2;
gzip_types text/plain application/x-javascript text/css application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]."

1-3.Long Term Caching

参考链接

webpack4.0下的最佳实践,一言以蔽之:

  • output.filename 以及 output.chunkFilename 使用 chunkhash。而 extractCss 使用 contenthash
  • gif|png|jpe?g|eot|woff|ttf|svg|pdf 等使用 hash。其配置的hash 表示的是静态文件的内容 hash 值,不是 webpack 每次打包编译生成的 hash 值。
  • code spliting 当中利用 optimization.runtimeChunk 抽取 runtime.js。这样可以保证每个 chunk 相互独立、降低影响,chunk 当中不包含其他冗余代码。
  • 在生产环境中,使用 webpack.HashdModuleIdsPlugin 以及 webpack.NamedChunksPlugin 以保证 chunkhash 的稳定。
  • 项目部署到生产后,htmlcache-control 最好设置成 no-cache。而其他资源(jscssimg等)的 cache-conrol 可设置成一个较大值。

TIP

  • 保证module id的稳定。开发环境下可使用webpack.NamedModulesPlugin(利用路径替代原本的数值型id),生产环境下可使用webpack.HashdModuleIdsPlugin(对比前者,将路径hash化,减小代码size)。
  • 保证chunk id的稳定。在开发环境和生产环境中,使用webpack.NamedChunksPlugin

2.webpack打包优化

2-1.DLL

本章的代码仓库,已上传至github

DLL意为动态链接库,即Dynamic Link Library。在split-chunks一章中,我们知道为了浏览器更好的缓存,可以将第三方模块分包。 但是如果第三方模块很多,webpack进行构建的时间就会很长。为了优化时长、提高打包效率,可以考虑一次性的将不经常变动的第三方模块单独打包,这样以后只需要打包业务代码

TIP

DLLsplit-chunks并不冲突。它们针对的优化点并不相同,二者可以相辅相成。

2-1-1.流程梳理

  • webpack已经自带了DllPlugin以及DllReferencePlugin插件。
  • 创建一个配置文件webpack.dll.js,这个文件主要是用来生成dll文件以及manifest文件。dll文件会声明一个全局变量,可以在html当中引用。而DllPlugin生成的manifest文件可以看做一份信息清单。
  • webpack.config.js中,使用webpack.DllReferencePlugin并配置manifest,映射到上步骤中建立的manifest文件。

2-1-2.初始项目搭建

项目基础结构如下:

.
├── index.html
├── package.json
├── src
│   ├── a.js
│   ├── b.js
│   └── main.js
|
├── webpack
│   ├── webpack.config.js
│   └── webpack.dll.js
└── yarn.lock

scripts及项目依赖如下:

json
{
  "scripts": {
    "start": "webpack --config webpack/webpack.config.js",
    "report": "config_report=true webpack --config webpack/webpack.config.js",
    "dll": "webpack --config webpack/webpack.dll.js"
  },
  "devDependencies": {
    "clean-webpack-plugin": "^3.0.0",
    "copy-webpack-plugin": "5.0.0",
    "html-webpack-plugin": "^4.5.0",
    "webpack": "4.29.0",
    "webpack-bundle-analyzer": "^4.1.0",
    "webpack-cli": "3.2.1"
  },
  "dependencies": {
    "element-ui": "^2.14.1",
    "vue": "^2.6.12"
  }
}

main.jsa.jsb.js文件的内容如下:

js
// main.js
import vue from 'vue'
import element from 'element-ui'
import('./a')
import('./b')
console.log('main.js')

// a.js
import vue from 'vue'
console.log('a.js')

// b.js
import vue from 'vue'
console.log('b.js')

webpack.config.js文件内容如下:

js
const path = require('path')
const webpack = require('webpack')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

const config = {
  // 实际生产,这里设置成production
  mode: 'development',
  entry: {
    main: path.resolve(__dirname, '../src/main.js')
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, '../src')
    },
    extensions: ['.js']
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[chunkhash:6].js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../index.html')
    }),
    new CleanWebpackPlugin()
  ]
}

// 是否开启webpack-bundle-analyzer
// 这里对应的package.json中的script为 { "report": "config_report=true webpack --config webpack/webpack.config.js"
if (process.env.config_report) {
  config.plugins.push(new BundleAnalyzerPlugin())
}

module.exports = config

执行yarn start后,结果如下:

可以看出,构建时间为2.48s

2-1-3.DllPlugin

执行yarn report,查看下打包情况:

现在我们开始使用DllPlugin,将vueelement-ui打包成vendor

webpack.dll.js内容如下:

js
const path = require('path')
const webpack  = require('webpack')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  // 实际生产 这里设置成production
  mode: 'development',
  entry: {
    vendor: ['vue', 'element-ui']
  },
  output: {
    path: path.resolve(__dirname, '../static'),
    filename: 'dll/[name].[hash:4].dll.js',
    // ①dll会将资源打成新的js包 并需要手动在html添加链接引用 所以这里是把资源导出为一个全局变量
    library: '[name]_[hash:4]_dll'
  },
  plugins: [
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: [path.resolve(__dirname, '../static/dll')]
    }),
    // !!! DllPlugin的主要作用和价值就是生成一个manifest文件
    new webpack.DllPlugin({
      context: __dirname,
      // ①manifest文件的name值 必须与output.library保持一致
      name: '[name]_[hash:4]_dll',
      // 存放manifest文件的位置 DllReferencePlugin会根据这个文件进行寻找模块与全局变量之间的关系
      path: path.resolve(__dirname, './dll', '[name].manifest.json')
    })
  ]
}

执行yarn dlldll文件将被打包到根目录下static/js文件夹下。而由DllPlugin生成的manifest将会存在webpack/dll文件夹中。

2-1-4.DllReferencePlugin

在上步中,已经得到了dll文件以及manifest文件。现在我们就要在业务代码的构建过程中引入这些相关的第三方模块。需要对webpack.config.js文件做一些额外改造。

  • dll文件

index.html模板中,添加script标签,譬如:

html
<script src="/static/js/vendor.hash.dll.js"><script>

这样,在经过html-webpack-plugin插件处理后,生成的dist/index.html就会有相应的script标签。但这样还是不够,因为static文件夹也需要拷贝到dist目录中,我们需要在webpack.config.js中添加使用copy-webpack-plugin插件。代码如下:

js
// 不同版本的copy-webpack-plugin插件 用法可能略有区别
new CopyWebpackPlugin([
  {
    from: path.resolve(__dirname, '../static'),
    to: path.resolve(__dirname, '../dist/static')
  }
])
  • manifest文件

webpack.config.js文件中使用DllReferencePlugin插件,代码如下:

js
new webpack.DllReferencePlugin({
  context: __dirname,
  manifest: require('./dll/vendor.manifest.json')
}),

经过上面的操作,我们就完成了webpack.config.js的改造,现在来执行下yarn start。构建时间从之前2.48s下降到了1.03s。注意红框里的内容,说明目标第三方模块是从dll文件中读取的:

查看yarn report的结果,同样可以看到第三方是从我们的dll文件中获取的:

2-1-5.注意点

在实际操作中,有以下几个地方需要额外注意:

  • output.libraryDllPlugin配置项中的name必须一致。
  • DllPluginDllReferencePlugin的配置项中都需要有context,而且二者需要一致。(webpack.dll.jswebpack.config.js最好在同一目录下)。
  • 每次利用DllPlugin构建新的dll文件后,需要在模板index.html的链接当中对应修改。