配置分离

有一些 webpack 的配置是只有在开发环境中使用的,有一些 webpack 是只有在生产环境中使用,而有些配置时公用的,如果把这些文件放在一起的话会比较混乱不好管理,所以我们可以将其拆分为多个配置文件:dev.config.js 开发环境配置、pro.config.js 生产环境配置、common.config.js 公用配置,然后再将其合并使用。

下载 webpack.merge 用于合并配置:

  1. npm i webpack-merge -D

开发环境配置:

const { merge: webpackMerge } = require('webpack-merge');
const commonConfig = require('./common.config');

process.env.NODE_ENV = 'development'; // 设置对应环境

module.exports = webpackMerge(commonConfig, {
    mode: 'development',
});

生产环境:

const { merge: webpackMerge } = require('webpack-merge');
const commonConfig = require('./common.config');

process.env.NODE_ENV = 'production'; // 设置对应环境

module.exports = webpackMerge(commonConfig, {
    mode: 'production',
});

公共配置:

module.exports = {
    //...
}

在指定生产环境和开发环境时都会process.env.NODE_ENV 进行了赋值,它的主要作用时用于在其他配置文件中判断环境,比如:bable.config.js:

// babel.config.js
const plugins = [];
const presets = [
  [
    '@babel/preset-env',
    {
      useBuiltIns: 'usage',
      corejs: 3,
    },
  ],
  [      
    '@babel/preset-react',
  ],
  [
    '@babel/preset-typescript',
  ],
];

// 判断环境
if (process.env.NODE_ENV === 'development') {
  plugins.push('react-refresh/babel');
}

module.exports = {
  presets,
  plugins,
}

修改完配置后还要修改一下 scripts 脚本,添加 start 和 serve 两个命令:

 "scripts": {
    "start": "webpack serve --config ./config/webpack.dev.config.js --progress",
    "build": "webpack --config ./config/webpack.pro.config.js --progress"
  },

代码分离

代码分离(Code Split)是 webpack 的重要特性之一,它的作用是将代码分离到不同的 bundle 中,之后就可以实现代码的按需加载、并行加载。

默认情况下所有的代码(业务代码、第三方依赖)都是打包到一个 bundle 中的,那么就说明需要一次性加载这些文件,这是没有必要的,因为有些文件可能暂时用不到,一次性把所有文件都加载的话就会影响到页面的加载速度。

webpack 中配置代码分离的方法有三种:

  - 入口起点:多个 entry 手动配置代码分离。
  - 防止重复:使用 Entry Dependencies 或 Split Chunk Plugin 去除重复引用代码(多次引用只打包一次)和代码分离。
  - 动态导入:通过异步加载 `import('xx').then()` 分离代码。

多入口起点

配置多个 entry 来实现代码分离,这时 entry 的 value 是一个配置对象,这个配置对象的 key 可以随便填,value 则是对应的文件地址。

注: 使用多入口时 output 中的 filename 属性需要使用 placeholder 使文件的名字不出现重复,如果打包的文件名重复会导致打包失败。

// webpack.config.js
module.exports = {
    entry: {
      index: './src/index.js',
    app: './src/app.js',
  },
  output: {
      filename: 'js/[name]-main.js', // 必须使用 placeholder
    path: './dist',
  },
}

打包后的效果:
image.png

Entry Dependencies (入口依赖)

上面我们配置的多入口实现了代码分离,如果这两个入口文件都引用了相同的第三方依赖 dayjslodash,这时打包后就会发现 app-main 和 index-main 都打包了这两个第三方包,导致 dayjslodash 重复打包了。
image.png
dayjslodash 这两个第三方包是没必要重复打包的,最好的方法就是将其单独打包,然后引入即可,在多入口配置中使用 Entry Dependencies 来实现这个功能:

修改 webpack 配置,首先将 lodash 和 dayjs 也配置到 entry 中,这样这两个包就会进行单独打包、代码分离,然后将 index 和 app 的配置对象的 value 改为一个对象,这个对象的 import 属性为对应的文件地址,dependOn 属性是一个数组,数组中放上其模块的依赖项名称,依赖项的名称对应的是 entry 配置中的一项,在这里的依赖项就是 lodash 和 dayjs。

module.exports = {
    entry: {
    index: {
      import: './src/index.js',
      // 依赖数组
      dependOn: [
        'lodash',
        'dayjs'
      ],
    },
    app: {
      import: './src/app.js',
      dependOn: [
        'lodash',
        'dayjs'
      ]
    },
    // 配置 lodash 和 dayjs
    lodash: 'lodash',
    dayjs: 'dayjs',
  },
}

打包后的效果:
image.png

Split Chunks

split Chunks 是使用 splitChunksPlugins 来实现的,该插件已经被 webpack 默认安装和集成,所以我们不需要手动进行安装了,只需要直接提供相应的配置就行。

module、chunk、bundle 的概念

module:通过 import、require 引入的模块。

chunk:是根据 webpack 根据功能拆分出来的,包含三种情况:

  - 入口配置 entry,entry 的代码
  - 通过异步导入模块 import() 引入的代码
  - 通过 splitChunks 拆分的代码

以上三种情况抽离出来的代码就称为 chunk,chunk 中包含着一个或多个模块(module), webpack 会对 chunk 进行编译、压缩、打包生成 bundle。

bundle:bundle 指的是 webpack 打包出来的文件,一般就是和 chunk 一对一的关系,bundle 就是对 chunk进行编译、压缩、打包等处理之后的产出。

配置 splitChunks

splitChunks 配置在 optimizaion 属性中:

module.exports = {
    optimization: {
    // 配置 splitChunks
      splitChunks: {
        chunks: 'all', // 配置为 all
    },
  },
}

splitChunks.chunks 的配置有下面几种选择:

  - async :异步导入的模块单独打包 (默认值)
  - initial :同步导入的模块单独打包(webpack 会将异步加载的模块单独抽离为一个 chunk,所以这里就算设置为 initial 也会将异步导入的模块抽离)
  - all      :同步或异步导入的模块都单独打包

配置为 chunks:all 后,lodash、dayjs 就单独打包了:
image.png

splitChunks 常用配置

在上面直接配置 chunks = all 就实现了代码分割的原因是 splitChunks 中有一套默认的配置,我们也可以自定义下配置。

splitChunks 中的常用配置如下:

minSize

minSize 指定拆分 chunk 的大小,如果达不到 minSize 就不会拆分为 chunk,只有大与 minSize 才会拆分为一个 chunk。

module.exports = {
  optimization: {
      splitChunks: {
      minSize: 20000, // 默认为 20000
    },
  },
}

maxSize

如果一个 chunk 的体积大于 maxSize 指定的值,那么 webpack 会将这个 chunk 进行拆分,拆分出来的包体积至少为 minSize。

使用 maxSize 可以实现更细粒度的拆分,生成的 bundle 也会变多,webpack 官网表述 maxSize 选项旨在与 HTTP/2 和长期缓存一起使用。它增加了请求数量以实现更好的缓存。它还可以用于减小文件大小,以加快二次构建速度。

module.exports = {
  optimization: {
      splitChunks: {
      maxSize: 20000,
    },
  },
}

minChunks

一个 module 被引入次数在大于/等于 minChunks 的情况下才会被拆分。

module.exports = {
    optimization: {
      splitChunks: {
        minChunks: 2, // 引入 2 次及以上的包才会被拆分
    },
  },
}

splitChunks - cacheGroups 使用

cacheGroups 是 splitChunks 中最核心的配置, splitChunks 就是根据 cacheGroups 中的规则拆分模块的,上面的 chunk、minSize、maxSize 等配置都是为 cacheGroups 服务的。

cacheGroups 的默认配置为:

module.exports = {
    optimization: {
    // cacheGroups 配置
    cacheGroups: {
      defaultVendors: {
        test: /[\\/]node_modules[\\/]/, // 匹配 node_modules 下的包
        priority: -10, // 优先级, 如果命中多个规则, 那个优先级高按照那个配置处理
        reuseExistingChunk: true, // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用
      },
      default: {
        minChunks: 2,
        priority: -20,
        reuseExistingChunk: true,
      },
    }
  },
}

上面 cacheGroups 的默认配置中有两项:

第一个是 defaultVendors,这个配置主要作用是将 node_modules,下的第三方包进行了代码分割,上面配置 chunk: all 后能将第三方包 lodash 和 dayjs 拆分出来的原因就是有这个配置。

第二个是 default,这个配置的主要作用是将多个 chunk 中共同引入的包进行拆分(如两个 chunk 中都引用了一个 utils.js ),注意:这里指的是 chunk,满足这个 chunk 的条件只有异步导入(import())和多入口 (entry)。

default 配置不能实现的代码分割:

// utils.js
export default function add(a, b) {
    return a + b;
}
// test.js
import add from './utils';

export default function() {
  return add(1, 2);
}
// 入口文件
import add from './utils';
import test from './test';

add(1, 2);
console.log(test())

上面代码中 tuils 文件被引入了多次,但是他不会被 splitChunks 分离,原因是它不满足上面的 default 配置的条件。

自定义 cacheGroups 配置规则:
cacheGroups 配置中的 key 是随便指定的叫什么都可以,我们可以自定义一个规则来解决上面 default 不能实现的代码分割问题:

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        utils: {
          test: /utils/, // 匹配文件名称为 utils 的文件
          priority: -15,
          name: 'utils', // 指定这个 chunk 的名称
          // filename: 'xx'
        },
      },
    },
  },
}

上面配置有 name 和 filename 两个属性,它俩的区别如下:
name: 用于给这个 chunk 起一个名字,不能使用 placeholder,最后根据 output 出口的 filename 输出。

filename:用于给这个 chunk 打包后的 bundle 起名字,他会影响 output 出口的 filename 配置,可以使用 placeholder ,也可以指定输入的目录。

注:如果没有给 chunk 起名字,那么 chunk 的名字会根据 optimization.chunkIds 配置生成的 id 作为名称。

示例二:
默认配置中会将 node_modules 中的文件全部都打包在一个文件中,我们也可以通过自定义配置实现更细粒度的拆分,这也是 vendors 包过大时的解决方法:

module.exports = {
    optimization: {
      splitChunks: {
        chunks: 'all',
      cacheGroups: {
        // node_modules 中的 vue 单独生成一个 chunk
        vue: {
          name: 'vue',
          test: /[\\/]node_modules[\\/]vue[\\/]/,
          priority: -10, // 优先级大于 vendors
        },
        // 除去 vue 其他的生成一个 chunk
        vendors: {
            name: 'vendors',
          test: /[\\/]node_modules[\\/]/,
          priority: -20,
        },
      },
    },
  },
}

动态导入的文件命名

通过 import() 动态导入的文件会形成一个 chunk ,webpack 会对这个 chunk 进行打包,这个 chunk 的名称一般是根据 optimization.chunkIds 或 optimization.splitChunks.cacheGroups 配置的 name、filename 生成的,自动生成的这个名字也许不是我们想要的,这时可以通过魔法注释的方式来给这个 chunk 起一个名字。

import(/* webpackChunkName: "dayjs" */ 'dayjs')).then((dayjs) => {
  console.log(dayjs());
}

// 注意:注释中的名称必须用引号包裹起来, 不然报错.

打包后的结果(根据魔法字符串指定的名称 + output 的 filename 生成):
image.png

chunkFilename

动态导入和 splitChunks 生成的 chunk 最后的命名是根据 output.filename 生成的,在 output 中还有一个 chunkFilename 这个属性决定了非初始(non-initial)chunk 文件的名称(这里可以理解为,import 动态导入和 splitChunks 生成的代码)。

// webpack.config.js
module.exports = {
    output: {
        //...
    chunkFilename: 'js/[name]-[hash].chunk.js',
    },
}

optimization.chunkIds

optimization.chunkIds 用于告知 webpack 在生成 chunk id 时使用什么算法,常见的有三个值:

  - natural:自然数,按照 1、2、3... 的方式使用 id
  - named:使用 `splitChunk.cacheGroups` 配置中的 key + 第三方包名生成,一个 chunk 中存在多个 module 时,会将其连起来。
  - deterministic:根据文件内容生成一个固定的 id,文件内容不变时生成的 id 不会变。

最佳实践:
开发中使用 named,生产使用 deterministic (也是默认配置)。

optimization.runtimeChunk

在 wepback 运行环境中会生成一小段代码,这一小段代码叫做 runtime,当我们通过 import() 或 splitChunks 把代码分割为多个包时,在这个 runtime 文件中还会包括这些 chunk id 和对应文件的映射关系。

这段 runtime 代码默认会被打包到对应的 entry 入口文件中,假如我们通过 import() 拆分出来一个文件,当这个文件内容发生变化时,那么它对应的 chunk id 也会发生变化,chunk id 发生变化会导致 runtime 这段代码发生变化,如果 runtime 是放在入口文件中的,那么入口也会连带着发生变化,这样会导致浏览器的缓存失效(因为生成的 contenthash 不同)。

解决这个问题的方法就是将 runtime 这段代码抽离出来,这样当拆分出来的代码发生变化时,只有这个文件和 runtime 文件的 hash 会改变,入口文件的名称不变就不会影响到浏览器的缓存了。

optimization.runtimeChunk 设置为 ture 或 ‘multipe’ 就可以将 runtime 抽离出来:

module.exports = {
    // ...
  optimization: {
      runtimeChunk: true,
  },
}

optimization.runtimeChunk 的配置选项:
设置为 true、’multipe’ , 这两个值的效果相同,都是将 runtime 抽离出来,多个入口会抽离为多个 runtime 文件,这两个是下面这段配置的别名。

module.exports = {
  //...
  optimization: {
    runtimeChunk: {
      // name 是为生成的 runtime 文件起别名
      name: (entrypoint) => `runtime~${entrypoint.name}`,
    },
  },
};

还可以设置为 ‘single’, 这个配置也是将 runtime 抽离出来,但是会将多个入口的 runtime 合并在一个文件里面,它相当于下面配置的别名:

module.exports = {
  //...
  optimization: {
    runtimeChunk: {
      name: 'runtime',
    },
  },
};

默认值为 false: 每个入口 chunk 中直接嵌入 runtime。

InlineChunkHtmlPlugin

抽离处理的 runtime 文件其实是非常小的,其实我们可以将其之间内联到 HTMl 文件中,而不必让客户端再发送一个请求获取。

下载 react-dev-utils 插件,inlineChunkHtmlPlugin 是在这里面的:

npm i react-dev-utils -D

修改 webpack 配置:

const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    plugins: [
    // InlineChunkHtmlPlugin 内部会用到 HtmlWebpackPlugin
      new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [
      /^runtime.+\.js$/,
    ]),
    new HtmlWebpackPlugin({
      template: '/public/index.html',
    }),
  ]
}