webpack知识点

如何区分 webpack 中的 hash、chunkhash、contenthash?

hash 反映了项目的构建版本,因此同一次的构建过程中生成的 hash 值都是一样的。换句话说,如果项目里的某个模块发生了改变,触发了项目的重新构建,那么文件的 hash 值会相应改变。但是使用 hash 策略会存在一个问题:即使某个模块的内容压根没有改变,重新构建后也会产生一个新的 hash 值,使得缓存命中率很低。

chunkhash 根据入口文件进行依赖分析,contenthash 会根据文件具体内容生成 hash。

假设我们的项目做到了将公共库和业务项目入口文件区分开单独打包,采用 chunkhash 策略时,改动业务项目入口文件,不会引起公共库的 hash 改变:

1
2
3
4
5
6
7
8
9
entry: {
main: path.join(__dirname, './main.js'),
vendor: ['react']
},
output: {
path: path.join(__dirname, './build'),
publicPath: '/build/',
filename: 'bundle.[chunkhash].js'
}

我们再看一个例子,在 index.js 文件中对 index.css 进行引用,如下代码:

1
require('./index.css')

此时,因为 index.js 文件和 index.css 文件具有依赖关系,所以它们共用共同相同的 chunkhash 值。如果 index.js 文件发生了改变,即使 index.css 中内容没有改动,那么在使用 chunkhash 策略时,被单独拆分的 index.css 的 hash 值也发生了变化。

如果想让 index.css 完全根据文件内容来确定 hash 值,可以使用 contenthash 策略。

代码拆分和按需加载技术的实现

目前,按需打包一般通过两种方法实现:

  1. 使用 ES Module 支持的 Tree Shaking 方案,在使用构建工具打包时完成按需打包。

  2. 使用以 babel-plugin-import 为主的 Babel 插件实现自动按需打包。

通过 Tree Shaking 实现按需打包

假设业务中使用 antd 的 Button 组件,命令如下。

1
import { Button } from 'antd'

这样的引用会使最终打包的代码中包含 antd 导出的内容、假设我们的应用中并没有使用 antd 提供的 TimePicker 组件,那么对于打包结果来说,无疑增加了代码体积。在这种情况下,如果组件库提供了 ES Module 版本,并开启了 Tree Shaking 功能,那么我们就能通过“摇树”特性将不会被使用的代码在构建阶段移除。

以 webpack 为例。可以在 package.json 中设置 sideEffects: false。我们可以在 antd 的源码中看到如下代码:

1
2
3
4
5
6
"sideEffects": [
"dist/*",
"es/**/style/*",
"lib/**/style/*",
"*.less"
]

编写 Babel 插件实现自动按需打包

Babel 插件的核心在于对 AST 的解析和操作。它本质上是一个函数,在 Babel 对 AST 语法树进行转换的过程中介入,通过相应的操作,最终让生成结果发生变化。

Babel 内置了几个核心的分析、操作 AST 的工具集。Babel 插件通过“观察者 + 访问者”模式,对 AST 节点统一进行遍历,因此具备了良好的扩展性和灵活性。

重新认识动态导入

MDN 文档中给出了关于动态导入的更具体的使用场景,如下:

  1. 静态导入的模块明显降低了代码的加载速度且被使用的可能性很低,或者不需要马上使用。

  2. 静态导入的模块明显占用了大量系统内存且被使用的可能性很低。

  3. 被导入的模块在加载时并不存在,需要异步获取。

  4. 导入模块的说明符需要动态构建(静态导入只能使用静态说明符)。

  5. 被导入的模块有其他作用(可以理解为模块中直接运行的代码)这些作用只有在触发某些条件时才被需要。

  6. 动态导入只是一个 Function-like 语法形式。在 ES 类的特性中,super()与动态导入类似,也是一个 Function-like 的语法形式。因此,它的函数还是有着本质区别的。

  7. 动态导入并非继承自 Function.prototype,因此不能使用 Fcuntion 构造函数原型上的方法。

  8. 动态导入并非继承自 Object.prototype,因此不能使用 Object 构造函数原型上的方法。

Webpack 赋能代码拆分和按需加载

webpack 环境下支持代码拆分和按需加载,总的来说 webpack 提供了三种能力。

  • 通过入口配置手动分割代码。

  • 动态导入。

  • 通过 splitChunk 插件提取公共代码(公共代码分割)

webpack 对动态导入能力的支持

Webpack 早期提供了 require.ensure()能力。这是 webpack 特有的实现,require.ensure()能够将其参数对应的文件拆分到一个单独的 bundle,这个 bundle 会被异步加载。

require.ensure()已经被符合 ES 规范的动态导入所取代。import()被请求的模块和它所引用的所有子模块会被分割到一个单独的 chunk 中。我们知道,ES 中关于动态导入的规范是只接收一个参数表示模块的路径。

1
import('$(path)') -> Promise

但是 webpack 是一个构建工具,它对 import()的处理是,通过注释接收一些特殊的参数,无须破坏 ES 对动态导入的规定,示例如下:

1
2
3
4
5
import(
/* webpackChunkName: "chunk-name" */
/* webpackMode: "lazy" */
'module'
)

在构建时,webpack 可以读取到 import 参数,即便是参数内的注释部分,它也可以读取并处理。对于动态导入的代码,webpack 会将其转换成自定义的 webpack_require.e 函数。这个函数返回了一个 Promise 数组,最终模拟出了动态导入的效果。

webpack_require.e 主要实现了以下功能:

  • 定义一个数组,名为 Promises,最终以 Promise.all(Promise)形式返回。

  • 通过 installedChunkData,变量判断当前模块是否已经被加载,如果当前模块没有被加载,则先定义一个 Promise 数组,然后创建一个 script 标签,加载模块内容,并定义这个 script 标签的 onload 和 onerror 回调。如果当前模块已经被加载,将模块内容 push 到数组 promises 中。

  • 最终将新增的 script 标签对应的 promise(resolve/reject)处理方法定义在 webpckJsonpCallback 函数中。

Webpack 中的 splitChunk 插件和代码拆分

webpack4.0 版本退出的 splitChunk 插件并不陌生。这里需要注意的是,代码拆分与动态导入并不同,它们本质上是两个概念,动态导入本质上是一种懒加载,只有在需要的时候才加载。而以 splitChunk 插件为代表的代码拆分技术,与代码合并打包是一个互逆的过程。

代码拆分的核心意义在于重复打包及提高缓存利用率,进而提升访问速度。比如我们对不常变化的第三方依赖库进行代码拆分,方便对第三方依赖库缓存,同时抽离公共逻辑,减小单个文件的体积。

Webpack splitChunk 插件模块在满足下述条件时,将自动进行代码拆分。

  • 模块是可以共享的(被重复使用的)或存储于 node_modules 中。

  • 压缩前的体积大于 30kb。

  • 按需加载模块时,并行加载的模块数量不超过 5 个。

  • 页面初始化加载时,并行加载的模块数量不超过 3 个。

另外 webpack splitChunk 插件也支持前面提到的动态导入。