13. 代码分割

242 阅读10分钟

代码分割目的

单入口打包的代码都在一个文件里,这样导致代码过大,所以要把一些不是马上用到的代码拆分出来,加快首屏速度。 QQ截图20240528074752.png

单入口

  1. 异步导入
    原则:又大又后面使用的文件,异步加载。
  • math.ts
export function add(num1,num2){
    return num1 + num2
}
  • a.ts
console.log('aaa')
const a = 'aaa'
export default a 
  • b.ts
console.log('bbb')
const b = 'bbb'
export default b
  • index.ts
import {add} from './math.ts'
add(1,2)

setTimeout(() => {
  import('./a.ts').then(res => {
    console.log(res)
  })
  import('./b.ts').then(res => {
    console.log(res)
  })
},2000)
  • 打包之后 image.png

多入口

多入口因为有多个入口,所以打包后的代码本来就是分开的。

  1. 多入口主要去解决重复加载同一段逻辑代码。 QQ截图20240528081630.png
  • webpack.config.js
module.exports = {
  entry:{
    app1: "./src/index1.ts",
    app2: "./src/index2.ts",
  },
  output: {
    filename: "[name].[hash:4].bundle.js",
    path: path.resolve(__dirname, "./build"),
  }
}
  • /src/index1.ts
import {add} from './math.ts'
import a from './a.ts'
import b from './b.ts'
add(1,2)
console.log(a,b)
  • /src/index2.ts
import {add} from './math.ts'
import a from './a.ts'
import b from './b.ts'
add(1,2)
console.log(a,b)

打包之后: image.png

两个打包后的 js 文件中有相同的a和b的代码。

    mode: "production",
    entry: {
        app1: "./src/js/index1.js",
        app2: "./src/js/index2.js",
    },
    output: {
        filename: "[name].[hash:4].bundle.js",
        // 必须是一个绝对路径
        path: path.resolve(__dirname, "./build"),
        clean: true
    },
    optimization: {
        splitChunks: {
            chunks: "all", // 不管同步异步都拆分
            minChunks: 2, // 一个模块重复使用几次,才会分割
            minSize: 0, // 大于这个值才会拆分
            name: 'common' // 拆分出来的文件名称
        }
    }

打包之后: image.png

image.png

第三方库、webpack 运行时代码单独打包

  • 第三方库单独分离文件
    optimization: {
        splitChunks: {
            chunks: "all", // 不管同步异步都拆分
            cacheGroups: {
                // 第三方包
                vendor: {
                    test: /[\/]node_modules[\/]/,
                    filename: "vendor.js",
                    chunks: "all",
                    minChunks: 1,
                    minSize: 0
                },
                // 公共代码
                common: {
                    filename: "common.js",
                    chunks: "all",
                    minChunks: 2,
                    minSize: 0
                }
            }
        }
    }

image.png

  • webpack 运行时代码单独打包
  optimization:{
    splitChunks:{
      chunks: "all", // 不管同步异步都拆分
      cacheGroups:{
        // 第三方包
        vendor:{
          test: /[\\/]node_modules[\\/]]/,
          filename: "vendor.js",
          chunks:"all",
          minChunks:1
        },
        // 公共代码
        common:{
          filename: "common.js",
          chunks:"all",
          minChunks:2,
          minSize:0
        }
      }
    },
    runtimeChunk:{
      name:"runtime"
    }
  }

image.png

Webpack中常用的代码分离有三种

  1. 多入口起点:使用entry配置手动分离代码。
  2. 防止重复:使用 Entry Dependencies 或者 SplitChunksPlugin 去重和分离代码。
  3. 动态导入:通过模块的内联函数调用来分离代码。

1. 多入口起点

配置多入口

module.exports = {
  entry: {
    index: "./src/index.js",
    main: "./src/main.js",
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "./dist"),
  },
};

打包之后生成了 index.bundle.jsmain.bundle.js,打包后的 html 中引入了两个js 文件。

2.1 Entry Dependencies(入口依赖)

index.js 和 main.js 都依赖 dayjs,如果只进行入口分离,那么打包后的两个 bunlde 都有会有一份 dayjs,正确的处理方式是两个文件共享一个包文件。

const path = require("path");
module.exports = {
  entry: {
    index: { import: "./src/index.js", dependOn: "dayjs" },
    main: { import: "./src/main.js", dependOn: "dayjs" },
    dayjs: "dayjs",
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "./dist"),
  },
};

打包生成了dayjs.bundle.js index.bundle.js main.bundle.js 打包后的 html 中引入了三个资源

    <script defer="defer" src="index.bundle.js"></script>
    <script defer="defer" src="main.bundle.js"></script>
    <script defer="defer" src="dayjs.bundle.js"></script>

2.2 SplitChunks

  • 为什么需要 splitChunks?
    wepack 设置中有 3 个入口文件:a.jsb.js 和 c.js,每个入口文件都同步 import 了 m1.js,不设置 splitChunks,配置下 webpack-bundle-analyzer 插件用来查看输出文件的内容,打包输出是这样的:
// webpack 和 splitChunks 的初始设置如下
const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'async',
      
      // 生成 chunk 的最小体积(以 bytes 为单位)。
      // 因为演示的模块比较小,需要设置这个。
      minSize: 0,
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

a.js、b.js、c.js

import m1 from './m1'

QQ截图20231111130120.png

  • chunks
    splitChunks.chunks 的作用是指示采用什么样的方式来优化分离 chunks,常用的有三种常用的取值:asyncinitial 和 allasync 是默认值。

async
chunks: 'async' 的意思是只选择通过 import() 异步加载的模块来分离 chunks。举个例子,还是三个入口文件 a.jsb.js 和 c.js,有两个模块文件 m1.js 和 m2.js,三个入口文件的内容如下:

// a.js
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// b.js
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// c.js
import('./utils/m1');
import './utils/m2';

console.log('some code in c.js');

这三个入口文件对于 m1.js 都是异步导入,m2.js 都是同步导入。打包输出结果如下: image.png 对于异步导入,splitChunks 分离出 chunks 形成单独文件来重用,而对于同步导入的相同模块没有处理,这就是 chunks: 'async' 的默认行为。

  • initial
    把 chunks 的值改为 initial 后,再来看下输出结果: image.png 同步的导入也会分离出来了,效果挺好的。这就是 initial 与 async 的区别:同步导入的模块也会被选中分离出来。
  • all
    加入一个模块文件 m3.js,并对入口文件作如下更改:
// a.js
import('./utils/m1');
import './utils/m2';
import './utils/m3'; // 新加的。

console.log('some code in a.js');

// b.js
import('./utils/m1');
import './utils/m2';
import('./utils/m3'); // 新加的。

console.log('some code in a.js');

// c.js
import('./utils/m1');
import './utils/m2';

console.log('some code in c.js');

有点不同的是 a.js 中是同步导入 m3.js,而 b.js 中是异步导入。保持 chunks 的设置为 initial,输出如下: image.png 可以到看 m3.js 单独输出的那个 chunks 是 b 中异步导入的,a 中同步导入的没有被分离出来。也就是在 initial 设置下,就算导入的是同一个模块,但是同步导入和异步导入是不能复用的。
把 chunks 设置为 all,再导出康康: image.png 不管是同步导入还是异步导入,m3.js 都分离并重用了。所以 all 在 initial 的基础上,更优化了不同导入方式下的模块复用。
这里有个问题,发现 webpack 的 mode 设置为 production 的情况下,上面例子中 a.js 中同步导入的 m3.js 并没有分离重用,在 mode 设置为 development 时是正常的。不知道是啥原因 asyncinitial 和 all 类似层层递进的模块复用分离优化,所以如果考虑体积最优化的输出,那就设 chunks 为 all
如果配置了 async ,是不会多出来一个包的,只有异步导入才会分包。

  • minSize
    拆分包的大小, 至少为minSize,如果一个包拆分出来达不到minSize,那么这个包就不会拆分。默认值是 20 kb。
  • maxSize
    将大于maxSize的包,拆分为不小于minSize的包。maxSize 是要大于等于 minSize 的。
  • minChunks
    至少被引入的次数,默认是1,如果我们写一个2,但是引入了一次,那么不会被单独拆分。
  • cacheGroups
    通过 cacheGroups,可以自定义 chunk 输出分组。设置 test 对模块进行过滤,符合条件的模块分配到相同的组。
    splitChunks 默认情况下有如下分组
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

意思就是存在两个默认的自定义分组,defaultVendors 和 defaultdefaultVendors 是将 node_modules 下面的模块分离到这个组。我们改下配置,设置下将 node_modules 下的模块全部分离并输出到 vendors.bundle.js 文件中:

const path = require('path');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

QQ截图20231111135519.png 所以根据实际的需求,我们可以利用 cacheGroups 把一些通用业务模块分成不同的分组,优化输出的拆分。 举个栗子,我们现在输出有两个要求:

  1. node_modules 下的模块全部分离并输出到 vendors.bundle.js 文件中。
  2. utils/ 目录下有一系列的工具模块文件,在打包的时候都打到一个 utils.bundle.js 文件中。
const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
    b: './src/b.js',
    c: './src/c.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口文件调整如下:

// a.js
import React from 'react';
import ReactDOM from 'react-dom';
import('./utils/m1');
import './utils/m2';

console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m2';
import './utils/m3';

console.log('some code in a.js');

// c.js
import ReactDOM from 'react-dom';
import './utils/m3';

console.log('some code in c.js');

image.png

  • maxInitialRequests 和 maxAsyncRequests

maxInitialRequests 表示入口的最大并行请求数。规则如下:

  • 入口文件本身算一个请求。
  • import() 异步加载不算在内。
  • 如果同时有多个模块满足拆分规则,但是按 maxInitialRequests 的当前值现在只允许再拆分一个,选择容量更大的 chunks。
const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      maxInitialRequests: 2,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口文件内容如下:

// a.js
import React from 'react';
import './utils/m1';

console.log('some code in a.js');

打包输出结果如下: image.png 按照 maxInitialRequests = 2 的拆分过程如下:

  • a.bundle.js 算一个文件。
  • vendors.bundle.js 和 utils.bundle.js 都可以拆分,但现在还剩一个位,所以选择拆分出 vendors.bundle.js。 把 maxInitialRequests 的值设为 3,结果如下: image.png 再来考虑另外一种场景,入口依然是 a.js 文件,a.js 的内容作一下变化:
// a.js
import './b';

console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m1';

console.log('some code in b.js');

调整为 a.js 同步导入了 b.jsb.js 里再导入其他模块。这种情况下 maxInitialRequests 是否有作用呢?可以这样理解,maxInitialRequests 是描述的入口并行请求数,上面这个场景 b.js 会打包进 a.bundle.js,没有异步请求;b.js 里面的两个导入模块按照 cacheGroups 的设置都会拆分,那就会算进入口处的并行请求数了。 比如 maxInitialRequests 设置为 2 时,打包输出结果如下: image.png 设置为 3 时,打包输出结果如下: image.png maxAsyncRequests 的意思是用来限制异步请求中的最大并发请求数。规则如下:

  • import() 本身算一个请求。
  • 如果同时有多个模块满足拆分规则,但是按 maxAsyncRequests 的当前值现在只允许再拆分一个,选择容量更大的 chunks。 还是举个栗子,webpack 配置如下:
const path = require('path');
const BundleAnalyzerPlugin =
  require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'development',
  entry: {
    a: './src/a.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      maxAsyncRequests: 2,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
          name: 'vendors',
        },
        default: {
          test: /[\\/]utils[\\/]/,
          priority: -20,
          reuseExistingChunk: true,
          name: 'utils',
        },
      },
    },
  },
  plugins: [new BundleAnalyzerPlugin()],
};

入口及相关文件内容如下:

// a.js
import ('./b');
console.log('some code in a.js');

// b.js
import React from 'react';
import './utils/m1';
console.log('some code in b.js');

这个时候是异步导入 b.js 的,在 maxAsyncRequests = 2 的设置下,打包输出结果如下: image.png 按照规则:

  • import('.b') 算一个请求。
  • 按 chunks 大小再拆分 vendors.bundle.js
    最后 import './utils/m1' 的内容留在了 b.bundle.js 中。如果将 maxAsyncRequests = 3 则输出如下: image.png

3. 动态导入

使用 ECMAScript 中的 import() 语法来完成,也是目前推荐的方式。 异步导入的代码,都会生成独立文件。

import("./bar_1");
import("./bar_2");
  • 动态导入的文件命名
    因为动态导入通常是一定会打包成独立的文件的,所以并不会再cacheGroups中进行配置,通常会在output中,通过 chunkFilename 属性来命名。
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "./dist"),
    chunkFilename: "chunk_[id]_[name].js"
  }

默认情况下我们获取到的 [name] 是和id的名称保持一致的。 QQ截图20231107000005.png 如果我们希望修改name的值,可以通过magic comments(魔法注释)的方式。 QQ截图20231106235915.png

4.chunkIds

optimization.chunkIds 配置用于告知 webpack 模块的 id 采用什么算法生成。

  • natural:按照数字的顺序使用id
    QQ截图20231106235036.png
  • named:development下的默认值,一个可读的名称的id
    QQ截图20231106235137.png
  • deterministic:确定性的,在不同的编译中不变的短数字id
    QQ截图20231106235236.png

5.代码的懒加载

动态import使用最多的一个场景是懒加载(比如路由懒加载): 封装一个component.js,返回一个component对象,可以在一个按钮点击时,加载这个对象。

const element = document.createElement("div");
element.innerHTML = "cpn";
export default element;
const button = document.createElement("button");
button.innerHTML = "点击按钮";
button.addEventListener("click", () => {
  import("./element").then(({ default: component }) => {
    document.body.appendChild(component);
  });
});
document.body.appendChild(button);

QQ截图20231111155952.png 动画.gif 这个方式有一个缺点是:点击的时候下载 js 文件,然后执行 js 文件,过程有点长了,可以使用 Prefetch 预先下载。

6.Prefetch 和 Preload

Prefetch(预下载)
上述案例修改如下:

const button = document.createElement("button");
button.innerHTML = "点击按钮";
button.addEventListener("click", () => {
  import(/* webpackPrefetch: true */"./element").then(({ default: component }) => {
    document.body.appendChild(component);
  });
});
document.body.appendChild(button);

QQ截图20231111161104.png
Preload和父bundle一起下载。 Prefetch 和 Preload 区别

  1. preload chunk 会在父 chunk 加载时,以并行方式开始加载。
  2. prefetch chunk 会在父 chunk 加载结束后开始加载。
  3. preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
  4. preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻.

文章引用
[webpack 拆包:关于 splitChunks 的几个重点属性解析]