Webpack SplitChunksPlugin插件研究

5,158 阅读10分钟

SplitChunksPlugin初见

自Webpack4以后,代码拆分的插件由CommonsChunkPlugin变成了SplitChunksPlugin,并且不必单独引用,集成到了Webpack之中,通过配置下的optimization.splitChunks和optimization.runtimeChunk就可以控制了。

研究插件经验

前两天研究了一下CommonsChunkPlugin插件,总结出来一条经验,就是要理解这个插件,单纯看如何配置它,是不会懂它的。

先知道它的设计思路,再学习如何配置它。

CommonsChunkPlugin的不足

Webpack4的代码拆分方案,完全换了一个插件,想必设计思路和使用上差别会比较大,实际上也的确如此。

如果是一些简单的重复代码的拆分,CommonsChunkPlugin是可以胜任的。但一些复杂的场景,CommonsChunkPlugin就不行了。

复杂场景举例

我们的项目结构是这样的:

  • 有一个入口文件: index.js
  • 三个用于异步加载的文件:Greeter1.js、Greeter2.js、Greeter3.js
  • Greeter1.js、Greeter2.js引用了React.js,Greeter3.js 引用了Vue.js

使用CommonsChunkPlugin的情况

这时候用Webpack打包此项目,使用CommonsChunkPlugin的话,会将React.js Vue.js这些库打包到vendor.js中。

这样做的问题:

  1. 我们得到了一个非常大的公共Chunk。浏览器加载我们的项目首屏时,会加载入口Chunk index.js和公共Chunk vendor.js。这样对首屏加载速度是不利的。
  2. 某用户只是看Greeter1.js和Greeter2.js这两个Chunk的内容,那么对于他来说,加载Vue.js这样根本用不到的库,只是浪费流量。

理想的拆分和使用情况

不是将React.js、Vue.js打包到同一个vendor Chunk中,而是Webpack通过分析,将React.js打包到一个vendor~Greeter1~Greeter2.js中,将Vue.js打包到一个vendor~Greeter3.js中,这样分别打包公共代码。

然后首屏加载的时候,只加载入口Chunk index.js。等用户查看Greeter1.js的时候,再并行加载Chunk Greeter1.js和Chunk vendor~Greeter1~Greeter2.js。查看Greeter3.js的时候,再并行加载Chunk Greeter3.js和Chunk vendor~Greeter3.js。

这样,解决了上面提到的两个问题,首屏速度和流量浪费。

使用SplitChunksPlugin解决

SplitChunksPlugin就是可以应付上面描述的复杂的拆分情况,比较理想的拆分代码。

搭建实验项目

按照上面描述的,我们新建文件,目录结构如下:

image

文件内容如下:

// index.js 
// 这样就是异步加载Greeter1、2、3 三个Chunk

import(/* webpackChunkName: "Greeter1" */'./Greeter1').then(module => {
  const greeter = module.default
  document.querySelector("#root").appendChild(greeter());
})

import(/* webpackChunkName: "Greeter2" */'./Greeter2').then(module => {
  const greeter = module.default
  document.querySelector("#root").appendChild(greeter());
})

import(/* webpackChunkName: "Greeter3" */'./Greeter3').then(module => {
  const greeter = module.default
  document.querySelector("#root").appendChild(greeter());
})
// Greeter1.js / Greeter2.js
// 我们就这样模拟引用了React。Greeter2.js和Greeter1.js长得一样。

import React from 'react';
console.log(React);

export const greeter = function () {
  var greet = document.createElement('div');
  greet.textContent = "Hi there and greetings!";
  return greet;
};
// Greeter3.js
// Greeter3中引用的是Vue

import Vue from 'vue';
console.log(Vue);

export const greeter = function () {
  var greet = document.createElement('div');
  greet.textContent = "Hi there and greetings!";
  return greet;
};

再来看看关键的Webpack配置

module.exports = {
  entry: {
    index: __dirname + "/app/index.js",
  },
  output: {
    path: __dirname + "/public",//打包后的文件存放的地方
    filename: "[name].js", //打包后输出文件的文件名
    chunkFilename: '[name].js',
  },
  mode: 'development',
  devtool: false,

  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
        },
      }
    }
  },
}

关键的就是optimization下的配置,我们先只关注cacheGroups下的vendors配置。其中的test是分割代码的规则,代表node_modules文件夹下的代码都要被抽离出来。我们运行一下Webpack,看看输出结果:

image

我查看了一下vendor~Greeter1~Greeter2.js文件,里面是React打包后的代码。 我查看了一下vendor~Greeter3.js文件,里面是Vue库打包后的代码。

再利用打包分析,看到:

image

这不就是我们设想的那种理想情况。如果是CommonsChunkPlugin,配置后,只会帮我们打包出一个vendor.js的公共Chunk,而SplitChunksPlugin,我们只是告诉它node_modules下的文件要抽离出来,Webpack就根据项目的引用情况,自动分理处两个公共Chunk vendor~Greeter1~Greeter2.js 和 vendor~Greeter3.js

SplitChunksPlugin设计思路总结

解决复杂场景下的代码拆分问题。针对异步加载中公共模块的拆分,我们只需设置需要被公共打包的代码,SplitChunksPlugin就会自动帮我们按照各异步模块的需求,将公共的Chunk拆分成一些小的公共Chunks。供各异步模块使用。并且这些公共Chunks不会首屏加载,会随着使用使用它们的异步模块,使用时再一同并行加载。

核心思路:根据我们给的规则拆分代码,然后针对拆分的公共Chunk,再次拆分。

拆分出来的Chunk过多,怎么办

到这里,还需一种极端情况,就是被拆分出来的公共Chunk,太多了。Webpack的初衷是合并代码啊,这又给拆碎了。

过多Chunk导致的问题就是浏览器同时需要并发请求太多的js。

同样的SplitChunksPlugin也替我们想到了。

我们再来做一个实验。

复杂场景举例:

  • 有一个入口文件: index.js
  • 有四个用于异步加载的文件:Greeter1.js、Greeter2.js、Greeter3.js、Greeter4.js。
  • 有三个给上四个文件引用的文件:helper1.js、helper2.js、helper3.js(注意这里helper*.js都要大于30k,太小了Webpack不会将其抽离出一个Chunk)。
  • Greeter1.js引用helper1.js,Greeter2.js引用helper2.js,Greeter3.js引用helper3.js,,Greeter4.js同时引用helper1.js、helper2.js、helper3.js。

我画一个图说明情况:

image
G1代表Greeter1.js,h1代表helper.js。依此类推。这样的引用,形成了helper1~3都成了公共模块。需要我们将其提取出Chunk。使用Wepback编译,查看结果:

image

是否跟你预想的一样,出现了三个公共Chunk,也就是我们上图画的公共部分,分别包含了header1~3的代码。再看更直观的效果图:

image

Greeter4.js的问题

浏览器加载Greeter4.js的时候,需要同时加载default~Greeter1~Greeter4.js、default~Greeter2~Greeter4.js、default~Greeter3~Greeter4.js三个Chunk。也就是用户看Greeter4.js时,需并行请求4个js文件。

问题就在于我们把公共包拆的过于细,有可能会出现,加载一个异步Chunk的时候,需要同时并且请求很多的公共Chunk,这不是我们想看到的,为此,SplitChunksPlugin提供给我们一个属性maxAsyncRequests,限制最大并行请求数。

目前的最大的并行请求数是加载Greeter4.js时的4,我们设置成3,看看什么效果:

optimization: {
    splitChunks: {
      maxAsyncRequests: 3, // 在此设置
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  },

运行webpack,效果如下:

image

Greeter4将helper3.js打到一个Chunk里,然后helper1、helper2单独打包,这样Greeter4的并行请求数等于3,符合预期。

同样的将helper3.js同样被Greeter3.js引用,所以也打包到了Greeter3中,造成了重复打包helper3。为了减少并且请求数,就会导致一定程度的重复打包,我们要做的,就是通过配置在平衡并且请求数和重复打包率上做一个平衡。

总的来说SplitChunksPlugin还是很智能啊,我们只是提出要求(并行请求数要小于等于3),它就会基于此条件为我们的进行拆包和组合包。

SplitChunksPlugin默认配置

即使我们不写optimization,Webpack也会帮助我们进行代码拆分,相当于我们写了如下的配置:

splitChunks: {
    chunks: "async", // 默认只处理异步chunk的配置
    minSize: 30000, // 如果模块的最小体积小于30,就不拆分它
    minChunks: 1, // 模块的最小被引用次数
    maxAsyncRequests: 5, // 异步加载Chunk时的最大并行请求数
    maxInitialRequests: 3, // 入口Chunk的最大并行请求数
    automaticNameDelimiter: '~', // 文件名的连接符
    name: true, // 此处写成false,公共块就不会是default~Greeter1~Greeter4.js了,而是0.js这样命名Chunk。
    cacheGroups: { // 缓存组,拆分Chunk的规则
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10, // 此数越大,越优先匹配
        },
        default: {
            minChunks: 2, // CommonsChunkPlugin的minChunks既可以传方法,也可以传数字,现在只可以传数字了,如果你想传方法,用test属性
            priority: -20,
            reuseExistingChunk: true //  配置项允许重用已经存在的代码块而不是创建一个新的代码块。这句我不懂,有知道的小伙伴麻烦告诉我一下
        }
    }
}

可以看到默认配置只对异步加载的Chunk有效,原因是配置了 chunks: "async"。

以下是默认配置的描述:

  • 被共享的代码块或者来自node_modules文件夹
  • 被分割的代码块要大于30kb(在min+giz之前)
  • 按需加载代码块的请求数量应该<=5
  • 页面初始化时加载代码块的请求数量应该<=3

这些描述分别对应了上面哪条配置,相信大家都清楚了。如果没有经过分析,这些描述真是让人摸不着头脑。

maxInitialRequests

maxInitialRequests字段我们还没有解释,看字段名字应该是初始化时,也就是针对入口Chunk的分割吧,于是我做了如下配置:

module.exports = {
  entry: {
    Greeter1: __dirname + "/app/Greeter1.js",
    Greeter2: __dirname + "/app/Greeter2.js",
    Greeter3: __dirname + "/app/Greeter3.js",
    Greeter4: __dirname + "/app/Greeter4.js",
  },
  output: {
    path: __dirname + "/public",//打包后的文件存放的地方
    filename: "[name].js", //打包后输出文件的文件名
    chunkFilename: '[name].js',
  },
  mode: 'development',
  devtool: false,

  optimization: {
    splitChunks: {
      chunks: "initial", // 默认只处理异步chunk的配置
      maxInitialRequests: 3, // 一个入口最大并行请求数
      cacheGroups: { // 缓存组
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  },
}

我们来看打包效果:

image

我将maxInitialRequests调成5,再来看打包效果:

image

打包的结果,和我们分析异步Chunk的提取策略一致,限制为5的时候,即使是Greeter4.js的最大并且请求数才是4,所以可以尽情的拆包。但限制为3的时候,Webpack就不把helper3.js单独拆成一个公共Chunk了,而是分别打包到引用了它的Greeter4.js和Greeter3.js里,以此来限制Greeter4这个入口Chunk被加载时,并行请求为3。可以说maxInitialRequests就是 针对多入口限制拆包数量的maxAsyncRequests。

拆分runtime的代码

说了这么多,还没有提到拆分runtime呢。SplitChunksPlugin拆分runtime只需配置一个属性,如下:

 optimization: {
    runtimeChunk: true,
    splitChunks: {
      chunks: "initial", // 默认只处理异步chunk的配置
      minSize: 30000, // 如果模块的最小体积小于30,就不拆分它
      minChunks: 1, // 模块的最小被引用次数
      maxAsyncRequests: 5, // 按需加载的最大并行请求数
      maxInitialRequests: 5, // 一个入口最大并行请求数
      automaticNameDelimiter: '~', // 文件名的连接符
      name: true,
      cacheGroups: { // 缓存组
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  },

我这里还是沿用上一个例子,打包结果如下:

image

很有意思,针对我们四个入口文件,分别生成了四个文件,runtime~Greeter1到4。这也符合预期,使用哪个入口的代码,就也加载它对应的runtime文件。

让我们回到最初

现在,我们还是回到最初的一个简单的例子,结束我们今天的研究。

基本场景

不考虑异步加载模块,只是分离业务代码,第三方库代码和runtime代码。

配置

入口文件index.js,里面只引用了react。 配置如下:


module.exports = {
  entry: {
    index: __dirname + "/app/index.js",
  },
  output: {
    path: __dirname + "/public",//打包后的文件存放的地方
    filename: "[name].js", //打包后输出文件的文件名
    chunkFilename: '[name].js',
  },
  mode: 'development',
  devtool: false,

  optimization: {
    runtimeChunk: true,
    splitChunks: {
      chunks: "initial", 
      automaticNameDelimiter: '~',  
      name: true,
      cacheGroups: { // 缓存组
        vendors: {
          test: /[\\/]node_modules[\\/]/,
        },
      }
    }
  },
}

打包结果

image

我们看到react是第三方库,提取到了vendors~index.js中,runtime代码,提取到了runtime-index.js,业务代码,就是index.js。

结束语

Webpack的官方文档,没有解释的那么清楚,对于Webpack的学习,需要多多动手,在实践中,帮助我们学习体会Webpack。

SplitChunksPlugin要理解起来还是稍微复杂一点的,它的设计就是为了搞定复杂的拆分情况。但摸清它的原理后,发现它还是很强大的,通过几项配置,就可以完成复杂情况下的代码拆分。