本文目录

[[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 生成 hashentry 构建后的其中一个 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 的主要特点:

  1. 按需加载:只有在需要时才会加载,减少初始加载时间
  2. 独立文件:每个异步 chunk 生成独立的 JS 文件
  3. JSONP 加载:通过 JSONP 方式动态加载,避免阻塞主线程
  4. 缓存机制:已加载的 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 内部被广泛使用,主要用于:

  1. 检查模块是否已经加载
  2. 检查对象是否包含特定属性
  3. 在模块合并和依赖解析过程中判断属性是否存在

例如,在异步 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 函数的主要功能:

  1. 检查 chunk 是否已经加载
  2. 如果未加载,创建 script 标签加载对应的 chunk 文件
  3. 返回一个 Promise,当 chunk 加载完成后 resolve
  4. 处理加载失败的情况,提供错误处理机制

这两个函数是 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 对象的主要作用:

  1. 提供多种加载策略:支持 script 标签、import() 和 require.ensure 等多种加载方式
  2. 统一加载接口:通过 __webpack_require__.e 函数统一调用这些加载策略
  3. 处理加载状态:跟踪每个 chunk 的加载状态,确保只加载一次
  4. 错误处理:提供加载失败时的错误处理机制

在 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)
})()

这个函数的主要作用是:

  1. 简化异步加载后的处理逻辑:当异步 chunk 加载完成后,提供一个简洁的方式来处理结果
  2. 支持可选的回调函数:如果提供了回调函数,则执行回调;否则直接返回结果
  3. 统一处理异步加载结果:在 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))
})()

异步任务队列的主要特点和作用:

  1. 延迟加载模块管理deferredModules 数组用于存储需要延迟加载的模块信息,包括模块 ID 和对应的 chunk ID。

  2. 异步加载状态跟踪installedChunks 对象用于跟踪每个 chunk 的加载状态,确保每个 chunk 只被加载一次。

  3. 异步加载完成处理:当异步 chunk 加载完成后,webpackJsonpCallback 函数会处理加载结果,并将延迟加载的模块添加到 __webpack_modules__ 中。

  4. 延迟模块执行:在异步 chunk 加载完成后,会检查 deferredModules 中是否有需要执行的模块,如果有则执行它们。

  5. 防止重复加载:通过 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)
  }
})

冒烟测试

有些时候构建可能没有报错,但是生成的产物会有问题,可以根据简单的脚本对产物进行检测,常见的检测手段有:

  1. 判断是否在指定路径下输出了指定名称的 html 文件
  2. 判断指定 assets 目录中是否生成了 js 、 css 等静态资源文件
  3. 起一个本地 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 库来管理,核心的 compilercompilation 都是继承自 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 / taptap
执行callAsync / promisecall

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 --> emit

loader

定义: 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

随后 按照配置决定执行 watch 还是 run

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: LibraryTemplatePlugin
  • externals: ExternalsPlugin
  • devtool: EvalDevtoolModulePlugin / SourceMapDevToolPlugin
  • AMDPlugin / CommonJsPlugin
  • RemoveEmptyChunksPlugin

完整代码可以查看 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 阶段,以单入口构建为例:

单入口构建会转换为 SingleEntryPluginSingleEntryPlugin 也就是 EntryPlugin ,在 代码 中可以看到,该 plugin 会在触发后调用 compilation.addEntry ,开始正式构建。

构建过程中会按照不同的 Module 调用其 build 函数执行构建。

  1. 实例化 loaderContext ,通过 runLoader 读取文件信息
  2. 读取完模块信息后,通过 parser.parse() 解析为 AST ,如果是 JS 模块用 acron , 如果是 WASM 模块用 @webassemblyjs/wasm-parser
  3. 所有的模块解析后会存放到 compilation.modules 中,触发 finishModules

webpack 4 用的是 successModules 而不是 finishModules

chunk 生成

默认生成 chunk 是按照如下流程的,后续产物的 chunk 是 seal 之后的结果,具体生成过程如下:

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk
  2. 遍历 module 的依赖列表,将依赖 module 也加入 chunk 中
  3. 如果依赖动态导入的 module ,则该 module 重新创建一个 chunk ,继续遍历
  4. 直到所有 chunk 生成完成

代码优化 ( seal )

这个阶段 会进行 tree-shaking 等对构建产物进行优化的行为,具体执行顺序如下:

  1. 构建 chunk 图
  2. 优化依赖项
  3. 优化 module
  4. 优化 chunk
  5. tree-shaking
  6. 优化 ChunkModules
  7. 输出 Assets

输出阶段

通过 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 对象
makethisCompilation新 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 配置项生成的 Chunk
  • Async Chunk: 异步模块引用,如 import(xxx) 语句对应的异步 Chunk
  • Runtime Chunk: 只包含运行时代码的 Chunk

默认策略

主要是关于 SplitChunksPlugin 的分包策略, SplitChunksPlugin 通过 module 被引用频率、chunk 大小、包请求数 三个维度决定是否执行分包操作,这些决策都可以通过 optimization.splitChunks 配置项调整定制,基于这些维度可以 实现单独打包某些特定路径的内容

SplitChunksPlugin 内置了 defaultdefaultVendors 两个配置组,提供一些开箱即用的特性:

  1. node_modules 资源会命中 defaultVendors 规则,并被单独打包
  2. 只有包体超过 20kbChunk 才会被单独打包
  3. 加载 Async Chunk 所需请求数不得超过 30
  4. 加载 Initial Chunk 所需请求数不得超过 30

根据 Module 使用频率

SplitChunksPlugin 支持按 ModuleChunk 引用的次数决定是否进行分包,开发者可通过 optimization.splitChunks.minChunks 设定最小引用次数

“被 Chunk 引用次数” 并不直接等价于被 import 的次数,而是取决于上游调用者是否被视作 Initial ChunkAsync 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 ) 的值只能允许再拆分一个模块,那么体积更大的模块会被优先拆解

根据 体积分包

在满足 minChunksmaxInitialRequests 的基础上, SplitChunksPlugin 还会进一步判断 Chunk 包大小决定是否分包

  • minSize: 超过这个尺寸的 Chunk 才会正式被分包
  • maxSize: 超过这个尺寸的 Chunk 会尝试继续做分包
  • maxAsyncSize:maxSize 功能类似,但只对异步引入的模块生效
  • maxInitialSize:maxSize 类似,但只对 entry 配置的入口模块生效
  • enforceSizeThreshold: 超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 size 限制

使用 cacheGroup

SplitChunksPlugin 提供了 cacheGroups 配置项用于为不同文件组设置不同的规则

  • test: 接受正则表达式、函数及字符串,所有符合 test 判断的 ModuleChunk 都会被分到该组
  • type: 接受正则表达式、函数及字符串,与 test 类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如 type = 'json' 会命中所有 JSON 文件
  • idHint: 字符串型,用于设置 Chunk ID ,它还会被追加到最终产物文件名中,例如 idHint = 'vendors' 时,输出产物文件名形如 vendors-xxx-xxx.js
  • priority: 数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到 priority 更大的组
// 通过 cacheGroups 属性设置 vendors 缓存组,
// 所有命中 vendors.test 规则的模块都会被视作 vendors 分组,
// 优先应用该组下的 minChunks、minSize 等分包配置
module.exports = {
  // ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          minChunks: 1,
          minSize: 0,
        },
      },
    },
  },
}

术语表

  1. Entry: 编译入口, webpack 编译的起点
  2. Compiler: 编译管理器, webpack 启动后会创建 compiler 对象,该对象一直存活知道结束退出
  3. Compilation: 单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象
  4. Dependence: 依赖对象, webpack 基于该类型记录模块间依赖关系
  5. Module: webpack 内部所有资源都会以 module 对象形式存在,所有关于资源的操作、转译、合并都是以 module 为基本单位进行的
  6. Chunk: 编译完成准备输出时, webpack 会将 module 按特定的规则组织成一个一个的 chunk ,这些 chunk 某种程度上跟最终输出一一对应
  7. Loader: 资源内容转换器,其实就是实现从内容 A 转换 B 的转换器
  8. 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 ,可选值简化为 inlinehiddentruefalse
    • true: 生成 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 生成 sourcemap
    • eval-source-map: 生成完整的 sourcemap,并使用 eval 执行模块
    • cheap-source-map: 生成没有列信息的 sourcemap
    • cheap-module-source-map: 类似于 cheap-source-map,但会为模块的 loader 生成 sourcemap
    • inline-cheap-source-map: 生成没有列信息的 sourcemap,并将其作为 DataURI 嵌入到文件中
    • inline-cheap-module-source-map: 类似于 inline-cheap-source-map,但会为模块的 loader 生成 sourcemap
    • source-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