本文目录
[[toc]]
Bundle
目的
- 压缩代码,减小体积
- 文件合并,减少 HTTP 请求数
- 语法转换,将非 js 语法转为 js 语法
主要流程
- 收集依赖
- 生成依赖图
- 构建组装代码
实现
// 模块捆绑器 将 小块代码 编译成 更大和更复杂的代码,可以运行在Web浏览器中.
// 这些小块只是JavaScript文件以及它们之间的依赖关系,而这正是由模块系统表示
// https://webpack.js.org/concepts/modules
// 模块捆绑器具有 入口文件 的这种概念,而不是添加一些
// 脚本标签在浏览器中并让它们运行,我们让 捆绑器 知道
// 哪个文件 是我们应用程序的 主要文件. 该文件能引导
// 我们的整个应用程序.
// 我们的打包程序将从该 入口文件 开始,并尝试理解
// 它依赖于哪些文件. 然后,它会尝试了解哪些文件
// 依赖关系取决于它,它会继续这样做,直到它发现
// 我们应用程序中的 每个模块,以及它们如何 相互依赖.
// 这种对项目的理解被称为`依赖图`.
// 在这个例子中,我们将创建一个 依赖关系图 并将其用于打包
// 它的所有模块都捆绑在一起.
// 让我们开始: )
// > 请注意: 这是一个非常简化的例子
// 对这些例子仅仅执行一次循环依赖,缓存模块导出和解析每个模块
// 其他方面的处理都跳过,使这个例子尽可能简单.
const fs = require('node:fs')
const path = require('node:path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')
let ID = 0
// 我们首先创建一个函数,该函数将接受 文件路径 ,读取内容并提取它的依赖关系.
function createAsset(filename) {
// 以字符串形式读取文件的内容.
const content = fs.readFileSync(filename, 'utf-8')
// 现在我们试图找出这个文件依赖于哪个文件. 我们可以通过查看其内容
// 来获取 `import` 字符串. 然而,这是一个非常笨重的方法,所以我们将使用JavaScript解析器.
// JavaScript解析器是可以读取和理解JavaScript代码的工具.
// 它们生成一个更抽象的模型,称为`ast (抽象语法树)`.
// 我强烈建议你看看[`ast explorer`](https://astexplorer.net) 看看 `ast` 是如何的
// `ast`包含很多关于我们代码的信息. 我们可以查询它了解我们的代码正在尝试做什么.
const ast = babylon.parse(content, {
sourceType: 'module',
})
// 这个数组将保存这个模块依赖的模块的相对路径.
const dependencies = []
// 我们遍历`ast`来试着理解这个模块依赖哪些模块.
// 要做到这一点,我们检查`ast`中的每个 `import` 声明. ❤️
traverse(ast, {
// `Ecmascript`模块相当简单,因为它们是静态的. 这意味着你不能`import`一个变量,
// 或者有条件地`import`另一个模块.
// 每次我们看到`import`声明时,我们都可以将其数值视为`依赖性`.
ImportDeclaration: ({ node }) => {
// 我们将依赖关系数组推入我们导入的值. ⬅️
dependencies.push(node.source.value)
},
})
// 我们还通过递增简单计数器为此模块分配唯一标识符.
const id = ID++
// 我们使用`Ecmascript`模块和其他JavaScript功能,可能不支持所有浏览器.
// 为了确保`我们的bundle`在所有浏览器中运行,
// 我们将使用[babel](https://babeljs.io)来传输它
// 该`presets`选项是一组规则,告诉`babel`如何传输我们的代码.
// 我们用`babel-preset-env``将我们的代码转换为浏览器可以运行的东西.
const { code } = transformFromAst(ast, null, {
presets: ['env'],
})
// 返回有关此模块的所有信息.
return {
id,
filename,
dependencies,
code,
}
}
// 现在我们可以提取单个模块的依赖关系,我们将通过提取`入口文件{entry}`的依赖关系来解决问题.
// 那么,我们将提取它的每一个依赖关系的依赖关系. 循环下去
// 直到我们了解应用程序中的每个模块以及它们如何相互依赖. 这个项目的理解被称为`依赖图`.
function createGraph(entry) {
// 首先解析整个文件.
const mainAsset = createAsset(entry)
// 我们将使用`队列{queue}`来解析每个`资产{asset}`的依赖关系.
// 我们正在定义一个只有 入口资产{entry asset} 的数组.
const queue = [mainAsset]
// 我们使用一个`for ... of`循环遍历 队列.
// 最初 这个队列 只有一个 资产,但是当我们迭代它时,我们会将额外的 新资产 推入 队列 中.
// 这个循环将在 队列 为空时终止.
for (const asset of queue) {
// 我们的每一个 资产 都有它所依赖模块的相对路径列表.
// 我们将重复它们,用我们的`createAsset() `函数解析它们,并跟踪此模块在此对象中的依赖关系.
asset.mapping = {}
// 这是这个模块所在的目录.
const dirname = path.dirname(asset.filename)
// 我们遍历其相关路径的列表
asset.dependencies.forEach((relativePath) => {
// 我们的`createAsset()`函数需要一个绝对文件名.
// 但是该依赖关系数组是保存了相对路径的数组.
// 这些路径是相对于导入他们的文件.
// 我们可以通过将相对路径与父资源目录的路径连接,将相对路径转变为绝对路径.
const absolutePath = path.join(dirname, relativePath)
// 解析资产,读取其内容并提取其依赖关系.
const child = createAsset(absolutePath)
// 了解`asset`依赖取决于`child`这一点对我们来说很重要.
// 通过给`asset.mapping`对象增加一个新的属性(值为child.id)来表达这种一一对应的关系.
asset.mapping[relativePath] = child.id
// 最后,我们将`child`这个资产推入队列,这样它的依赖关系也将被迭代和解析.
queue.push(child)
})
}
// 到这一步,队列 就是一个包含目标应用中 每个模块 的数组:
// 这就是我们的表示图.
return queue
}
// 接下来,我们定义一个函数,它将使用我们的`graph`并返回一个可以在浏览器中运行的包.
// 我们的包将只有一个自我调用函数:
// `(function() {})()`
// 该函数将只接收一个参数: 一个包含`graph`中每个模块信息的对象.
function bundle(graph) {
let modules = ''
// 在我们到达该函数的主体之前,我们将构建一个作为该函数的参数的对象.
// 请注意,我们构建的这个字符串被两个花括号 ({}) 包裹,因此对于每个模块,
// 我们添加一个这种格式的字符串: `key: value,`.
graph.forEach((mod) => {
// 图表中的每个模块在这个对象中都有一个`entry`. 我们使用`模块的id`作为`key`和一个数组作为`value` (用数组因为我们在每个模块中有2个值) .
// 第一个值是用函数包装的每个模块的代码. 这是因为模块应该被 限定范围: 在一个模块中定义变量不会影响 其他模块 或 全局范围.
// 我们的模块在我们将它们`转换{被 babel 转译}`后, 使用`commonjs`模块系统: 他们期望一个`require`, 一个`module`和`exports`对象可用. 那些在浏览器中通常不可用,所以我们将它们实现并将它们注入到函数包装中.
// 对于第二个值,我们用`stringify`解析模块及其依赖之间的关系(也就是上文的asset.mapping). 解析后的对象看起来像这样: `{'./relative/path': 1}`.
// 这是因为我们模块的被转换后会通过相对路径来调用`require()`. 当调用这个函数时,我们应该能够知道依赖图中的哪个模块对应于该模块的相对路径.
modules += `${mod.id}: [
function (require, module, exports) { ${mod.code} },
${JSON.stringify(mod.mapping)},
],`
})
// 最后,我们实现自调函数的主体.
// 我们首先创建一个`require()`⏰函数: 它接受一个 `模块ID` 并在我们之前构建的`模块`对象查找它.
// 通过解构`const [fn, mapping] = modules[id]`来获得我们的包装函数 和`mappings`对象.
// 我们模块的代码通过相对路径而不是模块ID调用`require()`.
// 但我们的`require`🌟函数接收 `模块ID`. 另外,两个模块可能`require()`相同的相对路径,但意味着两个不同的模块.
// 要处理这个问题,当需要一个模块时,我们创建一个新的,专用的`require`函数供它使用.
// 它将是特定的,并将知道通过使用`模块的mapping对象`将 `其相对路径` 转换为`ID`.
// 该mapping对象恰好是该特定模块的`相对路径和模块ID`之间的映射.
// 最后,使用`commonjs`,当模块需要被导出时,它可以通过改变exports对象来暴露模块的值.
// require函数最后会返回exports对象.
const result = `
(function(modules) {
function require(id) { //🌟
const [fn, mapping] = modules[id];
function localRequire(name) { //⏰
return require(mapping[name]); //🌟
}
const module = { exports: {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`
// 我们只需返回结果,欢呼!:)
return result
}
const graph = createGraph('./example/entry.js')
const result = bundle(graph)
console.log(result)tree-shaking
支持 tree-shaking 的模块
- 一般只支持 ESM 模块, CJS 模块需要使用额外工具处理,并且效果不如 ESM 模块
- 使用单个导入的形式,不管是
import * as A还是import { A }都可以,但是import A则不行 package.json中没有定义sideEffects或者其值不为true
Babel 7 默认会将代码转为 cjs ,导致 esm 模块也不能正常的 tree-shaking
热更新
---
title: 热更新原理
---
graph TB
Editor[文本编辑器/IDE]
File[文件系统]
subgraph DevServer[Webpack Dev Server]
Compiler[Webpack Compiler]
HMR[HMR Server]
Bundle[Bundle Server]
end
subgraph Browser[浏览器]
subgraph BundleJS[bundle.js]
HMRRuntime[HMR Runtime]
JS[构建后的 JS 代码]
end
end
Editor -->|保存修改| File
File -->|触发构建| Compiler
Compiler -->|通知热更新服务器哪些资源改变| HMR
Compiler -->|传输构建后的 Chunk| Bundle
Bundle -->|通过 WS 让本地 chunk 可以被浏览器访问| BundleJS
HMRRuntime -->|将本地构建后的 chunk 输出到浏览器| JS文件指纹
JS 文件指纹
hash: 整个项目唯一,再次构建后会更新chunkhash: 按照entry生成hash,entry构建后的其中一个chunk更新,值就会改变contenthash: 每个chunk独立,只要当前chunk内容没有变更,就不会变化
file-loader 文件指纹
hash: 整个项目唯一,再次构建后会更新contenthash: 每个chunk独立,只要当前chunk内容没有变更,就不会变化
Webpack 构建产物分析
整体结构
webpack 会对转换后的模块产物包裹一层闭包函数,格式如下:
// import 、 require 等会被替换为 __webpack_require__ 函数
// export 、 exports 会统一为给 exports 添加字段
function(__unused_webpack_module, exports, __webpack_require__) {
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval('文件转换后的 JS 代码')
}项目中的模块会记录到 __webpack_modules__ 中,如果已经执行了会缓存 exports 到 __webpack_module_cache__ 对象中
/** key 为模块 ID ,一般为文件相对根目录的路径,或者是 npm 包名 */
var __webpack_modules__ = {
// 入口文件
// 生产模式下, key 会是文件序号,第几个被访问的,所以入口文件一般是 0
"./src/entry.js": ((__unused_webpack_module, exports, __webpack_require__) => {
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval("执行入口文件代码")
})
}__webpack_require__ 函数负责加载模块,其代码如下:
function __webpack_require__(moduleId) {
// 如果模块已经执行过了,就不再执行,直接返回缓存好的模块 exports
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports
}
// 如果模块没有执行,先初始化模块缓存,默认 exports 为空对象
var module = __webpack_module_cache__[moduleId] = {
exports: {}
}
// 执行模块,同时也会收集到模块的导出
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
// 返回模块 export 的内容
return module.exports
}同步 chunk
同步 chunk 会直接内联到 __webpack_modules__ 中,如:
/** key 为模块 ID ,一般为文件相对根目录的路径,或者是 npm 包名 */
var __webpack_modules__ = {
// 模块按照引用顺序依次排列,入口在最底部
// 对部分变量适当重命名,避免变量名冲突
// 生产模式下, key 会是文件序号,第几个被访问的
"./src/a.js": ((__unused_webpack_module, exports, __webpack_require__) => {
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval("文件转换后的 a.js 代码")
}),
// 生产模式下, key 会是文件序号,第几个被访问的
"./src/b.js": ((__unused_webpack_module, exports, __webpack_require__) => {
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval("文件转换后的 b.js 代码")
}),
// 生产模式下, key 会是文件序号,第几个被访问的
"./src/c.mjs": ((__unused_webpack_module, exports, __webpack_require__) => {
// ESM 会开启严格模式
"use strict";
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval("文件转换后的 c.mjs 代码")
}),
// 其他模块
// ...
// 入口文件
// 生产模式下, key 会是文件序号,第几个被访问的,所以入口文件一般是 0
"./src/entry.js": ((__unused_webpack_module, exports, __webpack_require__) => {
// 在生产模式下,这里的 eval 会被替换为实际的文件内容,而不是通过 eval 执行
eval("执行入口文件代码")
})
}异步 chunk
异步 chunk 不会直接内联到 __webpack_modules__ 中,而是通过 JSONP 方式动态加载。 webpack 会为异步 chunk 生成单独的文件,并在需要时通过 script 标签加载。
// 异步 chunk 的加载机制
// 1. 定义全局的 chunk 加载函数
var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
// 2. 定义 JSONP 回调函数,用于处理加载的 chunk
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data
// 将新模块添加到 __webpack_modules__ 中
for (var moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId]
}
}
// 执行 runtime 代码
if (runtime) {
var result = runtime(__webpack_require__)
}
// 标记 chunk 为已加载
for (var i = 0; i < chunkIds.length; i++) {
var chunkId = chunkIds[i]
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]()
}
installedChunks[chunkId] = 0
}
return __webpack_require__.O(result)
}
// 3. 重写 chunkLoadingGlobal.push 方法,使其能够处理新加载的 chunk
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))当使用 import() 或 require.ensure() 等动态导入语法时,webpack 会生成类似以下的代码:
// 入口文件中的动态导入
__webpack_require__.e(/* chunkId */ 889).then(__webpack_require__.bind(__webpack_require__, 889)).then((module) => {
// 使用异步加载的模块
console.log(module.default)
});
// 异步 chunk 文件 (chunk-889.js)
(self["webpackChunk"] = self["webpackChunk"] || []).push([[889], {
// 模块定义
"./src/async-module.js": (__unused_webpack_module, exports, __webpack_require__) => {
"use strict"
eval("异步模块的代码")
}
}])异步 chunk 的主要特点:
- 按需加载:只有在需要时才会加载,减少初始加载时间
- 独立文件:每个异步 chunk 生成独立的 JS 文件
- JSONP 加载:通过 JSONP 方式动态加载,避免阻塞主线程
- 缓存机制:已加载的 chunk 会被缓存,避免重复加载
webpack 运行时函数
webpack 在打包过程中会注入一些运行时函数,用于支持模块加载和执行。以下是两个重要的运行时函数:
__webpack_require__.o 函数
__webpack_require__.o 是一个用于检查对象是否拥有特定属性的辅助函数,它是对 Object.prototype.hasOwnProperty.call 的封装:
/* webpack/runtime/hasOwnProperty shorthand */
(() => {
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})()这个函数在 webpack 内部被广泛使用,主要用于:
- 检查模块是否已经加载
- 检查对象是否包含特定属性
- 在模块合并和依赖解析过程中判断属性是否存在
例如,在异步 chunk 加载时,会使用这个函数检查模块是否已经存在于 __webpack_modules__ 中:
for (var moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId]
}
}__webpack_require__.e 函数
__webpack_require__.e 是用于加载异步 chunk 的核心函数,它负责创建 script 标签并处理加载过程:
/* webpack/runtime/ensure chunk */
(() => {
__webpack_require__.f = {}
// 这个对象存储了已经加载的 chunk
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises)
return promises
}, []))
}
})()当使用动态导入(如 import())时,webpack 会生成类似以下的代码:
// 入口文件中的动态导入
__webpack_require__.e(/* chunkId */ 889).then(__webpack_require__.bind(__webpack_require__, 889)).then((module) => {
// 使用异步加载的模块
console.log(module.default)
})__webpack_require__.e 函数的主要功能:
- 检查 chunk 是否已经加载
- 如果未加载,创建 script 标签加载对应的 chunk 文件
- 返回一个 Promise,当 chunk 加载完成后 resolve
- 处理加载失败的情况,提供错误处理机制
这两个函数是 webpack 运行时环境的重要组成部分,它们共同支持了 webpack 的模块加载机制,特别是异步 chunk 的按需加载功能。
__webpack_require__.f 函数
__webpack_require__.f 是一个对象,它包含了 webpack 用于加载异步 chunk 的不同策略。在 webpack 5 中,它主要包含以下几种加载方式:
/* webpack/runtime/ensure chunk */
(() => {
__webpack_require__.f = {}
// 1. 通过 script 标签加载
__webpack_require__.f.j = (chunkId, promises) => {
// 检查 chunk 是否已加载
if (!__webpack_require__.o(installedChunks, chunkId) || installedChunks[chunkId] === undefined) {
// 创建一个 Promise 并存储到 installedChunks 中
var promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject]
})
promises.push(promise)
// 创建 script 标签加载 chunk
var script = document.createElement('script')
script.src = __webpack_require__.p + __webpack_require__.u(chunkId)
script.onerror = () => {
reject(new Error('Chunk load failed for chunk: ' + chunkId))
}
document.head.appendChild(script)
} else {
promises.push(installedChunks[chunkId])
}
}
// 2. 通过 import() 加载 (ESM)
__webpack_require__.f.d = (exports, definition) => {
for (var key in definition) {
if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] })
}
}
}
// 3. 通过 require.ensure 加载 (CommonJS)
__webpack_require__.f.require = (chunkId, promises) => {
// 类似 script 标签加载,但使用 require.ensure 语法
}
})()__webpack_require__.f 对象的主要作用:
- 提供多种加载策略:支持 script 标签、import() 和 require.ensure 等多种加载方式
- 统一加载接口:通过
__webpack_require__.e函数统一调用这些加载策略 - 处理加载状态:跟踪每个 chunk 的加载状态,确保只加载一次
- 错误处理:提供加载失败时的错误处理机制
在 webpack 5 中,默认使用 __webpack_require__.f.j 策略,即通过 script 标签加载异步 chunk。这种方式的优点是:
- 兼容性好,支持所有浏览器
- 可以并行加载多个 chunk
- 支持缓存和预加载
- 可以处理加载失败的情况
__webpack_require__.f 与 __webpack_require__.e 和 __webpack_require__.o 一起,构成了 webpack 异步加载的核心机制,使得 webpack 能够支持代码分割和按需加载。
__webpack_require__.O 函数
__webpack_require__.O 是 webpack 运行时中的一个辅助函数,用于处理异步加载完成后的回调:
/* webpack/runtime/ensure chunk */
(() => {
__webpack_require__.O = (result, fn) => (fn ? fn() : result)
})()这个函数的主要作用是:
- 简化异步加载后的处理逻辑:当异步 chunk 加载完成后,提供一个简洁的方式来处理结果
- 支持可选的回调函数:如果提供了回调函数,则执行回调;否则直接返回结果
- 统一处理异步加载结果:在 webpack 的异步加载机制中,用于统一处理加载完成后的逻辑
在 webpack 的异步加载代码中,通常会看到这样的使用方式:
// 异步加载完成后,通过 __webpack_require__.O 处理结果
__webpack_require__.e(/* chunkId */ 889)
.then(__webpack_require__.bind(__webpack_require__, 889))
.then((module) => {
// 使用异步加载的模块
console.log(module.default)
})
.then(__webpack_require__.O) // 使用 __webpack_require__.O 处理结果__webpack_require__.O 函数的设计体现了 webpack 运行时函数的简洁性和灵活性,它通过一个简单的函数封装,实现了对异步加载结果的处理,使得 webpack 的异步加载机制更加完整和健壮。
异步任务队列
在 webpack 构建产物中,除了上述运行时函数外,还有一个重要的机制用于维护异步任务,这就是异步任务队列(queue)。这个队列主要用于处理异步加载的模块和资源。
/* webpack/runtime/ensure chunk */
(() => {
// 初始化异步任务队列
var installedChunks = {
// 主包(入口文件)的 chunkId 为 0
0: 0
}
// 异步任务队列,用于存储异步加载的模块
var deferredModules = []
// 异步加载函数
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises)
return promises
}, []))
}
// 处理异步任务队列的函数
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data
// 将新模块添加到 __webpack_modules__ 中
for (var moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId]
}
}
// 执行 runtime 代码
if (runtime) {
var result = runtime(__webpack_require__)
}
// 标记 chunk 为已加载
for (var i = 0; i < chunkIds.length; i++) {
var chunkId = chunkIds[i]
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]()
}
installedChunks[chunkId] = 0
}
// 处理延迟加载的模块
if (deferredModules.length) {
deferredModules.forEach(([moduleId, chunkId]) => {
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId] === 0) {
__webpack_require__(moduleId)
}
})
deferredModules = []
}
return __webpack_require__.O(result)
}
// 重写 chunkLoadingGlobal.push 方法
var chunkLoadingGlobal = self.webpackChunk = self.webpackChunk || []
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))
})()异步任务队列的主要特点和作用:
延迟加载模块管理:
deferredModules数组用于存储需要延迟加载的模块信息,包括模块 ID 和对应的 chunk ID。异步加载状态跟踪:
installedChunks对象用于跟踪每个 chunk 的加载状态,确保每个 chunk 只被加载一次。异步加载完成处理:当异步 chunk 加载完成后,
webpackJsonpCallback函数会处理加载结果,并将延迟加载的模块添加到__webpack_modules__中。延迟模块执行:在异步 chunk 加载完成后,会检查
deferredModules中是否有需要执行的模块,如果有则执行它们。防止重复加载:通过
installedChunks对象跟踪已加载的 chunk,避免重复加载相同的 chunk。
这种异步任务队列机制使得 webpack 能够高效地处理异步加载的模块,实现按需加载和代码分割,同时保证模块的正确加载顺序和依赖关系。
webpack 构建处理
捕获错误
可以通过 plugin 监听 done 周期获取错误信息,自行处理上报逻辑
class ErrorReportPlugin {
constructor(options) {
this.reportUrl = options.url // 上报的目标 URL
}
apply(compiler) {
compiler.hooks.done.tap('ErrorReportPlugin', (stats) => {
if (stats.hasErrors()) {
const errors = stats.toJson().errors
const formattedErrors = errors.map(error => ({
message: error.message,
module: error.moduleName,
stack: error.stack,
timestamp: new Date().toISOString(),
}))
this.sendErrorsToServer(formattedErrors)
}
})
}
async sendErrorsToServer(errors) {
try {
await fetch(this.reportUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errors }),
})
} catch (e) {
console.error('上报失败:', e)
}
}
}
// 在 Webpack 配置中使用插件
module.exports = {
plugins: [
new ErrorReportPlugin({
url: 'https://your-error-report-api.com/collect',
}),
],
}在自定义构建工具中调用 webpack 时,也可以通过 webpack 函数回调获取错误信息,不需要额外增加 plugin
const webpack = require('webpack')
// 导入 webpack 构建的配置
const config = {}
webpack(config, (err, stats) => {
if (err) {
// 存在构建错误
process.exit(-1)
}
})冒烟测试
有些时候构建可能没有报错,但是生成的产物会有问题,可以根据简单的脚本对产物进行检测,常见的检测手段有:
- 判断是否在指定路径下输出了指定名称的 html 文件
- 判断指定 assets 目录中是否生成了 js 、 css 等静态资源文件
- 起一个本地 preview 服务,访问 url ,查看页面是否能正常展示,并且控制台无报错
webpack 构建性能优化
构建性能本质上就是执行性能,在不考虑替换构建工具(比如使用 rsbuild )的情况下,归根结底就是想办法优化 NodeJS 的执行性能。
分析 loader/plugin 耗时
使用 speed-measure-webpack-plugin 统计时间,会在终端输出各个 loader 、 plugin 耗时时间
const SpeedMeasureWebpackPlugin = require('speed-measure-webpack-plugin')
const smw = new SpeedMeasureWebpackPlugin()
module.exports = smw().wrap({
entry: './src/entry.js',
})分析 chunk 产物
分析产物可以帮助我们查找是否有依赖库被重复构建的情况,在处理需要 babel 转化的包的情况下适用,主要用于产物体积分析,也就是加载性能优化。
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
entry: './src/entry.js',
plugins: [
new BundleAnalyzerPlugin()
],
}升级 NodeJS
升级 NodeJS 可以有效提高 js 脚本的运行速度, NodeJS 底层也在随着版本迭代不断优化性能。
在 NodeJS 22 版本支持开启代码缓存,进一步提高运行速度
更换耗时算法
比如将 md5 算法更换为 md4 ,也可以有效提高性能, md4 比 md5 快了 7.69% 。这种优化也可以应用到网络加载性能上,比如使用超椭圆曲线加密算法的 ECC 证书替换使用 RSA 算法加密 PKCS 证书。
多线程构建
loader 处理
老代码中会使用 HappyPack 插件进行多线程构建。
HappyPack 的运行原理是创建一个线程池,所有的任务交给线程池,线程池将不同的任务分配给不同的线程,子线程处理完成后,将结果返回给主线程。
const HappyPack = require('happypack')
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve('src'),
use: [
// 如果有多个模块需要处理,可以修改为 'happypack/loader?id=xxx'
'happypack/loader',
],
},
],
},
plugins: [
new HappyPack({
// // 设置 loader 对应的 plugin ,默认 id 为 1
// id: 'xxx',
// 配置线程数,默认为 3
threads: 4,
// 使用哪个 loader 处理
loader: ['·babel-loader`],`
})·``
],
}现在 HappyPack 基本不维护了,可以转用官方提供的 thread-loader 替代。
thread-loader 原理与 HappyPack 相似,每次解析模块的时候,将模块已经模块依赖分配给 worker 线程处理。
const path = require('path')
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve('src'),
use: [
// thread-loader 需要放到最前面,最后加载
// 这样才能在 loader 初始化的时候,拦截其他的 loader ,用于自身处理,避免冗余配置
{
loader: 'thread-loader',
options: {
// 配置线程数,默认为 2
worker: 3,
},
},
'babel-loader',
],
},
],
},
}代码压缩
uglifyJS
使用 parallel-uglify-plugin / uglifyjs-webpack-plugin 插件,可以并行压缩代码,底层压缩使用的是 uglifyJS ,所以无法支持 ES6 代码的压缩
const ParallelUglifyPlugin = require('parallel-uglify-plugin')
const UglifyjsPlugin = require('uglifyjs-webpack-plugin')
module.exports = {
plugins: [
// parallel-uglify-plugin 压缩更快,但是体积以及功能丰富性比不上 uglifyjs-webpack-plugin
new ParallelUglifyPlugin({
// uglify 配置
uglifyJS: {
ouput: {
beautify: false,
comments: false,
},
compress: {
warnings: false,
drop_console: true,
collapse_vars: true,
reduce_vars: true,
},
},
}),
// UglifyJS 的插件,支持开启并行压缩,产物体积比 `parallel-uglify-plugin` 较小,功能更丰富
new UglifyjsPlugin({
uglifyOptions: {
warnings: false,
parse: {},
compress: {},
mangle: true,
output: null,
toplevel: false,
nameCache: null,
ie8: false,
keep_fnames: false,
},
parallel: true,
}),
],
}terser
webpack 4 默认使用 terser 压缩代码,支持 ES6 代码的压缩,可以通过 terser-webpack-plugin 开启并行压缩
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: 4,
})
],
},
}分包
通过将部分不容易变化的代码 external ,减少需要处理的模块,可以有效提升构建速度。
转 cdn
通过 html-webpack-externals-plugin 可以将依赖包转为从 cdn 获取,在运行时会是以 UMD 格式加载的包。
如果加载的依赖包很多的话,请求容易被阻塞,适合少量包的 external 。
const HtmlExternalsPlugin = require('html-webpack-externals-plugin')
module.exports = {
plugins: [
new HtmlExternalsPlugin({
externals: [
{
// 包名
module: 'vue',
// 转为哪个 cdn 加载
entry: '//cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js',
// 挂载到 Window 时属性名叫什么
global: 'Vue',
},
],
})
],
}转 DLL
通过 DLL 技术将不太频繁变化的依赖库构建为 DLL 静态文件,不需要每次构建都处理该依赖包。
DLL 技术支持合并包,将多个关联的包合并到同一个 chunk 中。
// webpack.dll.config.js
const webpack = require('webpack')
module.exports = {
// 本质上 DLL 就是一个构建后的包,所以需要在 entry 中添加对应的包的构建范围
// 配置多个入口即对应多个 dll
entry: {
// 基础框架包
library: [
'vue',
'vue-router',
'pinia',
],
// 图表包
chart: [
'echart',
],
},
output: {
// 配置依赖库的入口文件名,可以通过 hash 让浏览器缓存失效
filename: '[name]_[chunkhash].dll.js',
path: './build/library',
library: '[name]',
},
plugins: [
new webpack.DllPlugin({
name: '[name]_[hash]',
// 输出为 manifest.json 格式,作为包的入口
// 内部包含包的描述信息,比如有哪些包、对应的入口等、
path: './build/library/[name].json',
}),
],
}在实际项目中,通过以下代码引用 DLL
// webpack.prod.config.js
const webpack = require('webpack')
module.exports = {
plugins: [
// 引入之前构建的 library 入口
new webpack.DllReferencePlugin({
manifest: require('./build/library/library.json')
}),
// 引入之前构建的 chart 入口
new webpack.DllReferencePlugin({
manifest: require('./build/library/chart.json')
}),
],
}构建缓存
目的: 提升二次构建速度
缓存 babel-loader
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
// 通过 query 参数就可以传参,指定开启缓存
use: 'babel-loader?cacheDirectory=true',
},
],
},
},缓存 terser-webpack-plugin
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: 4,
// 在这里直接开启即可
cache: true,
})
],
},
}缓存模块解析结果
模块解析在构建中频繁使用,需要将速记的模块名映射到具体的磁盘绝对路径,而且支持缺省、上级目录递归等等功能,整体耗时也比较久。
const HardSourcePlugin = require('hard-source-webpack-plugin')
module.exports = {
plugins: [
new HardSourcePlugin()
],
}cache-loader
cache-loader 可以缓存 loader 的处理结果,用于处理本身未提供 cache 功能的 loader
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', 'babel-loader'],
},
],
},
}webpack.cache
webpack5 中废弃了 cache-loader ,直接内置了 cache 配置,让 webpack 自行决定缓存目标,不需要手动配置。
module.exports = {
// 等价于 `cache: { type: 'memory' }` ,会被缓存在内存中
// 可以设置为 `cache: { type: 'filesystem' }` 缓存到磁盘中
cache: true,
}缩小构建目标
目的: 尽可能的少处理模块
比如默认排除掉 node_modules 内的包
排除第三方包的处理
module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
// node_modules 中的包不进行 babel 处理
exclude: 'node_modules',
},
],
},
}减少包检索层级
const path = require('path')
module.exports = {
resolve: {
// 指定在此目录中查找,不再继续往上查
modules: [
path.resolve(__dirname, 'node_modules')
],
},
}指定包的 mainFields
module.exports = {
resolve: {
// 读取第三方包时,只需要识别 main 字段即可,不需要处理 exports 、 require 等条件判断逻辑
mainFields: 'main',
},
}减少 extensions
module.exports = {
resolve: {
// 缺省后缀时,只尝试匹配 .js 文件,不匹配其他后缀
extensions: ['.js'],
},
}alias
const path = require('path')
module.exports = {
resolve: {
alias: {
// 直接使用生产环境的最小包,同时节省模块的查找时间
vue: path.resolve(__dirname, './node_modules/vue/dist/vue.esm-browser.prod.js'),
},
},
}资源体积优化
图片
转换格式
图片占用的资源非常大,尤其是 png 或者是嵌套图片的 svg ( 比如 svg 中引用了其他的 png 图片的 base64 代码 ) ,可以通过转换图片格式,比如转为 webp 、 avif 、 jpeg
或者通过 BFF 提供获取不同图片尺寸的 API ,通过预先加载低像素图片的形式处理。
压缩
如果实在无法转换,或者转换后体积依旧很大,可以使用图片压缩的技术,将图片进行无损/低损压缩。
const ImageLoader = require('image-webpack-loader')
module.exports = {
module: {
rules: [
{
test: /\.(png|svg|jpe?g|gif|webp)$/,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
},
// optipng 是 png 的压缩,这里使用 pngquant 而不是 optipng
optipng: {
enabled: false,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false,
},
webp: {
quality: 75
}
},
},
],
},
],
},
}CSS tree-shaking
PurifyCSS
实现原理: 遍历代码,查找用到的 CSS
需要使用 mini-css-extract-plugin + purgecss-webpack-plugin
const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')
const PATHS = {
src: path.join(__dirname, 'src'),
}
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
optimization: {
splitChunks: {
cacheGroups: {
styles: {
name: 'styles',
test: /\.css$/,
chunks: 'all',
enforce: true,
},
},
},
},
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
}),
new PurgeCSSPlugin({
// 必须绝对路径,支持数组类型的值
paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
}),
],
}uncss
实现原理: 通过无头浏览器打开页面,用 document.querySelector 识别哪些样式使用了。
uncss 与 SPA 页面较难适配,所以一般很少用,只有在优化首屏场景,移除首屏无用的 css 的时候会用,这时候一般是作为 postcss 插件去处理: postcss([ require('uncss').postcssPlugin ])
动态 polyfill
babel 转义代码
通过 babel 设定最小支持版本,将 polyfill 硬编码到产物中,适用于局域网用户,比如银行、金融等。
- 优点: 使用简单
- 缺点:
- 升级最低支持版本的时候,每次都需要重新构建
- 用户浏览器较高的时候也需要负担 polyfill 开销,包括加载、解析等
polyfill-service
根据用户的浏览器版本去打垫片,减少无效代码的浪费。
浏览器的版本一般通过 UA 获取,通过 polyfill-service 即可处理
常见的 polyfill-service 提供商有 polyfill.io ,但是不建议使用他们提供的服务,里面检查出含有恶意内容 推荐自行部署该服务
或者使用 https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?version=4.8.0 / https://polyfill-fastly.io/v3/polyfill.min.js
如果用户通过修改 UA 或者浏览器魔改的形式,导致加载 polyfill 依旧报错,可以捕获全局错误,再动态添加全量 polyfill 的 script 进行兜底。
webpack 启动过程
webpack 命令
webpack 命令触发时,会寻找 webpack-cli / webpack-command ,如果没有安装会提供自动安装能力,并且最终只会使用两个包的其中一个。
webpack-cli
webpack-cli 主要是收集命令行参数,将参数转换为 webpack 配置,并调用 webpack 进行打包。
部分命令可能不需要进行打包,所以 webpack-cli 又内置了 NON_COMPILATION_ARGS ,在数组中包含了不需要触发 webpack 构建的命令,比如 init / info 等。
在合并参数时,命令行指定的参数优先级高于 webpack.config.js 中的配置。
合并完成后,通过 compiler = webpack(options) 创建编译器。
根据 serve / build 决定是进行 compiler.watch() 还是 compiler.run() 。
tapable
webpack hook 基本都是交由 tapable 库来管理,核心的 compiler 与 compilation 都是继承自 tapable 。
tapable 是一个类似 EventEmitter 的库,主要控制 hook 的发布订阅
可以说,整个 webpack 就是基于事件流的编程范式,在不同的过程中触发不同的 hook ,等到 hook 执行完成继续往下执行主流程。
支持的 hook
同步 hook
SyncHook: 同步钩子。SyncBailHook: 同步熔断钩子。遇到 return 会直接返回,阻止后续其他未处理的回调。SyncWaterfallHook: 同步流水钩子。将 hook 处理结果传递给下一个 hook 。SyncLoopHook: 同步循环钩子。监听函数返回 true 表示继续循环,返回 undefined 表示结束循环
异步 hook
AsyncParalleHook: 异步并发钩子。AsyncParalleBailHook: 异步并发熔断钩子。AsyncSeriesHook: 异步串行钩子。AsyncSeriesBailHook: 异步串行熔断钩子。AsyncSeriesWaterfallHook: 异步串行流水钩子。
绑定 hook 的方法
| 操作 | 异步钩子 | 同步钩子 |
|---|---|---|
| 绑定 | tapAsync / tapPromise / tap | tap |
| 执行 | callAsync / promise | call |
webpack plugin
webpack plugin 可以视为动态添加 hook 监听的操作,触发 hook 的时机与参数是由 webpack 内置了。
整个 plugin 体系就是以策略模式为核心的一整套动态更新策略的机制。
webpack 构建流程
以 hook 调用顺序来看,基本流程如下:
---
title: webpack 主要 hook 调用顺序
---
graph TB
entry["entry-option <br>( 初始化 option )"]
run["run <br>( 开始编译 )"]
make["make <br>( 从 entry 开始递归分析依赖,对每个依赖模块 build )"]
beforeResolve["before-resolve <br>( 解析模块对应的路径 )"]
buildModule["build-module <br>( 开始构建某个模块 )"]
normalModuleLoader["normal-module-loader <br>( 将 loader 加载完成的 module 编译为 AST )"]
program["program <br>( 遍历 AST 收集依赖 )"]
seal["seal <br>( 构建完成,开始优化代码 )"]
emit["emit <br>( 输出构建产物到文件系统 )"]
entry --> run
run --> make
make --> beforeResolve
beforeResolve --> buildModule
buildModule --> normalModuleLoader
normalModuleLoader --> program
program --> seal
seal --> emitloader
定义: loader 只是一个函数,输入文件内容,输出 JS 代码
loader 主要目的是将 webpack 无法理解的(非 JS / JSON )文件解析为 webpack 能够识别的模块,将所有资源统一为同一模块标准,有助于 webpack 统一处理与扩展新文件类型支持。
loader 总是 从右到左 被调用。有些情况下, loader 只关心 request 后面的 元数据 ( metadata ) ,并且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 之前,会先 从左到右调用 loader 上的 pitch 方法。
loader-runner
loader-runner 可以在不安装 webpack 的情况下运行 loader ,可以方便的对 loader 进行开发、调试。
import { runLoaders } from "loader-runner"
runLoaders(
{
// 需要处理哪个文件,必须使用绝对路径,可以包含 query 参数
resource: "/abs/path/to/file.txt?query",
// 需要调用的 loader , 支持的值跟 webpack 中 module.rules.use 一样
// 如果是本地 loader 需要使用绝对路径
loaders: ["/abs/path/to/loader.js?query"],
// 声明只使用基础的 context
context: { minimize: true },
// loader 的处理结果会通过该函数回调,可以用于输出、测试等
// 函数签名为 `function(context, path, function(err, buffer))`
processResource: (loaderContext, resourcePath, callback) => { ... },
// 怎么读取文件,可以修改读文件操作,进行拦截、测试等
// 函数签名为 `function(path, function(err, buffer))`
readResource: fs.readFile.bind(fs)
},
/**
* @param {Error | undefined} err
* @param {Object} result
* @param {Buffer | string} result.result 没有 err 才有 result
* @param {Buffer} result.resourceBuffer 原始 Buffer 数据
* @param {boolean} result.cacheable 是否可缓存
* @param {string[]} result.fileDependencies 依赖的文件
* @param {string[]} result.missingDependencies 缺失的文件
* @param {string[]} result.contextDependencies 上下文依赖的文件
* @returns {void}
*/
function (err, result) {
},
)创建 loader
创建: 使用 webpack 提供的 webpack generate-loader 创建或者自行创建对应文件均可。
一般借助 loader-utils 包可以方便的对 loader 进行操作,比如获取参数
同步 loader:
const LoaderUtils = require('loader-utils')
module.exports = function (source) {
// 这里可以获取 loader 参数
const { name: _name } = LoaderUtils.getOptions(this)
// 异常处理有两种方式:
// 1. 直接抛出异常
throw new Error()
// 2. 通过 callback 返回
this.callback(
/* err: Error | null */ null,
/* content: string | Buffer */ source,
/* sourceMap?: SourceMap */
/* meta?: any */
)
// 同步 loader 可以直接返回 source
// 如果需要回传多个值的时候,需要使用 callback 返回
// 所以推荐统一使用 callback 处理
// return source
}异步 loader:
module.exports = async function (source) {
// 异步 loader 需要获取对应的 callback
const callback = this.async()
await new Promise((resolve) => {
resolve(true)
})
callback(
/* err: Error | null */ null,
/* content: string | Buffer */ source,
)
}loader 缓存
默认 webpack 会对 loader 进行缓存,如果 loader 有依赖的话是无法缓存的。
手动关闭缓存可以在 loader 内通过 this.cacheable(false) 关闭
输出文件
loader 可以通过 this.emitFile 直接输出额外的文件,这也是 file-loader 等 loader 能够输出文件的原因
const LoaderUtils = require('loader-utils')
module.exports = function (content) {
// 获取输出文件的相对路径
const url = LoaderUtils.interpolateName(this, '[hash].[ext]', { content })
// 输出到文件
this.emitFile(url, content)
// 文件内容修改为对应模块的路径
// __webpack_public_path__ 是全局环境变量,会被替换为 output path
const path = `__webpack_public_path__ + ${JSON.stringify(url)}`
return `export default ${path}`
}plugin
plugin 必须在 webpack 中运行,不能像 loader 一样单独运行
创建 plugin
创建: 使用 webpack 提供的 webpack generate-plugin 创建或者自行创建对应文件均可。
const { RawSource } = require('webpack-sources')
module.exports = class {
constructor(options) {
// 这里可以进行参数校验,如果校验失败了,直接抛出异常即可
throw new Error('invalid options')
this.options = options
}
// 插件被调用时的入口
apply(compiler) {
compiler.plugin('emit', (compilation, cb) => {
// 如果调用阶段发生异常,一般通过 compilation 记录
compilation.warnings.push('警告信息')
compilation.errors.push('错误信息')
// 输出文件,这里只输出了原始文件,还有其他的比如带 sourcemap 的文件,具体可以看 [webpack-sources](https://github.com/webpack/webpack-sources?tab=readme-ov-file#rawsource)
compilation.assets['filename'] = new RawSource('file content')
cb()
})
}
}plugin 的 plugin
由于前面提到的 webpack 核心都是基于事件,如果某个插件也有注册事件的话,也是可以基于这个事件,对 plugin 进行 plugin 开发的,比如 html-webpack-plugin 提供了很多的 hook 。
通过对这些 hook 的监听,就可以增强 plugin 了。
创建插件
准备阶段
准备阶段会 初始化 Compiler
compiler.run
Compiler 也是继承 Tapable 的,自身也存在 hook 机制。
compiler.run 会依次触发 beforeRun hook -> run hook -> compiler 函数
entry 处理
在触发 entry-option hook 的时候,会触发 EntryOptionPlugin 回调,对 entry 配置进行检查,如果是函数则调用 DynamicEntryPlugin 注册 entry ,否则使用 EntryPlugin 注册。
完整代码可以查看 lib/EntryOptionPlugin.js
参数处理
监听 beforeRun hook。
将每个 options 参数转为 webpack 内置插件,以及按照运行环境注册内置的插件,比如说:
output.library:LibraryTemplatePluginexternals:ExternalsPlugindevtool:EvalDevtoolModulePlugin/SourceMapDevToolPluginAMDPlugin/CommonJsPluginRemoveEmptyChunksPlugin
完整代码可以查看 lib/WebpackOptionsApply.js
初始化 compilation
每次编译都会初始化一个 compilation ,在 watch 的时候每次修改代码同样也会重新初始化 compilation
初始化的时机为 compiler.compile 被调用,也就是在 run hook 触发之后
构建阶段
compiler.compile 依次触发 beforeCompile -> compile -> make -> finishMake
Module
webpack 将所有的模块,抽象成了 Module ,具体差异如下:
NormalModule: 常规资源文件,如 JS 、 CSS 、 图片等,通过 loader 读取。ContextModule: 处理动态 reuqire 语句,比如require.context()加载的模块ExternalModule: 外部依赖,比如 CDN 引入的库,该类不继承Module,因为不需要进行构建DelegateModule: 处理代理模块,比如 DLL 加载RawModule: 将文件内容直接作为导入内容使用。该类不继承Module,因为不需要进行构建
make hook
主要构建都在 make 阶段,以单入口构建为例:
单入口构建会转换为 SingleEntryPlugin , SingleEntryPlugin 也就是 EntryPlugin ,在 代码 中可以看到,该 plugin 会在触发后调用 compilation.addEntry ,开始正式构建。
构建过程中会按照不同的 Module 调用其 build 函数执行构建。
- 实例化
loaderContext,通过runLoader读取文件信息 - 读取完模块信息后,通过
parser.parse()解析为 AST ,如果是 JS 模块用 acron , 如果是 WASM 模块用@webassemblyjs/wasm-parser - 所有的模块解析后会存放到
compilation.modules中,触发finishModules
webpack 4 用的是 successModules 而不是 finishModules
chunk 生成
默认生成 chunk 是按照如下流程的,后续产物的 chunk 是 seal 之后的结果,具体生成过程如下:
- webpack 先将 entry 中对应的 module 都生成一个新的 chunk
- 遍历 module 的依赖列表,将依赖 module 也加入 chunk 中
- 如果依赖动态导入的 module ,则该 module 重新创建一个 chunk ,继续遍历
- 直到所有 chunk 生成完成
代码优化 ( seal )
这个阶段 会进行 tree-shaking 等对构建产物进行优化的行为,具体执行顺序如下:
输出阶段
通过 createChunkAssets 输出构建产物,在 emitAsset 中将产物输出到文件系统中。
完整 hook 对照表
compiler 对象逐次触发如下钩子
| 钩子名称 | 描述 |
|---|---|
environment | 在开始读取 Webpack 配置文件之前触发 |
afterEnvironment | 在读取并解析完 Webpack 配置文件之后触发 |
entryOption | 在处理入口配置项( entry options )之前触发 |
beforeRun | 在运行 compiler 之前触发,所有插件的 apply 方法调用后 |
run | 开始编译主流程,在此阶段会创建 Compilation 对象 |
watch-run (监听模式) | 当源代码发生变化并重新编译时触发 |
normal-module-factory | 创建 NormalModuleFactory,用于处理模块加载逻辑的插件非常有用 |
before-compile | 在创建 Compilation 对象之前触发 |
compile | 创建 Compilation 对象 |
make 或 thisCompilation | 新 Compilation 对象创建后立即触发,进行资源和模块处理 |
after-compile | 完成一次 Compilation 过程后触发 |
should-emit | 决定是否应该将编译结果写入硬盘 |
emit | 在编译结果准备输出到硬盘前触发,可以修改或替换输出内容 |
after-emit | 在编译结果成功输出到硬盘后触发,常用于清理工作或其他后期处理 |
done | 整个编译过程完成后触发,不论是否有错误发生 |
failed | 如果编译过程中出现错误,则触发此钩子 |
compilation 对象逐次触发:
| 钩子名称 | 描述 |
|---|---|
build-module | 当一个模块开始构建时触发 |
normal-module-loader | 当使用普通加载器加载模块时触发 |
module-asset | 每当一个模块添加资产到 compilation 时触发 |
optimize-module-order | 优化模块顺序之前触发 |
optimize-modules-basic | 基础模块优化阶段触发 |
optimize-module-ids | 为模块分配唯一标识符(ID)时触发 |
after-optimize-module-ids | 在模块 ID 优化完成后触发 |
optimize-chunk-ids | 为 chunk 分配唯一标识符时触发 |
after-optimize-chunk-ids | 在 chunk ID 优化完成后触发 |
record-modules | 记录所有模块信息前触发 |
record-chunks | 记录所有 chunk 信息前触发 |
before-hash | 在计算 compilation 的 hash 值之前触发 |
content-hash | 当计算内容相关 hash 值时触发 |
after-hash | 在计算完成 compilation 的 hash 值后触发 |
optimize-chunk-assets | 在优化 chunk 资产之前触发 |
optimize-assets | 在优化所有资产之前触发 |
additional-chunk-assets | 在额外的 chunk 资产被加入 compilation 后触发 |
additional-assets | 在其他额外资产被加入 compilation 后触发 |
need-additional-pass | 如果需要再次处理 compilation ,则在此触发 |
before-seal | 在 seal(封闭)compilation 过程之前触发 |
optimize | 在执行最终优化步骤前触发 |
after-optimize | 在执行完所有的优化步骤之后触发 |
seal | 在 compilation 封闭(不可再修改)时触发 |
chunk-graph | 当 chunk 图谱可用时触发 |
after-seal | 在 compilation 完全封闭后触发 |
optimize-tree-chunk | 用于 tree shaking 的 chunk 级别优化 |
optimize-tree-module | 用于 tree shaking 的模块级别优化 |
after-optimizing | 在所有优化操作完成后触发 |
revive-modules | 在模块从缓存恢复后触发 |
optimize-module-factory | 优化模块工厂创建过程时触发 |
optimize-module-factories | 在所有模块工厂优化完成后触发 |
before-module-ids | 在给模块分配 id 之前触发 |
module-id | 每个模块 id 被确定时触发 |
after-module-ids | 在所有模块 id 分配完毕后触发 |
revive-chunks | 在 chunks 从缓存恢复后触发 |
optimize-chunks-basic | 基本的 chunk 优化阶段触发 |
optimize-extracted-chunks | 优化提取出的 chunks 时触发 |
after-optimize-extracted-chunks | 在优化提取出的 chunks 完成后触发 |
optimize-chunk-contents | 优化 chunk 内容时触发 |
before-hash-for-chunk | 在计算单个 chunk 的 hash 值之前触发 |
async | 在异步任务完成后触发 |
child-compiler | 子 compiler 创建时触发 |
分包
术语表
Webpack 内部包含三种类型的 Chunk:
Initial Chunk: 基于 Entry 配置项生成的 ChunkAsync Chunk: 异步模块引用,如 import(xxx) 语句对应的异步 ChunkRuntime Chunk: 只包含运行时代码的 Chunk
默认策略
主要是关于 SplitChunksPlugin 的分包策略, SplitChunksPlugin 通过 module 被引用频率、chunk 大小、包请求数 三个维度决定是否执行分包操作,这些决策都可以通过 optimization.splitChunks 配置项调整定制,基于这些维度可以 实现单独打包某些特定路径的内容
SplitChunksPlugin 内置了 default 与 defaultVendors 两个配置组,提供一些开箱即用的特性:
node_modules资源会命中defaultVendors规则,并被单独打包- 只有包体超过
20kb的Chunk才会被单独打包 - 加载
Async Chunk所需请求数不得超过30 - 加载
Initial Chunk所需请求数不得超过30
根据 Module 使用频率
SplitChunksPlugin 支持按 Module 被 Chunk 引用的次数决定是否进行分包,开发者可通过 optimization.splitChunks.minChunks 设定最小引用次数
“被 Chunk 引用次数” 并不直接等价于被 import 的次数,而是取决于上游调用者是否被视作 Initial Chunk 或 Async Chunk 处理
根据 请求数量分包
这里所说的"请求数",是指加载一个 Chunk 时所需同步加载的分包数。
例如对于一个 Chunk A ,如果根据分包规则(如模块引用次数、第三方包)分离出了 i 个子 Chunk ,
那么请求 A 时,浏览器需要同时请求所有的子 Chunk ,此时并行请求数等于 i 个分包加 A 主包,即 i+1
在满足 minChunks 基础上,还可以通过 maxInitialRequest / maxAsyncRequests 配置项限定分包数量,配置项语义:
maxInitialRequest: 用于设置Initial Chunk最大并行请求数maxAsyncRequests: 用于设置Async Chunk最大并行请求数
并行请求数关键逻辑总结如下:
Initial Chunk本身算一个请求Async Chunk不算并行请求- 通过
runtimeChunk拆分出的runtime不算并行请求 - 如果同时有两个
Chunk满足拆分规则,但是maxInitialRequests( 或maxAsyncRequest) 的值只能允许再拆分一个模块,那么体积更大的模块会被优先拆解
根据 体积分包
在满足 minChunks 与 maxInitialRequests 的基础上, SplitChunksPlugin 还会进一步判断 Chunk 包大小决定是否分包
minSize: 超过这个尺寸的Chunk才会正式被分包maxSize: 超过这个尺寸的Chunk会尝试继续做分包maxAsyncSize: 与maxSize功能类似,但只对异步引入的模块生效maxInitialSize: 与maxSize类似,但只对entry配置的入口模块生效enforceSizeThreshold: 超过这个尺寸的Chunk会被强制分包,忽略上述其它size限制
使用 cacheGroup
SplitChunksPlugin 提供了 cacheGroups 配置项用于为不同文件组设置不同的规则
test: 接受正则表达式、函数及字符串,所有符合test判断的Module或Chunk都会被分到该组type: 接受正则表达式、函数及字符串,与test类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如type = 'json'会命中所有JSON文件idHint: 字符串型,用于设置Chunk ID,它还会被追加到最终产物文件名中,例如idHint = 'vendors'时,输出产物文件名形如vendors-xxx-xxx.jspriority: 数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到priority更大的组
// 通过 cacheGroups 属性设置 vendors 缓存组,
// 所有命中 vendors.test 规则的模块都会被视作 vendors 分组,
// 优先应用该组下的 minChunks、minSize 等分包配置
module.exports = {
// ...
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
minChunks: 1,
minSize: 0,
},
},
},
},
}术语表
Entry: 编译入口, webpack 编译的起点Compiler: 编译管理器, webpack 启动后会创建compiler对象,该对象一直存活知道结束退出Compilation: 单次编辑过程的管理器,比如watch = true时,运行过程中只有一个compiler但每次文件变更触发重新编译时,都会创建一个新的compilation对象Dependence: 依赖对象, webpack 基于该类型记录模块间依赖关系Module: webpack 内部所有资源都会以module对象形式存在,所有关于资源的操作、转译、合并都是以module为基本单位进行的Chunk: 编译完成准备输出时, webpack 会将module按特定的规则组织成一个一个的chunk,这些chunk某种程度上跟最终输出一一对应Loader: 资源内容转换器,其实就是实现从内容 A 转换 B 的转换器Plugin: webpack 构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程
sourcemap
数据结构如下:
{
/** Source map的版本。 */
"version": 4,
/** 转换后的文件名。 */
"file": "out.js",
/** 转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。 */
"sourceRoot": "",
/** 转换前的文件。该项是一个数组,表示可能存在多个文件合并。 */
"sources": ["foo.js", "bar.js"],
/** 转换前的所有变量名和属性名。 */
"names": ["src", "maps", "are", "fun"],
/** 记录位置信息的字符串。 */
"mappings": "AAgBC,SAAQ,CAAEA"
}vite VS webpack
vite可配置build.sourcemap参数控制sourcemap,可选值简化为inline、hidden、true、falsetrue: 生成sourcemap到单独的文件,并在模块后追加//# sourceURL注释 (即 webpack 的source-map)inline: 生成soucemap,并将其作为 DataURI 嵌入到文件中 (即 webpack 的inline-source-map)hidden: 生成sourcemap到单独的文件,但是不在产物中追加注释 (即 webpack 的hidden-source-map)false: 不生成sourcemap
webpack可配置devtool参数控制sourcemap,可选值为eval: 使用eval执行每个模块,并在模块后追加//# sourceURL注释eval-cheap-source-map: 生成没有列信息(column mappings)的sourcemap,并使用eval执行模块eval-cheap-module-source-map: 类似于eval-cheap-source-map,但会为模块的loader生成sourcemapeval-source-map: 生成完整的sourcemap,并使用eval执行模块cheap-source-map: 生成没有列信息的sourcemapcheap-module-source-map: 类似于cheap-source-map,但会为模块的loader生成sourcemapinline-cheap-source-map: 生成没有列信息的sourcemap,并将其作为 DataURI 嵌入到文件中inline-cheap-module-source-map: 类似于inline-cheap-source-map,但会为模块的loader生成sourcemapsource-map: 生成完整的sourcemap文件inline-source-map: 生成完整的sourcemap,并将其作为 DataURI 嵌入到文件中hidden-source-map: 生成完整的sourcemap文件,但不在产物中添加链接注释nosources-source-map: 生成sourcemap文件,但不包含源代码内容
安全性与性能优化
可以通过配置 HTTP 请求头 指定 SourceMap ,同时可以进行权限控制,针对特定用户返回该请求头,避免源码泄露。
如 k8s 中,可以通过 Ingress 注解来配置
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-ingress
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
# 主要是这里,进行判断并添加对应的 SourceMap 请求头
if ($http_cookie ~* "tag=123") {
add_header SourceMap "$request_uri.map";
}
spec:
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 80