webpack3极致分包

Published on
发布于·预估阅读22分钟
Authors
  • Name
    willson-wang
    Twitter

项目中目前使用的是webpack3.8.1版本,然后准备将webpack升级到4.x版本,在升级的过程中,碰到了分包的变化,所以先详细的记录下webpack3.x,然后在另外记录webpack4.x版本中关于分包的对比;

在理解分包之前,一定要先理解webpack中几个重要的概念;

怎样区分chunk、bundle及module

  • module: 每个文件就是一个模块
  • bundle: webpack最终输出的文件
  • chunk: webpack内部处理过程中的代码块,一个chunk可以最终生成一个bundle,多个chunk也可以最终生成一个bundle

chunk分为三类

  • Entry chunk - 入口chunk,怎样区分入口chunk内,即包含webpack runtime代码的,如下所示
entry: {
   a: './src/a.js',
   b: './src/b.js'
}

a,b都是entry chunk
  • Initial chunk:不包含运行时代码的chunk则认为是initial chunk。initial chunk都是紧随在entry chunk加载之后加载,如webpack3中通过CommonsChunkPlugin抽离的chunk
new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity // 模块必须被3个 入口chunk 共享,Infinity除了vendor的chunk其它的chunk不会被打进来
}),
  • Normal chunk:异步加载or懒加载的模块(如通过require.ensure, or System.import or import()加载的模块),就是普通chunk;
require.ensure(['./c'], function(c) {
        console.log('c', c)
}, function () {}, 'c')

注意Normal chunk与Initial chunk唯一的区别方式就是加载方式,所有通过懒加载or异步加载的都认为是Normal chunk,其它非entry chunk的都是Initial chunk

怎样区分hash、chunkhash、contenthash

  • hash: 在 webpack 一次构建中会产生一个 compilation 对象,该 hash 值是对 compilation 内所有的内容计算而来的

  • chunkhash: 每一个 chunk 都根据自身的内容计算而来

  • contenthash: 根据每个文件自身内容计算而来

所以从描述来看,chunkhash应该作为我们持久化缓存用于生成文件名的参数

怎么看待分包这个问题

我们为什么要进行分包

分包的目的是做持久化缓存

怎样利用webpack做持久化缓存

通过给输出的js文件添加chunkhash,给css文件名添加contenthash

通过分包,把不容易变的抽离到一个chunk内,把公共代码抽到一个chunk内,最后在把webpack的runtime代码抽到一个chunk内,在通过HashedModuleIdsPlugin、NamedModulesPlugin、NamedChunksPlugin等插件来最大化保证chunkhash变化的文件数量

下面我们分包按照上面的思路,在webpack3中通过多入口实例来实现上述的分包效果

webpack3.x版本,我们需要借助CommonsChunkPlugin插件来实现,在进行具体的分包之前,先来详细了解CommonsChunkPlugin插件每个参数的含义,只有理解了每个参数的具体含义,才能够结合自己的项目进行具体的分包,要不然又是从网上copy一段配置项;

我们先看下有哪些参数

{
  name: string, // or
  names: string[]

  filename: string,

  minChunks: number|Infinity|function(module, count) => boolean,

  chunks: string[],

  children: boolean,

  deepChildren: boolean,

  async: boolean|string,

  minSize: number,
}

一共9个参数,除了filename及minSize我们来重点理解下其它7个参数具体是什么意思

以一个具体的例子为例,共8个js文件

a.js  作为入口文件

import './assets/a.css'
import { a } from './common'
import _ from 'lodash'
import react from 'react'
import reactDom from 'react-dom'

document.getElementById('btn1').addEventListener('click', function () {
    require.ensure([], function(require) {
        const c = require('./c')
        console.log('c', c)
    }, function () {}, 'c')

    
})

console.warn('a.js', a, _, react, reactDom)
b.js 作为入口文件
import './assets/b.css'
import { b } from './common'
import _ from 'lodash'
import react from 'react'
import reactDom from 'react-dom'

document.getElementById('btn2').addEventListener('click', function () {
    require.ensure([], function(require) {
        const d = require('./d')
        console.log('d', d)
    }, function () {}, 'd')
})

console.warn('b.js', b, _, react, reactDom)
c.js 在a.js中通过异步引入
import _ from 'lodash'

require.ensure([], function(require) {
    const f = require('./f')
    console.log('f', f)
}, function () {}, 'f')

require.ensure([], function(require) {
    const g = require('./g')
    console.log('g', g)
}, function () {}, 'g')

console.log('c qs', common2, _)
d.js 在b.js中通过异步引入
import qs from 'qs'
import { common2 } from './common2'

console.log('d qs', qs, common2)
f.js 在c.js中异步引入
import _ from 'lodash'
import { common2 } from './common2'
import qs from 'qs'

console.log('f', _, common2, qs)
g.js 在c.js中异步引入
import _ from 'lodash'
import { common2 } from './common2'
import qs from 'qs'

console.log('g', _, common2, qs)
common.js 在a.js及b.js引入
import './assets/common.css'

export const a = 'a00000'
export const b = 'b00000'
common2.js 在f.js、g.js中引入
export const common2 = 'common2'

我们先看name or names参数有什么作用

先看一张没有分包之前构建结果 image

new webpack.optimize.CommonsChunkPlugin({
      name: 'pageA'
})

打包结果

image

pageB.js包的大小明显减少,为什么?我们在继续看下

new webpack.optimize.CommonsChunkPlugin({
            name: 'pageA',
            chunks: ['pageA', 'pageB'],
            minChunks: 2
})

打包结果

image

二者打包结果一致

我们在看,把name改成names: ['pageA']

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            chunks: ['pageA', 'pageB'],
            minChunks: 2
})

打包结果 image

同前两次分包结果还是一致

所以我们可以推断出

  1. name or names用于指定将符合条件的模块提取指定name的chunk中,如果name为不存在的chunk则创建新chunk,如果name指定的chunk存在,则继续往chunk中添加被提取的模块内容

  2. chunks用于指定,从哪些chunk中提取符合条件的模块

  3. minChunks用于判断chunk中的模块是不是符合提取要求,如默认值为2,意思就是一个模块必须要在两个chunk都引用过,在这个例子中就是必须在a.js、b.js中引用过

我们在把name换个不存在的chunk名来验证我们的推断

new webpack.optimize.CommonsChunkPlugin({
            names: ['test'],
            chunks: ['pageA', 'pageB'],
            minChunks: 2
})

打包结果

image

生成了一个新的test chunk所以验证了我们的name or names及chunks的作用

我们把minChunks换成函数,及在webpack CommonsChunkPlugin插件中输入targeChunk及affectedChunks

new webpack.optimize.CommonsChunkPlugin({
            names: ['test'],
            chunks: ['pageA', 'pageB'],
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

输出结果

image

从上图可知,我们之前对minChunks的推断也是正确的,目标targeChunk为test,需要被提取的chunks为pageA、pageB,在两个chunk中都被引用的则count为2,只有一次的则count为1

从上面的步骤来看,我们发现一个问题c.js、d.js、f.js、g.js这些异步加载的chunk都没有公共模块被提取出来,也没有被认为是需要被提取的chunks,如果我们要提取c、d、f、g中的公共模块呢?

这时我们就需要用到children及deepChildren这两个参数

我们先将children设置为true,deepChildren设置为false

new webpack.optimize.CommonsChunkPlugin({
            names: ['test'],
            chunks: ['pageA', 'pageB'],
            children: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

打包结果,报错

image

我们去掉chunks

new webpack.optimize.CommonsChunkPlugin({
            names: ['test'],
            children: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

打包结果

image

test chunk没有生成、且没有被选中的chunks,所以自然不会生成test chunk

我们修改names将test改成pageA

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            children: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

打包结果

image

目录targetChunk pageA,通过异步引入的c.js则是受影响的chunk,但是c.js内没有符合minChunks判断条件的模块,所以c.js中没有模块被提取到父chunk pageA中

从这里我们可以作出如下总结

  1. 如果要提取子chunk,不能设置chunks参数
  2. 如果要提取子chunk,name or names指定的chunk必须要是已存在的chunk,不能是不存在的chunk
  3. children: true只提取了一级异步加载的子chunk

我们在看下另外一个参数deepChildren,将deepChildren设置为true,children设置为false

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            children: false,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

打包结果

image

没有按预期处理,提取子chunk中的公共模块,跟children: false,deepChildren: false,是一样的打包效果

我们在把children,及deepChildren同时设置为true

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            }
})

打包结果

image

明显c、f、g.js都被选中,且符合条件的模块有qs及common2.js

所以我们可以得出结论

  1. deepChildren单独设置无效,必须要与children一起设置为true才有效
  2. deepChildren用于提取二级、三级等等,所有的子chunk

然后我们在来看一个问题,子chunk中关于模块count的计算方式,我这里直接给结论,具体的过程,有兴趣的可以自己去推导

先看一下父子chunk的关系,已本例为例

本例中的chunk关系为entry chunk(a.js) => 一级子chunk(c.js) => 二级子chunk(f.js、g.js)

  1. 父chunk内已经引用过了的模块,子chunk内是不会被计算在内的,比如这个例子中因为a.js中引入过lodash,所以所有子chunk中lodash没有被计算进去;而父chunk没有引入过的模块,子chunk有引入才会被计算进去,如qs,a.js及c.js都没有引入,而f.js、g.js有引入,所以qs模块的count是2

  2. 同级子chunk,才会被重复计算次数;如qs,因为被二级chunk f、g同时引入count就是2;如果在c.js内引入一次qs,那么结果则只会是一次了

调整c.js,引入qs依赖

import _ from 'lodash'
import qs from 'qs'

require.ensure([], function(require) {
    const f = require('./f')
    console.log('f', f)
}, function () {}, 'f')

require.ensure([], function(require) {
    const g = require('./g')
    console.log('g', g)
}, function () {}, 'g')

console.log('c qs', common2, _)

require.ensure([], function(require) {
    const f = require('./f')
    console.log('f', f)
}, function () {}, 'f')

require.ensure([], function(require) {
    const g = require('./g')
    console.log('g', g)
}, function () {}, 'g')

console.log('c qs', common2, _)

打包结果

image

结果符合我们的预期

现在我们已经知道了,怎么从指定的chunks中抽取公共的module,提取到指定的chunk,已经怎样把子chunk公共模块抽取到父chunk内;这些chunk都是同步chunk,而我现在想抽取异步chunk,该怎么办?

我们继续看async参数

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            },
            async: 'async-vendor'
})

打包结果

image

生成了一个新的async-vendor chunk,这个chunk的内容是从pageA chunk下的所有子chunk中抽出来的公共模块

将async参数的值该为true

new webpack.optimize.CommonsChunkPlugin({
            names: ['pageA'],
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            },
            async: true
})

打包结果

image

异步公共chunk文件名变了,所以我们可以知道async参数为字符串时,会作为异步chunk的文件名前缀

我们在去掉names参数

new webpack.optimize.CommonsChunkPlugin({
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('common count', number, module.resource, count)
                return count >= 2
            },
            async: 'async-vendor'
})

打包结果

image

当没有指定name or names时,针对的是所有的chunks中的异步chunk,所有我们可以得出结论

  1. 指定async参数时,被提取的chunks为name or names指定的chunk or all 异步子chunk;
  2. async参数为字符串时,该字符串会会作为chunk的name

通过了解了CommonsChunkPlugin插件每个参数的作用之后,我们正式来在项目中使用CommonsChunkPlugin来优化我们的项目

我们参照基于 webpack 的持久化缓存方案来进行分包处理

两个思路

思路1: 针对异步chunk,提取出async-vendor及async-common两个异步公共chunk,然后针对入口chunk,提取非dll中的node_modules下的chunk,然后再是common chunk,最后runtime chunk

思路2: 把异步chunk中node_module目录下的模块提取到entry chunk中,只提取一个异步async-common chunk,然后针对入口chunk,提取非dll中的node_modules下的chunk,然后再是common chunk,最后runtime chunk

思路1

简单展示下配置项

entry: {
        app: [path.join(__dirname, 'src/app.js')],
        app_agency: [path.join(__dirname, 'src/app_agency.js')]
},

未分包之前,我们通过webpack-bundle-analyzer插件来查看各包的大小及依赖关系

image

我们可以看出

  1. 所有包的大小为86.21M(未压缩)
  2. entry chunk都包含相同的依赖
  3. 异步加载的chunk都包含node_module文件,且很多都是重复的模块

第一步

new webpack.optimize.CommonsChunkPlugin({
            async: 'asycn-vendor',
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                // console.log('async-vendor count', module.resource, count)
                return module.resource && (/node_modules/).test(module.resource) && count >= 2
            }
 }),

分析图

image

从图中我们可以看出

  1. 所有包的大小为33.24M(未压缩),总文件大小减少了61%
  2. 异步chunk中满足条件的模块都被提取到了async-vendor chunk中
  3. 各个异步chunk中node_modules目录下的模块基本都被提取到了async-vendor chunk中

第二步,把异步chunk中公共的业务代码,提取到async-common chunk中

new webpack.optimize.CommonsChunkPlugin({
            async: 'asycn-common',
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                return count >= 2
            }
}),

分析图

image

从图中我们可以看出

  1. 所有包的大小为27.3M(未压缩),总文件大小减少了68%
  2. 提取了两个async-common chunk
  3. 各个异步chunk文件大小又得到减少
  4. 两个entry chunk中的module_modules下有很多重复的依赖

第三步,针的enrty chunk 提取rest-vendor,因为实际项目中结合了dll

new webpack.optimize.CommonsChunkPlugin({
            name: 'rest-vendor',
            minChunks: function (module, count) {
                const flag = module.resource && (/node_modules/).test(module.resource) && !vendors.some(vendor => module.resource.includes(`p_qmyx/node_modules/${vendor}/`))
                return flag
            },
}),

分析图

image

从图中我们可以看出

  1. 所有包的大小为18.94M(未压缩),总文件大小减少了77%
  2. 提取了rest-vendor这个chunk,包含了entry chunk及async-vendor chunk中的node_modules模块

第四步,针的enrty chunk 提取common chunk

new webpack.optimize.CommonsChunkPlugin({
            name: 'common',
            chunks: ['app', 'app_agency'],
            minChunks: 2
}),

分析图

image

  1. 所有包的大小为18.8M(未压缩),总文件大小减少了78%
  2. 提取了common这个chunk,包含了entry入口中的引入超过2次的模块
  3. 因为满足条件的模块不多,所以,两个entry chunk文件大小减少不多

第五步,针对entry chunk提取runtime chunk

new webpack.optimize.CommonsChunkPlugin({
            name: 'runtime'
}),

分析图

image

  1. 所有包的大小为18.8M(未压缩),总文件大小减少了78%
  2. 提取了runtime这个chunk,包含了webpack的runtime code

思路2

第一步,把异步chunk中node_module下,且count>=2的模块提取到entry chunk中

new webpack.optimize.CommonsChunkPlugin({
            names: ['app', 'app_agency'],
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                number++
                console.log('async-vendor count', number, module.resource, count)
                return module.resource && (/node_modules/).test(module.resource) && count >= 2
            }
}),

分析图

image

从图中我们可以看出

  1. 所有包的大小为33.24M(未压缩),总文件大小减少了61%
  2. 异步chunk中满足条件的模块都被提取到了entry chunk中
  3. 各个异步chunk中node_modules目录下的模块基本都被提取到了entry chunk中

第二步,把异步chunk中公共的业务代码,提取到async-common chunk中

new webpack.optimize.CommonsChunkPlugin({
            async: 'asycn-common',
            children: true,
            deepChildren: true,
            minChunks: function (module, count) {
                return count >= 2
            }
}),

分析图

image

从图中我们可以看出

  1. 所有包的大小为27.3M(未压缩),总文件大小减少了68%
  2. 提取了两个async-common chunk
  3. 各个异步chunk文件大小又得到减少
  4. 两个entry chunk中的module_modules下有很多重复的依赖

第三步,针的enrty chunk 提取rest-vendor,因为实际项目中结合了dll

new webpack.optimize.CommonsChunkPlugin({
            name: 'rest-vendor',
            minChunks: function (module, count) {
                const flag = module.resource && (/node_modules/).test(module.resource) && !vendors.some(vendor => module.resource.includes(`p_qmyx/node_modules/${vendor}/`))
                return flag
            },
}),

分析图

image

从图中我们可以看出

  1. 所有包的大小为18.41M(未压缩),总文件大小减少了78%
  2. 提取了rest-vendor这个chunk,包含了entry入口中的node_modules模块,当然也可以限制count
  3. 两个entry chunk文件大小减少很多

第四步,针的enrty chunk 提取common chunk

new webpack.optimize.CommonsChunkPlugin({
            name: 'common',
            chunks: ['app', 'app_agency'],
            minChunks: 2
}),

分析图

image

  1. 所有包的大小为18.27M(未压缩),总文件大小减少了78%
  2. 提取了common这个chunk,包含了entry入口中的引入超过2次的模块
  3. 因为满足条件的模块不多,所以,两个entry chunk文件大小减少不多

第五步,针对entry chunk提取runtime chunk

new webpack.optimize.CommonsChunkPlugin({
            name: 'runtime'
}),

分析图

image

  1. 所有包的大小为18.27M(未压缩),总文件大小减少了78%
  2. 提取了runtime这个chunk,包含了webpack的runtime code

然后我们通过chrome的performance来对比下两种思路下,首页js加载的时常,已三次为例

思路1

image

image

image

思路2

image

image

image

从图片上可以看出两个思路其实没有特别大的差异,所以还是需要根据自己的项目来选择合适的分包策略;

总结

通过上述的步骤,我们清楚的知道CommonsChunkPlugin插件每个参数的作用,也学习了一个分包的参考思路,至于具体的项目中,我们只需要根据缓存策略,然后在根据自己具体的项目去决定是否需要分包,分几次包,最终实现最有利的分包。