webpack整合笔记分片 Code Splitting

1,111 阅读9分钟

实现高性能应用其中重要的一点就是尽可能地让用户每次只加载必要的资源,优先级不太高的资源则延迟加载,这样可保证首屏加载速度。

代码分片可有效降低首屏加载资源的大小,但同时也会带来新的问题,比如应该对哪些模块进行分片、分片后的资源如何管理等。

一 多页应用(MPA)

为了尽可能减小资源的体积,每个页面都只应加载各自必要的逻辑,而不是将所有页面打包到一个bundle中。因此每个页面都需要一个独立的bundle,这时使用多入口来实现。

module.exports = {
    entry: {
        pageA : './src/pageA.js',
        pageB : './src/pageB.js',
        pageC : './src/pageC.js',
        vendor: ['react', 'react-dom']
    }
}

之后在配置optimization.splitChunks,将它们从各自页面中提取出来,生成单独的bundle即可。

CommonsChunkPlugin(webpack4之前)

可将多个chunk中公共部分提取出来,可带来如下收益:

  • 开发工程中减少了重复模块打包,可提升开发速度
  • 减小整体资源体积
  • 合理分片后的代码可更有效地利用客户端缓存
plugins: [
    new Webpack.optimize.CommonsChunkPlugin({
        name: 'commons',        // 指定公共chunk名字
        filename: 'commons.js'  // 提取后的资源文件名
    })
]

最后,记得在页面中添加一个script标签来引入commons.js,并且注意该JS一定要在其他JS之前引入。

二 单入口应用

单入口应用可使用提取vendor的方法。vendor(供应商)在webpack中一般指工程使用的库、框架等第三方模块集中打包而产生。

用CommonsChunkPlugin(webpack4已废弃,可采用optimization.splitChunks,具体参考下面)将app与vendor这两个chunk中的公共模块提取出来。通过这样的配置,app.js产生的bundle将只包含业务模块,其依赖的第三方模块将会被抽取出来生成一个新的bundle。因此可有效地利用客户端缓存,加快整体渲染速度。

module.exports = {
    entry: {
        app:'./src/app.js',
        admin: './admin.js',
        header: './src/header',
        footer: './src/footer',
        vendor: ['react', 'react-dom', 'react-router']
    },
    output: {
        filename: '[name].js'
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
            chunks: ['app', 'admin']  // 只提取app和admin, 全部提取字符串'all'
        })
    ]
}

CommonsChunkPlugin(webpack4之前)

用CommonsChunkPlugin默认只要一个模块被两个入口chunk使用就会被提取出来,然而有些时候我们不希望所有的公共模块都被提取出来,比如项目中一些组件或工具模块,虽然被多次引用,但是可能经常修改,如果将其和react这种库放在一起反而不利于客户端缓存。 此时可通过CommonsChunkPlugin.minChunks配置项来设置提取规则。 该配置项支持多种输入形式

1. 数字

minChunks被设置为n时,只有该模块被n个入口同时引用才会提取。这个阀值不会影响通过数组形式入口传入模块的提取,见下例:

entry: {
    foo: './foo.js',
    bar: './bar.js',
    vendor: ['react']
},
output: {
    filename: '[name].js'
},
plugins: [
    new webpack.HashedModuleIdsPlugin(),  // 解决模块文件变更影响全部vendor问题
    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',             // 指定公共chunk名字
        filename: 'vendor.js',      // 提取后的资源文件名
        minChunks: 3
    })
]

foo.js和bar.js都引入了react和until.js,由于minChunks: 3, 打包后util.js不会被提取到vendor.js中,react并不受这个影响,仍然会出现在vendor.js中,这就是所说的“数组形式入口的模块会照常提取”

2. Infinity

阀值无限高,也就是所有模块都不会被提取。 两个意义:一个是和上面的情况类似,即只想让webpack提取特定的几个模块,并将成些模块通过数组型入口传入。另一个是为了生成一个没有任何模块而仅仅包含webpack初始化环境的文件,这个文件通常称为mainfest,后面长效缓存部分在讨论。

3. 函数

可让我们更细粒度地控制公共模块。当函数返回true时进行提取

minChunks: function(module, count){
    // module.context 模块目录路径
    if(module.context && module.context.includes('node_modules')){
        return true;
    }
    // module.resource 包含模块名的完整路径
    if(module.resource && module.resource.endsWith('util.js')){
        return true
    }
    //count 为模块被引用的次数
    if(count > 5){
        return true
    }
}

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

hash 与长效缓存

使用CommonsChunkPlugin时,一个绕不开的问题就是hash与长效缓存。提取后的资源内部不仅仅是模块的代码,往往还包含webpack运行时 runtime。 webpack的运行时指的是初始化环境的代码,如创建模块缓存对象、声明模块加载函数等。 一般我们会用chunk hash作为资源的版本号优化客户端的缓存,版本号改变会导致用户频繁更新资源,即使它们的内容没有发生变化。

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

plugins: [
    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor'
    }),
    new webpack.optimize.CommonsChunkPlugin({
        name: 'mainfest'  // 提取运行时,必须出现在最后,否则webpack无法正常工作
    })
]

在页面中,mainfest.js应最先被引入,用来初始化webpack环境。 index.html

<script src="dist/manifest.js"></script>
<script src="dist/vendor.js"></script>
<script src="dist/app.js"></script>

这时,app.js中的变化只会影响mainifest.js而它是一个很小的文件,而vendor.js及hash都不会变化,因此可被用户缓存。

三 optimization.splitChunks

01 CommonsChunkPlugin的不足

  • 一个 CommonsChunkPlugin 只能提取一个vendor,假如我们想提取多个vendor则需配置多个插件,这会增加很多重复的配置代码。
  • 前面提到的manifest实际上会使浏览器多加载一个资源,这对页面渲染速度是不友好的
  • 由于内部设计上的缺陷CommonsChunkPlugin在提取公共模块时会破坏原有Chunk的依赖关系,导致难以进行更多优化。

02 使用SplitChunks

前面异步加载例子:换成SplitChunks就可自动提取出react了。

mode: 'development',  // webpack4 新增
optimization: {
    splitChunks: {
        chunks: 'all'  // 默认为async
    }
}

此处与之前不同点:

  1. 使用splitChunks替代了CommonsChunkPlugin,并指定了chunks的值为all
  2. mode是 webpack4 新增的指定环境

03 从命令式到声明式

CommonsChunkPlugin是通过配置项将特定入口中的特定模块提取出来,更贴近命令式的方式。 SplitChunks不同之处在于只需要设置一些提取条件,如提取模式、提取模块体积等,SplictChunks更像声明式。

以下是SplitChunks默认情形下的提取条件:

  • 提取后的chunk可被共享或来自node_modules目录
  • 提取后的JS chunk体积大于30KB(压缩前), CSS chunk体积大于50KB(压缩前)
  • 在按需加载工程中,并行请求的资源最大值5
  • 首次加载,并行请求资源的最大值3

看下是SplitChunks默认配置

splitChunks: {
    chunks: 'saync',  // saync 只分割异步包 / initial 只分割同步包 / all 全部
    minSize: {
        javascript: 30000,
        style: 50000
    },
    maxSize: 50000,  // 50kb, lodash 1mb, 尝试将lodash拆分为20个chunk, 通常不配置
    minChunks: 1,    // 引入次数
    maxAsyncRequests: 5,   // 只拆分前5个包,后面不拆分(因为增加了浏览器请求数)
    maxInitialRequests: 3,          // 入口文件最多拆分3个包
    automaticNameDelimiter: '~',    // 名称连接符
    name: true,
    cacheGroups: {   // 缓存组
        
        vendors: {   // vendors.js 组规则     
            test: /[\\/]node_modules[\\/]/,  // node_modules内的包打包到vendors组内
            priority: -10, // 优先级,例jquery同时符合vendors和default,以此决定分到哪个组
            filename: 'vendors.js'           // 指定包名
        },
        default: {    // 不符合vendors规则时 common.js 规则
            priority: -20,
            reuseExistingChunk: true,
            filename: 'common.js'
        }
    },
}
  • 匹配模式: chunks 默认async(只提取异步),可选initial(只对入口chunk失效) / all
  • 匹配条件:minSize / minChunks / maxAsyncRequests / maxInitialRequests
  • 命名:name: true,意味着根据cacheGroups和作用范围自动为新生成的chunk命名,并以automaticNameDelimiter分隔。
  • cacheGroups 可开理解成分离chunks的规则。默认两种规则 vendors和default。 vendors用于提取所有node_modules中符合条件的模块,default用于被多次引用的模块。如果想禁用可设置为false

四 资源异步加载

意义: 对于大的web应用来讲,将所有的代码都放在一个文件中显然是够有效的,特别是当你的某些代码块是在某些特殊的时候才会被使用到。webpack有一个功能就是将你的代码库分割成chunks,当代码运行到需要它们的时候再进行加载。 适用的场景:

  • 抽离相同代码到一个共享块
  • 脚本懒加载,使得初始下载的代码更小
  • 当模块过多、资源过大时,可把一些暂时使用不到的模块延迟加载。

01 import()

已与正常ES6中的import语法不同,通过import函数加载的模块及其依赖会被异步加载,并返回一个Promise对象

如报错可安装下面插件

npm i -D @babel/plugin-syntax-dynamic-import .babelrc


{
    presets: [
        [
            "@babel/preset-env", {
                targets: {
                    chrome: "67",
                },
                useBuiltIns: 'usage'
            }
        ],
        "@babel/preset-react"
    ],
    plugins: ["@babel/plugin-syntax-dynamic-import"]
}

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

webpack.base.js 配置SplitChunksPlugin

entry: { foo: './foo.js'},
output: { 
    publicPath: '/dist/',   // 异步资源地址
    filename: '[name].js'
},
mode: 'development',
devServer: {
    publicPath: '/dist/',
    port: 3000
}

该技术实现原理很简单,即通过JS在页面的head标签插入一个script标签/dist/0.js import函数还有一个比较重要的特性。ES6 Module中要求import必须出现在代码的顶层作用域,而webpack的import函数则可在任何我们希望的时候调用。这种异步加载方式可赋予应用很强的动态特性,它经常被用来在用户切换到某些特定路由时去渲染相应组件,这样分离之后首屏加载的资源就会小很多。

index.js 为lodash打包后的chunk指定名称

function getComponent(){
    return import (/*webpackChunkName:"lodash"*/ 'lodash').then(({default: _ })=>{
        var element = document.createElement('div')
        element.innerHTML = _.join(['Dell', 'Lee'], '-')
        return element;
    })
}
getComponent().then(element => {
    document.body.appendChild(element)
})

可用异步函数懒加载

async function getComponent(){
    const {defalut: _ } = await import(/*webpackChunkName:"lodash"*/ 'lodash')
    //...
}

02 Shimming

公用库垫片,如$ > jquery, _ > lodash webpack.base.js

{
    plugins: [
        new wepback.ProvidePlugin({
            $: 'jquery',
            _: 'lodash'
        })
    ]
}

改变模块的this指向,强制指向window webpack.base.js

{
    rules:[
        {
            test: /\.js$/,
            exclude: /node_modules/,
            use: [
                { loader: 'babel-loader' },
                { loader: 'imports-loader?this=>window'}  //强制指向window
            ]
        }
    ]
}

03 提取页面公共资源CDN引入

基础库分离

  • 思路:将react/react-dom基础包通过cdn引入,不打入bundle中
  • 方法:使用html-webpack-externals-plugin npm i html-webpack-externals-plugin -D
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')
plugins: {
    new HtmlWebpackExternalsPlugin({
        externals: [
            {
                module: 'react',
                entry:'//..?_bid=3123',
                global: 'React'
            },{
                module: 'react-dom',
                entry:'//..?_bid=3123',
                global:'ReactDOM'
            }
        ]
    })
}