一文读懂webpack代码分片

代码分片是Webpack作为打包工具特有的一项技术,通过这项技术我们可以把代码按照特定的形式进行拆分,使用户不必一次全部加载,而是按需加载

1. 通过入口划分代码

在Webpack中每个入口都将生成一个对应的资源文件,通过入口的配置我们可以进行一些简单有效的代码拆分。

对于Web应用来说,通常一些库和工具是不常变动的,可以把他们放在一个单独的入口中,由该入口产生的资源不会经常更新,因此可以有效地利用客户端缓存,让用户不必要每次请求页面时都重新加载

javascript 复制代码
// webpack.config.js
entry: {
    app: './app.js',
    lib: ['lib-a','lib-b']
}

//index.html
<script src='dist/lib.js'></script>
<script src='dist/app.js'></script>

这种拆分方式主要适合那些将接口绑定在全局对象上的库,因为业务代码中的模块无法直接引用库中的模块,二者是属于不同的依赖树。

对于多页面应用来说,我们也可以通过入口划分的方式来拆分代码。比如,为每一个页面创建一个入口,并只放入涉及该页面的代码,同时在创建一个入口来包含所有公共模块,并是每个页面都进行加载。但是这样仍会带来公共模块与业务模块处于不同依赖树的问题。另外,不是所有页面都需要这些公共模块。比如,A、B页面需要lib-a模块,C、D需要lib-b模块,通过手工的方式去配置和提取公共模块将变得十分复杂,我们可以用webpack提供的插件来解决这个问题

2. CommonsChunkPlugin

CommonsChunkPlugin是webpack4之前内部自带的插件,可以将多个chunk中公共的部分提取出来。公共模块的提取有以下好处

  • 开发过程减少了重复模块打包,可以提升开发速度
  • 减小整体资源体积
  • 合理分片后的代码可以更有效的利用客户端缓存

让我们通过一个例子来认识CommonsChunkPlugin。假设我们当前项目中有foo.js和bar.js两个入口文件,并且都引入了react。下面是未使用CommonsChunkPlugin的配置

这是打包的结果

更改webpack.config.js,添加CommonsChunkPlugin

css 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js'
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons',
            filename: 'commons.js'
        })
    ]
}

在配置文件头部首先引入了webpack,及融资使用内部的CommonsChunkPlugin函数创建了一个插件实例,并传入配置对象

  • name:用于指定公共chunk的名字,即打包出来的文件的chunk名字
  • filename:提取后的资源文件名,即bundle的名字

可以看到产出的资源中多了commons.js,而foo.js和bar.js的体积从之前的72kb降到不到1kb,这就是将react及其依赖的模块都提到commons.js的结果。最后,记得在页面中添加一个script标签来引入commons.js,并且注意,这个JS一定要在其他JS之前引入

2.1 提取vendor

CommonsChunkPlugin主要用于提取多入口之间的公共模块,但是也可以用于单入口,只需要为他们创建一个入口即可

css 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        foo: './foo.js',
        vendor: ['react'] //没有设置路径回去node_modules模块下面去找
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js'
        })
    ]
}

为了把react从app.js中提取出来,我们在配置中加入一个入口vendor,并使其值包含react,这样就把react变成了app和vendor这两个chunk所共有的模块,CommonsChunkPlugin就会将这两个chunk共有的模块提取出来。在插件内部,我们将name指定为vendor,这样由CommonsChunkPlugin所产生的资源将覆盖原有的由vendor这个入口所产生的资源。

2.2 设置提取范围

通过CommonsChunkPlugin中的chunks配置项可以规定从哪些入口中提取公共模块

css 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        a: './a.js',
        b: './b.js',
        c: './c.js'
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons',
            filename: 'vendor.js',
            chunks: ['a','b']
        })
    ]
}

我们在chunks中配置a和b,这意味着只会从a.js和b.js中提取公共模块。对于大型应用来说,拥有几十个页面是正常的,这意味着会有几十个资源入口。这些入口所共享的模块会有差异,在这种情况下,我们可以配置多个CommonsChunkPlugin,并为每个插件规定提取范围来进行有效提取。

2.3 设置提取规则

CommonsChunkPlugin的默认规则是只要一个模块被两个入口chunk所使用就会被提取出来,比如a和b用了react,react就会被提取出来。

然而现实情况是,很多时候我们不希望所有的公共模块都被提取出来,比如项目中的一些组件或者工具模块,虽然被多次引用,但是可能经常修改,如果将其和react这种库放在一起反而不利于客户端缓存。我们可以通过CommonsChunkPlugin的minChunks配置项来设置提取的规则

  1. 数字

minChunks可以接收一个数字,当设置minChunks为n时,只有该模块被n个入口同时引用才会进行提取。另外,这个阈值不会影响通过数组形式入口传入的模块的提取,即如果该入口的模块是以数组形式传入的,那么就会照常提取,不会受这个阈值影响

css 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
        vendor: ['react']
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
            minChunks: 3
        })
    ]
}

我们令foo.js和bar.js共同引用一个util.js

按照我们在webpack中的配置上来说,我们只会提取到被引用了3次的模块,这里util.js只是引用了两次,因此不会被打包,但是react是数组形式引入的,不受minChunks的影响,此时react的提取规则就是CommonsChunkPlugin的默认提取规则,有了两个入口引用了react,那么react就会被提取

  1. Infinity

当设置minChunks的阈值为无限高时,说明所有的模块都不会被提取(除了以数组形式的入口的模块

这个配置项的意义有两个。第一个是和上面的情况类似,即我们希望webpack提取特定的几个模块,并将这些模块通过数组型入口传入,这样做的好处是提取哪些模块完全是可控的。

另一个好处是设置为Infinity后,可以生成一个没有任何模块而仅仅包含webpack初始化环境的文件,而是完完全全由webpack操作的,这个文件我们通常称为manifest

  1. 函数

minChunks可以传入一个函数,它可以让我们更细粒度地控制公共模块。Webpack打包过程中的每个模块都会经过这个函数的处理,当函数的返回值是true时进行提取。

javascript 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
        vendor: ['react']
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
            minChunks: function(module,count){
                //module.context 模块目录路径
                if(module.context && module.context.includes('node_modules')){
                    return true
                }
                //module.resource 包含模块名的完整路径
                if(module.resource && module.resource.endWith('util.js')){
                    return true
                }
                //count为模块被引用次数
                if(count > 5){
                    return true
                }
            }
        })
    ]
}

借助上面的配置,我们可以分别提取node_modules目录下的模块,名称为util.js的模块,以及被引用5次以上的模块

2.4 hash与长效缓存

使用CommonChunkPlugin时,由于我们将模块提取出来了,我们的目的就是让用户可以有效利用缓存,但是我们使用该插件提取出公共模块时,打包后的代码不仅仅包含模块里的代码,往往还包括Webpack的运行时。webpack的运行时是指初始化环境的代码,如创建模块缓存对象,声明模块加载函数,这些可以理解为webpack为了处理模块而做的环境,一旦有代码改动,这个环境就会改变,进而影响提取出来的公共代码。

在较早期的webpack版本中,运行时包含了模块的id,并且这个id是以数字的方式不断累加的(第一个模块id是0,第2个模块id是1)。这会导致一个问题,当我们修改模块的时候,模块id发生了变化,因此运行时也发生了变化,这就会让提取出来的公共模块也发生变化,即便提取出来的公共模块没有改变,他的版本号也会发生改变,导致无法使用本地缓存。

这个问题的解决方案是:将运行时的代码单独提出来

css 复制代码
const webpack = require('webpack')
module.exports = {
    entry: {
        app: './app.js',
        vendor: ['react']
    },
    output: {
        filename:'[name].js'
    },
    plugin: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'mainfest',
        })
    ]
}

上面的配置中,通过添加一个name为mainfest的CommonChunkPlugin来提取webpack的运行时。这里用了两个插件,第一个插件是提取出了公共的模块,第二个再次提取就是提取webpack的运行时了,提取webpack的运行时的插件配置必须写在最后,否则webpack将无法正常提取模块

在我们的页面中,mainfest.js应该最先被引入,用来初始化环境。通过这种方式,app.js中的变化只会影响到mainfest.js,而他是一个很小的文件,我们的vendor.js内容及其hash都不会发生改变,因此可以被用户所缓存

2.5 CommonsChunkPlugin的不足

在提取公共模块方面,CommonsChunkPlugin可以满足很多场景需求,但是它也有一些欠缺的地方。

  1. 一个CommonsChunkPlugin只能提取一个vendor(即只能打包出一个文件),假如我们想提取多个vendor,则需要配置多个插件,这样会增加很多重复的配置代码
  2. 前面我们提到的mainfest实际上会使浏览器多加载一个资源,这对于页面渲染是不友好的
  3. 由于内部设计上的缺陷,CommonsChunkPlugin在提取异步Chunk的时候不会按照我们预期正常工作

3. 资源异步加载

在介绍SplitChunks之前,我们先来了解一下异步chunk。资源异步加载要解决的主要问题是,当模块数量过多、资源体积过大时,可以把一些暂时用不到的模块延迟加载。这样使页面初次渲染的时候用户下载的资源尽可能小,后续模块等到恰当的时机再去触发加载。因此一般也把这种方法叫做按需加载,实现按需加载的方法就是import()函数

3.1 import()

与正常ES6的import语法不同,通过import()函数加载的模块及其依赖会被异步地进行加载,并返回一个promise对象。

假设我们的入口文件是foo.js,一开始就加载bar.js,但是bar.js的资源体积很大,并且我们在页面初次渲染的时候不需要使用它,就可以对他进行异步加载

这里我们还需要更改一下webpack的配置

css 复制代码
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        filename:'[name].js',
        publicPath:'/dist/'
    },
    mode:'development',
    devServer: {
        publicPath: '/dist/',
        port: 3000
    }
}

在之前资源输出配置的部分我们讲过,首屏加载的JS资源地址是通过模板页中的script标签来指定的,而间接资源(通过首屏JS再进一步加载的JS)的位置则要通过output.publicPath来指定。上面我们的import函数相当于是bar.js成为一个间接资源,我们需要配置publicPath来告诉webpack去哪里获取它。

此时我们使用Chrome的network面板应该可以看到一个0.js的请求,它就是bar.js及其依赖产生的资源。观察面板中的Initiator字段,可以发现他是foo.js所产生的请求

import()函数的原理很简单,就是通过JavaScript在页面的head标签里插入一个script标签/dist/0.js,打开Chrome的Elements面板就可以看到。由于该标签原本的HTML页面并没有,因此我们称他是动态插入的

import函数还有一个比较重要的性质。ES6 Module中要求import必须出现在代码的顶层作用域,而Webpack的import函数则可以在任何我们希望的时候调用

javascript 复制代码
if(condition){
    import('./a.js').then(a => {
        console.log(a)
    })
}else{
    import('./b.js').then(b => {
        console.log(b)
    })
}

这种异步加载方式可以赋予应用很强的动态特性,他经常被用来在用户切换到某些特定路由时去渲染相应组件,这样分离之后首屏加载资源就会小很多

3.2 异步chunk的配置

现在我们已经生成了异步资源,但是我们会发现产生的资源名称都是数字id,比如0.js,没有可读性,可以通过webpack为其添加有意义的名字

javascript 复制代码
//webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        filename:'[name].js',
        publicPath:'/dist/',
        chunkFilename:'[name].js'
    },
    mode:'development'
}

//foo.js
import(/* webpackChunkName: 'bar' */ './bar.js').then(({add}) => {
    console.log(add(2,3))
})

可以看到,我们在webpack的配置中添加了output.chunkFilename,用来指定异步chunk的文件名。其命名规则与output.filename基本一致,不过由于异步chunk默认没有名字,其默认值是[id].js,这也是我们在例子中看到0.js的原因。如果有更多的异步chunk,则会产生1.js,2.js。

在webpack中,我们通过特有的注释来让webpack获取到异步chunk的名字,并配置output.chunkFilename为[name].js,最终打包结果如下图

4. optimization.SplitChunks

optimization.SplitChunks是webpack4为了改进CommonsChunkplugin而重新设计和实现代码分片的特性。上面的代码用SpliteChunks的配置如下:

arduino 复制代码
//webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        filename:'[name].js',
        publicPath:'/dist/'
    },
    mode:'development',
    optimization:{
        splitChunks: {
            chunks: 'all'
        }
    }
}

//foo.js
import React from 'react'
import('./bar.js)
document.write('foo.js',React.version)

//bar.js
import React from 'react'
document.write('bar.js',React.version)

与CommonsChunkPlugin相比有以下两点不同

  • 使用optimization.splitChunks代替了CommonsChunkPlugin,并指定chunks的值为all,这个配置项的含义是,SplitChunks会对所有的chunks生效(默认情况下,SplitChunks只对异步chunks生效,并且不需要配置,也就是异步的代码都会进行分包处理,无论大小
  • mode是webpack4新增的配置项,可以针对当前是开发环境还是生产环境自动添加对应的一些webpack配置

打包结果如图

如果不使用SplitChunks的话,我们打包的结果应该是foo.js和0.foo.js(chunkname是0.js),使用了SplitChunks后,又生成了一个vendor~main.foo.js,并且把react提取到了里面

4.1 从命令式到声明式

CommonsChunkPlugin我们大多数时候是通过配置项将特定入口中的特定模块提取出来,也就是更贴近命令式的方式。SplitChunks只需要设置一些提取条件,当模块达到这些条件之后就会被自动提取出来,SplitChunks的使用更像声明式的。以下是SplitChunks的默认提取条件:

  • 提取后的chunk可以被共享(即可以通过import导入)或者来自node_modules目录。这一条很容易理解,被多次引用或处于node_modules中的模块更倾向于通用模块,比较适合被提取出来
  • 提取后的JavaScript chunk体积大于30kb(压缩和gzip之前),CSS chunk体积大于50kb。这个也比较容易理解,如果提取后的资源太小,那么带来的优化效果也一般
  • 在按需加载过程中,并行请求的资源最大值小于等于5。按需加载指的是,通过动态插入script标签的方式加载脚本(import函数)。我们一般不希望同时加载过多的资源,因为每一个请求都要花费建立链接和释放链接的成本,因此提取的规则只在并行请求不多的时候生效
  • 在首次加载(首屏加载)的时候,并行请求的资源数最大值小于等于3。和上一条类似,只不过在页面首次加载时往往对性能的要求更高,因此这里的默认阈值也更低

可以通过前面的例子来进一步解释这些条件。在从foo.js和bar.js提取react前,会对这些条件进行一一验证,只有满足了所有条件之后react才会被提取出来

  • react属于node_modules目录下的模块
  • react的体积大于30kb
  • 按需加载时的并行请求数量为1,为0.foo.js,即通过import函数请求的资源
  • 首次加载时的并行请求为2,为foo.js,vendor ~ main.foo.js。vendor~main.foo.js实际就是react的源代码,我们通过ES6 Module的import语法加载,而不是import函数加载,是因为它需要被添加到HTML的script标签中,在页面首次加载的时候就要用到

4.2 默认的异步提取

前面我们对SplitChunks添加了一个chunks:all的配置,这是为了提取foo.js和bar.js的公共模块 。实际上SplitChunks不配置也能生效,但这时候只会对异步资源还有符合上面提到的四个条件均满足的包进行分包,并且默认chunkname都是[id].js。请看下面例子:

javascript 复制代码
//webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        filename:'[name].js',
        publicPath:'/dist/'
    },
    mode:'development'
}

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

//bar.js
import lodash from 'loadsh'
console.log(lodash.flatten([1,[2,3]]))

打包结果如图所示

从结果来看,foo.js这个入口不仅仅产生了0.foo.js(原本的bar.js),还有一个1.foo.js,这里面就是lodash的内容,让我们看看lodash是否符合上面提到的4个条件

  • lodash属于node_modules目录下的模块,因此即便只有一个bar.js引用它也符合条件
  • lodash的体积大于30kb
  • 按需加载时的并行请求数量为2,为0.foo.js以及1.foo.js
  • 首次加载时的并行请求数量为1,为foo.js。这里没有计算1.foo.js的原因是他只是被异步资源所需要,并不影响入口资源的加载,也不需要添加额外的script标签

4.3 SplitChunks的配置

为了更好地理解SplitChunks是怎么样工作的,我们来看一下他的默认配置

yaml 复制代码
splitChunks= {
    chunks: 'async',
    minSize: {
        javascript: 30000,
        style: 50000
    },
    maxSize: 0,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendor: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        },
        default:{
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
        }
    }
}
  1. 匹配模式

    通过chunks我们可以配置SplitChunks的工作模式。他有三个可选值,分别是async(默认),initial和all。async只提取异步chunk,initial则只对入口chunk生效(如果配置了initial则上面的异步例子将失效,即只打包符合四个条件的包),all则是两种模式都开启

  2. 匹配条件

    maxSize、minChunks、maxAsyncRequests、maxInitialRequests都属于四个匹配条件,前文已经介绍过了

  3. 命名

    配置项name默认为true,他意味着SplitChunks可以根据cacheGroup和作用范围自动为新生成的chunk命名,并以automaticNameDelimiter分隔。如vendorsab~c.js意思是cacheGroup为=符合vendors规则,并且该chunk是由a、b、c三个入口chunk产生的

  4. cacheGroups

    可以理解为分离chunks的规则。默认情况下有两种规则--vendors和default。vendors用于提取所有node_modules中符合条件的模块,default则作用于多次引用的模块。我们可以对这些规则进行增加或修改,如果想要禁用某种规则,也可以将其置为false。当一个模块同时符合多个cacheGroups时,则根据其中的priority配置项确定优先级

可以看出,SplitChunks默认就会将我们将异步资源和符合四个条件的模块分包出来 完结撒花❀❀❀

相关推荐
上趣工作室4 分钟前
vue2在el-dialog打开的时候使该el-dialog中的某个输入框获得焦点方法总结
前端·javascript·vue.js
家里有只小肥猫4 分钟前
el-tree 父节点隐藏
前端·javascript·vue.js
fkalis5 分钟前
【海外SRC漏洞挖掘】谷歌语法发现XSS+Waf Bypass
前端·xss
陈随易1 小时前
农村程序员-关于小孩教育的思考
前端·后端·程序员
云深时现月1 小时前
jenkins使用cli发行uni-app到h5
前端·uni-app·jenkins
昨天今天明天好多天1 小时前
【Node.js]
前端·node.js
2401_857610032 小时前
深入探索React合成事件(SyntheticEvent):跨浏览器的事件处理利器
前端·javascript·react.js
雾散声声慢2 小时前
前端开发中怎么把链接转为二维码并展示?
前端
熊的猫2 小时前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
天农学子2 小时前
Easyui ComboBox 数据加载完成之后过滤数据
前端·javascript·easyui