webpack 之 Chunk

module、chunk、bundle 的概念

webpack 术语表 中有名词解释:

  • Module: Module 是离散功能块,相比于完整程序提供了更小的接触面。精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。
  • Chunk: 此 webpack 特定术语在内部用于管理捆绑过程。输出束(bundle)由 chunk 组成,其中有几种类型(例如 entry 和 child )。通常,chunk 直接与 bundle 相对应,但是有些配置不会产生一对一的关系。
  • Bundle:由许多不同的模块生成,包含已经经过加载和编译过程的源文件的最终版本。
  • Entry Point: 入口起点告诉 webpack 从哪里开始,并遵循着依赖图知道要打包哪些文件。可以将应用程序的入口起点视为要捆绑的内容的 根上下文

以我自身的理解来阐述:

在模块化编程中,对于module(模块)广义的认知是所有通过import/require等方式引入的代码(*.mjs*.js文件)。而在万物皆模块的 webpack,项目中使用的任何一个资源(如css、图片)也都被视作模块来处理。在 webpack 的编译过程中,module 的角色是资源的映射对象,储存了当前文件所有信息,包含资源的路径、上下文、依赖、内容等。
原始的资源模块以 Module 对象形式存在、流转、解析处理。

chunk(代码块)是一些模块 (module) 的封装单元。于 webpack 运行时的 seal 封包阶段生成,且直到资源构建阶段都会持续发生变化的代码块,在此期间插件通过各种钩子事件侵入各个编译阶段对 chunk 进行优化处理。
webpack 在 make 阶段解析所有模块资源,构建出完整的 Dependency Graph (从 Entry 入口起点开始递归收集所有模块之间的依赖关系)。然后在 seal 阶段会根据配置及模块依赖图内容构建出一个或多个 chunk 实例,再由 SplitChunksPlugin 插件根据规则与 ChunkGraph 对 Chunk 做一系列的变化、拆解、合并操作,重新组织成一批性能更高的 Chunks。后续再为它们做排序和生成hash等一系列优化处理,直到 Compiler.compile 执行完成作为资源输出(emitAssets)。

bundle(包) 是 webpack 进程执行完毕后输出的最终结果,是对 chunk 进行编译压缩打包等处理后的产出。通常与构建完成的 chunk 为一对一的关系。但也有例外,比如:

  1. 当我们给 webpack 配置了生成 SourceMap 的选项 (devtool: 'source-map'):
// webpack 配置
module.exports = {
  entry: {
    app: './src/index.js',
  },
  mode: 'development',
  devtool: 'source-map',
  output: {
    path: __dirname + '/dist',
    filename: '[name].[contenthash:6].js',
    chunkFilename: '[name].[contenthash:8].js',
  },
  // ...
}

可以看到同一个 chunk 产生了两个 bundle,app.js 和与它对应的 app.js.map。

打包结果
  1. MiniCssExtractPlugin 在 seal 的资源生成阶段 - chunk 获取 Manifest 清单文件的时候抽离出 CssModule 到单独的文件,这时 chunk 关联的css也算一个 bundle 了。
    【mini-css-extract-plugin源码解析】

顺便说明下上面的output.filenameoutput.chunkFilename

  • filename 是给每个输出的 bundle 命名的(最终的 chunk),[name]取值为 chunk 的名称。入口 chunk 的[name]是 entry 配置的入口对象的 key,如上面的app (但只有当给 entry 传递对象才成立,否则都是默认的main)。runtime chunk 则是optimization.runtimeChunk 配置的名字。
  • 如果配置了chunkFilename,则除了包含运行时代码的那个 bundle,其余 bundle 的命名都应用chunkFilename
    如单独抽出 runtime chunk,那么 runtime 应用 output.filename,其余都应用output.chunkFilename;否则就是包含入口模块的 chunk 应用 output.filename,其余用output.chunkFilename

原理:看了源码就是 chunk 资源构建阶段触发了template.hooks:renderManifest,会执行插件 JavascriptModulesPlugin 的相关方法。根据模版的不同执行的方法也不同,mainTemplate负责生成包含 runtime 的 chunk 资源,应用的文件名模版是outputOptions.filenamechunkTemplate负责其他 chunk,应用的文件名模版是outputOptions.chunkFilename。后面 TemplatedPathPlugin 插件在监听到 assetPath hook 时根据这个名字模版,把文件名中的占位符如[chunkhash:8],替换成 chunk hash 值。这个 hash 值存在当前 chunk 的 清单文件数据(通过 template.getRenderManifest 得到)中,而 hash 是 chunk 创建后的优化阶段生成的 (我真能bb)

总结:我们开发的时候是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的 bundle。Chunk是过程中的代码块,Bundle是结果的代码块。
Module 主要作用在 webpack 编译过程的前半段,解决原始资源「如何读」的问题;而 Chunk 则主要作用在编译的后半段,解决编译产物「如何写」的问题,两者合作搭建起 webpack 搭建主流程。

chunk 的默认分包规则有:

  1. 同一个 entry 入口模块与它的同步依赖(直接/间接) 组织成一个 chunk,还包含 runtime (webpackBootstrap 自执行函数的形式)。
  2. 每一个异步模块与它的同步依赖单独组成一个 chunk。其中只会包含入口 chunk 中不存在的同步依赖;若存在同步第三方包,也会被单独打包成一个 chunk。

seal阶段开始后,遍历入口对象_preparedEntrypoints,为每一个入口初始化生成 chunk 和 entryPoint,入口 chunk 此时只有入口模块本身,与它的依赖真正建立联系要在后面生成chunk graph时。
在生成入口 chunk 后,会执行buildChunkGraph方法,借助ModuleDependencyGraph中存储的依赖关系,生成 chunk graph (chunk 依赖图)。
chunk graph 是 webpack 输出最终 chunk 的依据,它的构建有两个阶段,生成 basic chunk graph优化 chunk graph

我们从buildChunkGraph的三个子方法按顺序来详解:
1.visitModules
遍历compilation.modules建立起基本的 Module Graph (模块依赖图),为遍历异步依赖(block)等所用。先处理入口 chunk 的所有同步依赖,遍历时优先将同步依赖嵌套的同步模块添加完再去处理平级的同步依赖。然后按每个异步依赖的父模块被处理的顺序依次生成异步 chunk 和 chunkGroup。
然后遍历 module graph,为入口模块和它所有(直接/间接)同步依赖形成一个 EntryPoint(继承自 ChunkGroup),入口 chunk此时才会建立起与其依赖模块的联系。为所有异步模块和它的同步依赖生成一个 chunk 和 chunkGroup(会重复)。如 chunk 的同步模块已存在于入口 chunk,则不会再存入它的_modules中。此阶段初始生成了 chunk graph(chunk 依赖图)
2.connectChunkGroups检查入口 chunk 和 有异步依赖的异步 chunk, 如果它们的子 chunk 有它们未包含的新模块,就建立它们各自所属 chunkGroup 的 父子关系。
3.cleanupUnconnectedGroups找到没有父 chunkgroup 的 chunkgroup,删除它里面的 chunk,并解除与相关 module、chunk、chunkGroup 的关系。
2、3 阶段对 chunk graph 进行了优化,去除了 由已存在于入口 chunk 中的 模块创建的异步 chunk。

buildChunkGraph也可以说是 chunk 生成阶段compilation.hooks.afterChunks触发之后就进入 chunk 优化阶段
暴露很多 chunk 优化相关钩子:触发 optimize 相关 hook 移除空 chunk 和 重复 chunk,如配置了SplitChunksPlugin也会在此时再对 chunk 进行组合/分割;
然后触发其他 hook 分别设置 module.id、chunk.id 并对它们进行排序。以及各类 hash 的创建。

下面我们来具体举例说明。比如我们有一个入口 index,全部关联文件如下:
注:@是 src/ 目录的别名

// a.js 入口文件
// src/a.js
import { add } from '@/b'
import('@/c').then(c => c.sub(2, 1))
const a = 1
add(3, 2 + a)
console.log(e)

// src/b.js
import mul from '@/d'
import('@/f').then(({f}) => console.log(f)) 

export function add(n1, n2) {
  return n1 + n2 + mul(10, 5)
}
export function unusedAdd(n1, n2) {
  return n1 + n2 * n2
}

// src/d.js
export default function mul(n1, n2) {
  const x = 10000
  return n1 * n2 + x
}

// src/c.js
import mul from '@/d'
import e from '@/e'
import('@/b').then(b => b.add(200, 100))
console.log(e)

export function sub(n1, n2) {
  return n1 - n2 + mul(100, 50)
}

// src/e.js
export default 'e'

// src/f.js
import { sub } from '@/c'
sub(6,8)
export const f = 'f'

webpack 配置:

// webpack.config.js
module.exports = {
  entry: {
    index: "./src/a",
  }
  // 省略上面相同的 output 、mode 等配置
};

先看结果,abd打包进入口 chunk (index.js),c和它同步依赖e组成 chunk[1]、f和它同步依赖ce组成 chunk[0]

根据默认分包规则为何输出这几个 chunk 不难理解,可能唯一需要捋的是
(1)c的同步依赖d(2) 异步依赖b webpack 是怎么处理的?(3)还有c作为入口的异步依赖,又被异步模块同步引入的情况。

再回去看我们上面大段 chunk 生成/优化流程:生成初始 chunk graph 阶段c的同步依赖d时发现d已经存在于入口 chunk,故不会再存入c所在的 chunk 中,疑问(1)解决。b在此时会生成一个异步 chunk 和 一个 chunkGroup。

初步的basic chunk 依赖图

此时 chunk 的顺序 和 图示 chunkGroup 一致。

接着对 chunk graph 进行优化,去除了 由已存在于入口 chunk 中的b模块创建的异步 chunk。疑问(2)解决。

不从原理角度也很好理解b已经存在于入口 chunk 了,项目运行时入口 chunk 会先于其他异步 chunk 加载。届时已经能获取到b,没有必要再去异步获取。

至于c为什么被重复打包进f生成的异步 chunk 从原理也好理解了,因为同步依赖c不存在于入口 chunk。疑问(3)解决。

最终的 chunk graph

具体过程很复杂,可以看下这篇【webpack系列之六chunk graph图生成】

另外,重复引入的异步块,最终只会生成一个异步 chunk (本例没有体现)。在chunk graph 优化完毕,chunk 优化阶段会借助订阅 hook 的插件实现 chunk 去重和 删除空 chunk、给 chunk 排序等。

默认打包规则存在的问题

第一个缺点:重复打包模块

实际项目中公共模块的数量和 size 和 demo 不是一个量级。像上例ce这样重复打包的问题就会尤其显著。 虽然 SplitChunksPlugin 插件的默认配置会起作用,比如不同 chunk 中大于20kb从属于异步 chunk的公共模块(公用 >= 2次)会被抽离出来。但这明显不足以适用所有情况。
多入口配置也会造成这个问题,比如现在添加了一个 admin 入口:

// webpack.config.js
module.exports = {
  entry: {
    index: "./src/a",
    admin: "./src/b"
  }
};

不同入口有相同的同步依赖会重复打包,有相同的异步依赖则不会(只单独打包一次)。

看框出部分,两个入口共同依赖bbbb就被重复打包在index.jsadmin.js里,因为不同入口 chunk 相互独立。这会造成不必要的性能损耗。合理利用 SplitChunksPlugin 能够更高效、智能地实现「启发式分包」,这里涉及的不在此展开,可移步另一篇【webpack SplitChunksPlugin 配置详解】。

另外的不足:
  • 每次打包都会变动的 runtime 包含在入口 chunk,会影响入口 chunk 文件的本地缓存
  • 第三方插件、UI库这种变动很少的模块作为同步依赖和别的模块打包在一个 chunk 中,无法利用浏览器缓存
  • SplitChunksPlugin 代码分割插件默认只处理异步 chunk

runtime 分包

出于性能考虑通常会将入口 chunk 中的 runtime 单独抽离。
配置方法:entry.runtime (webpack5) 或 optimization.runtimeChunk

同样是多入口情况,如果不抽离也会重复生成多份 runtime 代码(在每个入口 chunk 中),如果像下面这样抽出,两个入口就可以共用一份运行时 chunk 了。

module.exports = {
  entry: {
    // 不同入口为 runtime 属性设置同样的名称即可共享一个 runtime chunk
    index: { import: "./src/index", runtime: "runtime" }, // webpack 5 支持
    admin: { import: "./src/admin", runtime: "runtime" }, 
  }
  // 或者
  // optimization: {
    // runtimeChunk: {
      // name: 'runtime',
    // },
  // }
};

runtime 呈现为一个自执行函数,包含模块交互时连接模块所需的加载和解析逻辑的所有代码。它负责项目的运行,webpack 通过它来连接模块化应用程序。它不仅与业务代码联系紧密,还伴随着 manifest 数据(chunks 映射关系的 list),每个 chunk 的 id 都是基于内容 hash 出来的,所以每次改动都会影响它,如果打包进入口 chunk 等于入口文件(如 index.js)的缓存每次都会失效,这显然不是我们想要的。

原理:compilation.hooks: optimizeChunksAdvanced钩子事件被触发的时候RuntimeChunkPlugin 的监听事件会响应 (SplitChunksPlugin 插件也是这时处理的):这时候默认规则的 chunk 已分包(组)完成 (入口 和 异步),如有配置 optimization.runtimeChunk,会在这此基础上抽离出 runtime 代码。

编译时,webpack 会根据业务代码决定输出哪些支撑特性的运行时代码(基于 Dependency 子类),例如:

需要 webpack_require.f、webpack_require.r 等方法实现最起码的模块化支持;
如果有用到动态加载特性,则需要写入 webpack_require.e 函数;
如果用到 Module Federation 特性,则需要写入 webpack_require.o 函数
等...

在实际项目中,单独的 runtimeChunk 只是用于驱动不同页面路由和组件的加载,代码量比较小,而这个文件经常改变,每次都需要重新请求它。它的 http 耗时远大于它的执行时间了,所以通常的做法是将它抽出再内联到我们的 index.html 之中(index.html 本来每次打包都会变)。配置 optimization.runtimeChunk 抽离,使用 script-ext-html-webpack-plugin 插件将其内联在 index.html 。

示例:vue-cli 项目在 vue.config.js 用 webpack-chain 配置:

chainWebpack(config) {
  config
    .plugin('ScriptExtHtmlWebpackPlugin')
    .after('html')
    .use('script-ext-html-webpack-plugin', [{
      // `runtime` must same as runtimeChunk name. default is `runtime`
      // 正则匹配 runtime 文件名
      inline: /runtime\..*\.js$/
     }])
    .end()
  
  config.optimization.runtimeChunk('single')
}

关于 chunk 更详细的编译步骤可以参考【浅析 webpack 打包流程(原理) 三 - 生成 chunk】、【浅析 webpack 打包流程(原理) 四 - chunk 优化】

有点难的知识点: Webpack Chunk 分包规则详解
Webpack 理解 Chunk
webpack系列之六chunk图生成

你可能感兴趣的:(webpack 之 Chunk)