我们开发的项目中,具体代码文件主要包含三种类型,有 第三方库、工具函数、业务代码。在这篇文章,你会看到使用 webpack 处理这些文件的一些方法。

为了便于说明,首先,安装需要用到的第三方库,这里,以 jQuery 为例:

npm i -S jquery

一、直接引入

我们最常用的引入方式,就是用 AMD 或者 ES6 模块导入的形式在具体的业务模块中直接引入:

// header.js

import $ from 'jquery'; // 或者 const $ = require('jquery');

$('h1').hide();

如果webpack配置文件没有做其他相关设置,那么在这种情况下,jQuery 源码会和业务代码最终会打包到一个文件中。

倘若多个业务模块都引用了 jQuery,则在打包时,webpack很机智,不会对 jQuery 源码进行多次打包。即最终打包的文件,只包含一份 jQuery 源码。

二、webpack.ProvidePlugin

如果想要使用 jQuery,但又不想在业务代码中反复使用 import $ from 'jquery' 来引入 jQuery,你可以使用 ProvidePlugin 。它属于webpack的内置API,我们可以在 webpack 配置文件进行如下设置:

// webpack.config.js

const webpack = require('webpack');

module.exports = {
plugins: {
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery'
})
}
}

这样,我们甚至不用通过 require('jquery') 或者 import $ from 'jquery' 这种形式引入 jQuery,就能在开发模块中使用 jQuery 的 API 了,如:

// header.js

$('h1').hide(); // 或者 jQuery('h1').hide();

在使用 webpack-dev-server 插件启动本地服务开发的环境下,应用上面的设置,会把 jQuery 源码和其他模块一起打包成一个文件,假设打包后的文件为 main.js,当打开 main.js,你会发现,jQuery 的源码处于 main.js 的顶部,有关 jQuery 操作的代码,则是在后面的一个独立模块。

当打包发布生产环境时,jQuery 源码也会和其他模块一起打包成一个文件,也是位于文件的顶部,不同的是,有关 jQuery 操作的代码,是和 jQuery 源码处于同一模块。

由于在开发环境和生产环境,jQuery 都是以模块的形式呈现,因此,你在浏览器的控制台,无法使用 jQuery 的API。即只能是在开发模块中,使用在 ProvidePlugin 中定义好的 $ 或者 jQuery

如果要使用 jQuery 的第三方插件,也是非常方便的,比如绘制圆环的插件,安装npm包后,直接引入:

// header.js

import 'jquery-circle-progress';

$('#circle').circleProgress({
value: 0.75,
size: 80,
fill: {
gradient: ["red", "orange"]
}
});

三、expose-loader

通过前面的方式,我们知道,打包后 jQuery 源码以模块的形式呈现。因此,对于一些依赖它的文件,或者希望在Chrome开发者工具中,调试 jQuery API等情况并不友好。

针对这个问题,我们放弃使用 webpack.ProvidePlugin 方案(移除配置中的相关代码)。而是采用另外一种方案 - expose-loader。它的功能主要是将第三方库(本文指的是 jQuery)暴露给全局变量环境中,这样一来,无论是调试页面,还是依赖它的其他js文件,都能很方便的使用到它的API。

首先,我们安装它:

npm i expose-loader -D

然后,我们对 webpack 配置文件进行如下更新(前提是你已安装 jQuery):

// webpack.config.js

module.exports = {
module: {
rules: [
...
{
test: require.resolve('jquery'),
use: [{
loader: 'expose-loader',
options: '$'
}]
}
]
}
}

最后,需要在业务代码中引入第三方库,才能将它暴露到全局变量中:

// header.js

import $ from 'jquery'; // 或者 require('jquery');

倘若你希望对这个第三方库设置多个全局变量,则可以继续新增:

// webpack.config.js

module.exports = {
module: {
rules: [
...
{
test: require.resolve('jquery'),
use: [{
loader: 'expose-loader',
options: '$'
}, {
loader: 'expose-loader',
options: 'jQuery'
}]
}
]
}
}

上面的代码,除了原来的 $,现在还增加了 jQuery 作为 jQuery 的全局变量。如此一来,在 Chrome 开发者工具控制台中,无论是使用变量 $ 还是使用 jQuery,它们都能正常访问 jQuery 的 API。

如果项目中希望对其他第三方库(比如 lodash、react 等)暴露全局变量,那你只需要在安装好这些第三方库后,再在打包配置文件中,做相关新增类似配置即可。

可以看到,虽然在采用了 webpack.ProvidePlugin 方案后,模块无需在业务代码中引入第三方库,就能使用相关 API,但它无法全局,对依赖包不友好。而 expose-loader 则需要在业务代码中引入第三方库 ,它解决 webpack.ProvidePlugin 存在的问题。

四、externals

说到这里,其实对于上面的两种方案,我们都忽略一个重要的问题。

按理来说,对于第三方库而言,我们几乎不会修改它的源码。因此,并非每次修改完业务代码,都需要每次都将第三方库也重新打包一遍。

而上述的 webpack.ProvidePluginexpose-loader 方案,都存在第三方库重新打包或者说需要打包的问题,这就直接导致了打包效率慢。同时,这也导致了第三方库和业务代码打包在了一起,所以,打包后的文件通常都比较大。

而对于这两个问题,我们可以通过配置 externals 选项来解决。

首先,在webpack配置文件进行相关设置:

// webpack.config.js

module.exports = {
...
externals: {
jquery: '$'
}
}

然后,针对第三方库,我们一般用相对路径或者类似 CDN 这种绝对路径的形式,以 <script> 标签在页面里直接引入。这里我们拿 CDN 上的 jQuery 做演示:

<!-- index.html -->

...
<script src="https://code.jquery.com/jquery-3.1.0.js"></script>

最后,无需在业务代码中引入第三方库,就能直接使用 jQuery 的 API:

// header.js

$('h1').hide();

再次打包,你会发现打包效率变高了,因为第三方库不参与打包。你会发现打包后的文件变小了,因为第三方库与业务代码分离了。你会发现 jQuery 被暴露为全局变量,因为第三方库引用的是 CDN,而非webpack模块。另外,即使你没在业务代码中引入第三方库,你也在这个业务模块使用 jQuery 的 API。

但如果你不在 index.html 页面中 jQuery 文件(即移除上面的 script 连接),而是采用在业务代码中引入它(即 import $ from 'jquery'),则会直接导致报错。毕竟这也违背了 externals 分离第三方库的初衷。

可以说,externals 方案满足了我们大多数功能!

五、将第三方库从业务文件分离 - Entry + CommonsChunkPlugin

前面说到,由于第三方库几乎不怎么会变动,所以,我们通常希望这样处理它:

  • 打包时,不对第三方库进行打包(加快打包速度)
  • 将第三方库与业务代码分离,让第三方库充分利用浏览器缓存

5.1 最初打包

为方便说明,我们首先安装了两个工具库,分别是 jQuery 和 lodash:

npm i -S jquery lodash

假设有公共函数模块:

// utils.js

export function dataType(argument) {
return Object.prototype.toString.call(argument).replace(/\[object\s/, '').replace(/\]/, '');
}

再假设有以下两个业务js文件:

// app1.js

import $ from 'jquery';
import _ from 'lodash';
import * as utils from './utils';

console.log('app1', _.chunk(['a', 'b', 'c', 'd'], 1)); // ['a'] ['b'] ['c'] ['d']

$(function() {
$('.app1').css('color', 'red');
});

console.log('app1', utils.dataType([])); // Array
// app2.js

import $ from 'jquery';
import _ from 'lodash';
import * as utils from './utils';

console.log('app2', _.chunk(['a', 'b', 'c', 'd'], 2)); // ['a', 'b'] ['c', 'd']

$(function() {
$('.app2').css('color', 'blue');
});

console.log('app2', utils.dataType({})); // Object

让我们调整下配置文件:

// webpack.config.js

module.exports = {
entry: {
app1: './src/app1.js',
app2: './src/app2.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].[chunkhash].js'
}
}

然后,打包后生成了 app1.min.jsapp2.min.js 两个文件,页面也正常引用了这两个js文件,功能也都正常。

但是,当打开 app1.min.jsapp2.min.js,你会发现,这两个文件都包含了 jQuery、lodash 以及 utils.js 的源码,也就是说,webpack对于不同业务模块引用的公用文件(工具库)会重复打包,这显然是不对的。我们希望的理想情况是,所有的公用文件,只打包一次,在页面里也只引入一次。

那我们就要使用到 Entry + CommonsChunkPlugin

5.2 公用资源的分离

再次调整配置文件,在入口文件中新增第三方库的配置:

// webpack.config.js

var packagejson = require('./package.json');

module.exports = {
entry: {
vendor: Object.keys(packagejson.dependencies), // 或者 vendor: ['jquery', 'lodash']
app1: path.resolve(__dirname, './src/app1.js'),
app2: path.resolve(__dirname, './src/app2.js')
},

...

plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor'],
filename: '[name].js'
}),
]
}

再次打包,生成了 vendor.jsapp1.min.jsapp2.min.js 这三个文件。此时,再打开 app1.min.jsapp2.min.js,会发现里面只包含我们自己写的业务代码,而 jQuery、lodash 以及 utils.js 的源码则被打进了 vendor.js 这个文件中。

使用这种方式打包,除了可以分离公用资源、避免重复打包以外。对第三方库(这里指的是 jQuery、lodash),在业务代码中还可以无需引入模块,便能使用第三方库的API。比如,app1.js 可以调整为:

// app1.js  无需在此文件中引入 jquery 和 lodash

import * as utils from './utils';

console.log('app2', _.chunk(['a', 'b', 'c', 'd'], 2)); // ['a', 'b'] ['c', 'd']

$(function() {
$('.app2').css('color', 'blue');
});

console.log('app2', utils.dataType({})); // Object

5.3 第三方库、工具函数模块,业务代码的分离

到这里为止,我们基本达到了公用资源与业务代码分离的需求。但是,由于公用资源又包含 第三方库 和 工具函数模块。可以肯定的是,第三方库我们几乎不改,但 工具函数模块 则可能会不定时的修改。因此,最理想的情况是,把 第三方模块 和 工具函数模块 也分离开来。

这个也好办,只需要利用 chunks 再指定引用了 工具函数模块 的js文件即可。再次调整配置文件:

// webpack.config.js

module.exports = {
...

plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'common',
filename: '[name].[chunkhash].js',
chunks: ['app1', 'app2']
}),
new webpack.optimize.CommonsChunkPlugin({
name: ['vendor'],
filename: '[name].js'
})
...
]
}

再次打包,则生成了 vendor.jscommon.jsapp1.min.jsapp2.min.js 这四个文件。它们的内容分别如下:

  • vendor.js: 第三方库代码(这里指 jQuery + lodash)
  • common.js: 工具函数模块
  • app1.min.js: 业务代码1
  • app2.min.js: 业务代码2

这样,便达到我们最终的目的。打开这四个文件,你会发现,后面三个都是以 webpackJsonp([0], ...)webpackJsonp([1], ...) … 这样开头的webpack运行模块。

并且,由于我们是将第三方库单独打包在一个文件里,因此,你可以在该文件后面或者浏览器控制台使用第三方库的API。

不过注意,在 webpack4.x 中,CommonsChunkPlugin 这种形式已经被废弃了。

当你尝试在 webpack4.x 中使用 CommonsChunkPlugin,在命令面板会出现报错,并提示我们用 optimization.splitChunks

CommonsChunkPlugin错误

六、webpack4.x 模块分离 - optimization + splitChunks

在 webpack4.x 中,我们使用 optimization.splitChunks 来分离公用的代码块。

这里说的分离,当然只是针对一些第三方库(一般来自 node_modules),以及我们自己定义的工具库(或公用方法)。不然,还分离啥呢?

不知如何下手?首先,我们来看官网给的一份默认配置:

optimization: {
splitChunks: {
chunks: 'async',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}

接着,我们再来看下它们的含义:

  • chunks: 该属性值的数据类型可以是 字符串 或者 函数。如果是字符串,那它的值可能为 initial | async | all 三者之一。默认值的数据类型为 字符串,默认值为 async,但推荐用 all。它表示将哪种类型的模块分离成新文件。字符串参数值的作用分别如下:
    • initial:表示对异步引入的模块不处理
    • async:表示只处理异步模块
    • all:无论同步还是异步,都会处理
  • minSize: 该属性值的数据类型为数字。它表示将引用模块分离成新代码文件的最小体积,默认为 30000,单位为字节,即 30K(指min+gzip之前的体积)。这里的 30K 应该是最佳实践,因为如果引用模块小于 30K 就分离成一个新代码文件,那页面打开时,势必会多增加一个请求。
  • maxSize: 该属性值的数据类型为数字。它表示?
  • minChunks: 该属性值的数据类型为数字。它表示将引用模块如不同文件引用了多少次,才能分离生成新代码文件。默认值为 1
  • maxAsyncRequests: 该属性值的数据类型为数字,默认值为 5。它表示按需加载最大的并行请求数,针对异步。
  • maxInitialRequests: 该属性值的数据类型为数字,默认值为 3。它表示单个入口文件最大的并行请求数,针对同步。
  • automaticNameDelimiter: 该属性值的数据类型为字符串,默认值为 ~。它表示分离后生成新代码文件名称的链接符,比如说 app1.js 和 app2.js 都引用了 utils.js 这个工具库,那么,最后打包后分离生成的公用文件名可能是 xx~app1~app2.js 这样的,即以 ~ 符号连接。
  • name: 该属性值的数据类型可以是 布尔值 或者 函数(返回值为字符串),其中布尔值得为 true,此时,分离文件后生成的文件名将基于 cacheGroupsautomaticNameDelimiter。如果设置为 false,则不会进行模块分离。
  • cacheGroups: 该属性值的数据类型为对象,它的值可以继承 splitChunks.* 中的内容。如果 cacheGroups存在与 splitChunks.* 同名的属性,则 cacheGroups 的属性值则直接覆盖 splitChunks.* 中设置的值。
  • test: 该属性值的数据类型可以为 字符串 或 正则表达式,它规定了哪些文件目录的模块可以被分离生成新文件。
  • priority: 该属性值的数据类型可以为数字,默认值为 0。它表示打包分离文件的优先级。
  • reuseExistingChunk: 该属性值的数据类型可以为布尔值。它表示针对已经分离的模块,不再重新分离。

那么问题来了,我们该如何理解这些属性配置?并且利用它们实现更加高效的打包?

其实没有什么,要验证这些配置参数,只不过是反复打包验证的过程。

6.1 默认配置

在开始验证之前,我们准备三个入口文件(app1、app2、app3)以及它们各自引用的模块(jQuery、Lodash、React均来自 node_modules,utils 则是本地定义的工具函数文件),其中分离打包采用默认的配置:

// webpack.config.js

module.exports = {
entry: {
app1: './src/js/app1.js', // 引用了 jQuery、Lodash、utils
app2: './src/js/app2.js', // 引用了 jQuery、Lodash、utils
app3: './src/js/app3.js' // 引用了 React、React-dom
},

...

optimization: {
splitChunks: {
chunks: 'async',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
}

打包后,得到:

默认配置的分离打包

可以看到,由于采用默认分离打包配置,即 chunks: 'async'。而我们代码中没用使用到异步加载模块,此时打包,并没有分离出单独的模块文件。所以输出的文件是下面这三个。它们的内容分别为:

  • app1.min.js:jQuery、Lodash、utils 的源码和 app1.js 的业务代码
  • app2.min.js:jQuery、Lodash、utils 的源码和 app2.js 的业务代码
  • app3.min.js:React、React-dom 的源码和 app3.js 的业务代码

6.2 分离第三方库

要将第三方库分离出来,我们需要调整配置文件,设置 chunks: 'all',即表示让所有加载类型的模块在某些条件下都能打包:

// webpack.config.js

module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
}

打包后,得到:

chunks-all

由于 jQuery、Lodash、React、React-dom 未压缩之前的源码都超过了30K,所以它们被分离成独立的文件。由于它们都来自 node_modules,所以这些分离文件的名称前缀是我们的配置 vendors, 接着使用 automaticNameDelimiter 的值(符号 ‘~’)来连接共同引入它们的业务文件名,如 vendors~app1~app2.min.jsvendors~app3.min.js

它们的内容分别为:

  • vendors~app1~app2.min.js:jQuery、Lodash的源码
  • app1.min.js:utils 的源码和 app1.js 的业务代码
  • app2.min.js:utils 的源码和 app2.js 的业务代码
  • vendors~app3.min.js:React、React-dom 的源码
  • app3.min.js:app3.js 的业务代码

6.3 分离工具函数

在上面的打包中,我们发现,工具函数模块(utils)的源码被分别打包到了 app1.min.js 和 app2.min.js 这两个文件中,这显然是不对。之所以出现这种情况,是因为我们设置了 minSize: 30000,即分离成独立文件的最小体积为 30K,而这里的 工具函数(utils.js)只有几KB,所以,没被分离成单独的文件。

我们在 cacheGroups 中重新设置 minSize 的值,这样,就能覆盖 splitChunks.* 里面 minSize 的默认值:

// webpack.config.js

module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minSize: 0,
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
}

打包后,得到:

最小分离体积

这样就分离出了 工具函数模块。它们的内容分别为:

  • vendors~app1~app2.min.js:jQuery、Lodash的源码
  • default~app1~app2.min.js:utils 的源码
  • app1.min.js:app1.js 的业务代码
  • app2.min.js:app2.js 的业务代码
  • vendors~app3.min.js:React、React-dom 的源码
  • app3.min.js:app3.js 的业务代码

6.4 第三方库合并打包并重命名

有的时候,我们希望将所有来自 node_modules 的第三方库都打包到同一个文件中。显然,上面的打包配置并没有满足这个条件。并且,我们还希望可以对打包后的文件名进行重命名。

要完成,只需要在 cacheGroups 设置 name 属性即可。

// webpack.config.js

module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
name: 'lib'
},
default: {
minSize: 0,
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
name: 'utils'
}
}
}
}
}

打包后,得到:

分离打包文件合并并重命名

这样不仅合并了所有来自 node_modules 的第三方库,还自定义了打包后的文件名称。它们的内容分别为:

  • lib.min.js:jQuery、Lodash、React、React-dom 的源码 (由于包含的库都比较大,所有直接黄色了!!)
  • utils.min.js:utils 的源码
  • app1.min.js:app1.js 的业务代码
  • app2.min.js:app2.js 的业务代码
  • app3.min.js:app3.js 的业务代码

如果你嫌打包后的业务文件大,还可以结合 optimization.runtimeChunk。它可以提取 entry chunk 中的 runtime函数部分,生成一个单独的文件:

// webpack.config.js

module.exports = {
optimization: {
splitChunks: {
...
},
runtimeChunk: {
name: 'manifest'
}
}
}
runtimechunk

可以看到,打包后的业务文件(app1、app2、app3)体积都变小了。

另外, cacheGroups 中还有一个关于优先级 priority 的属性,由于一个模块可以被分配到多个缓存组中,优化策略会将模块分配至跟高优先级别的缓存组,这对于将这个模块分离到更大体积的js文件,减少请求数特别有用。

最后,需要说明的是,testpriortyreuseExistingChunk 只能用于配置缓存组。