从 Webpack 源码来深入学习 Tree Shaking 实现原理 🤗🤗🤗

75 阅读31分钟

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

20250310220634

Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,通过静态分析模块间的导入导出关系,精确识别并移除未被引用的代码,从而显著减小最终打包体积并提升应用性能。这一技术最初由 Rich Harris 在 Rollup 中创新性地实现,随后 Webpack 在 2.0 版本开始支持,如今已成为现代前端工程中不可或缺的优化手段。Tree-Shaking 的成功依赖于 ES Module 的静态结构特性,使构建工具能够在编译阶段确定代码的使用情况,这也是它区别于传统 CommonJS 模块优化的关键所在。

在 Webpack 中启动 Tree Shaking

在 Webpack 中启用 Tree Shaking 需要满足以下条件:

  1. 使用 ES Module 语法(import/export),而非 CommonJS(require)

  2. 将 Webpack 模式设置为 production:

    module.exports = {
      mode: "production",
    };
    
  3. 在 optimization 选项中启用 usedExports:

    module.exports = {
      mode: "production",
      optimization: {
        usedExports: true,
      },
    };
    
  4. 在 package.json 中添加 "sideEffects" 属性,标记哪些文件有副作用:

    {
      "name": "moment",
      "sideEffects": false
    }
    

    或指定有副作用的文件:

    {
      "name": "moment",
      "sideEffects": ["*.css", "*.scss"]
    }
    
  5. 确保你的 Babel 配置不会将 ES Modules 转换为 CommonJS 模块

编写代码时,应该避免导入整个库,而是只导入需要的部分:

// 不推荐(无法 tree shake)
import _ from "lodash";

// 推荐(可以 tree shake)
import { debounce } from "lodash";

为什么 Commonjs 不能实现 tree shaking

CommonJS 无法实现 Tree Shaking 的深层原因在于其动态特性与运行时行为:

CommonJS 模块系统允许高度动态的导入模式。开发者可以在条件语句中使用 require(),支持变量路径导入,甚至允许在任何作用域中导入模块。例如:

if (process.env.NODE_ENV === "development") {
  require("./debug-tools");
}

const moduleId = getModuleId();
const module = require(`./modules/${moduleId}`);

同时,CommonJS 采用值拷贝的导出方式,导出的是完整模块对象。即使只使用一个属性,也必须导入整个模块对象,无法在编译时确定使用了哪些具体导出项。依赖解析发生在执行阶段,模块关系图只有在代码实际运行时才能完全确定。静态分析工具无法可靠地预测所有可能的模块加载路径,动态 require 调用的结果依赖于运行时环境和条件。

相比之下,ES Modules 的设计从根本上解决了这些问题:

ES Modules 严格限制导入导出语句的位置和形式,所有 import/export 必须位于模块顶层。下面的代码在 ESM 中是非法的:

if (condition) {
  import { foo } from "./module"; // 语法错误!
  export const bar = "bar"; // 语法错误!
}

这种限制使得模块依赖图在编译时完全确定。ES Modules 支持精确的导入导出关系,可以明确指定需要的导出项,构建工具能够创建精确的依赖关系图,确定哪些导出项实际被使用。

此外,ES Modules 的编译时可分析性也很关键。模块说明符必须是字符串字面量,编译器可以在不执行代码的情况下构建完整的模块依赖图,准确识别哪些导出项从未被任何模块引用。

因此,ES Modules 的这些特性为 Tree Shaking 提供了必要的静态分析基础,使构建工具能够准确识别并移除未使用的代码,从而大幅减小最终打包体积。

实现原理

在 Webpack 中,Tree-shaking 通过"标记-清除"两阶段实现未使用代码的消除。整个过程精确且高效:

首先,在编译阶段 Webpack 会构建完整的模块关系图并标记未使用的导出:

  1. 构建阶段(Make):分析模块代码,提取所有导出变量并记录到模块依赖图(ModuleGraph)结构中,建立模块间的依赖关系网络

  2. 封装阶段(Seal):遍历模块依赖图,确定每个模块的导出变量是否被其他模块引用,对未被引用的导出变量做标记

  3. 生成阶段:输出最终代码时,根据标记状态生成有效的导出语句,已被标记为"未使用"的导出在产物代码中会以注释形式存在

随后,在优化阶段,Terser 等压缩工具会识别这些标记并物理移除未使用的代码,最终生成精简的产物文件。

这种设计将"分析依赖"与"清除死代码"解耦,使 Webpack 能在不同环境中灵活应用 Tree-shaking 技术。

首先,标记功能需要配置 optimization.usedExports = true 开启,如下代码所示:

const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },

  optimization: {
    usedExports: true,
  },
};

然后我们编写如下代码:

20250325221218

我们再 moment.js 文件导出了一个 moment 变量,并且在入口文件中使用了。

接下来我们将执行打包的操作,最终输出的结果如下所示:

/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => {
  // webpackBootstrap
  /******/ "use strict";
  /******/ var __webpack_modules__ = {
    /***/ "./src/index.js":
      /*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
      /***/ (
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) => {
        eval(
          '/* unused harmony exports bar, foo */\n/* harmony import */ var _moment__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moment */ "./src/moment.js");\nconst bar = "bar";\nconst foo = "foo";\n\n/* unused harmony default export */ var __WEBPACK_DEFAULT_EXPORT__ = ("foo-bar");\n\n\n\nconsole.log(_moment__WEBPACK_IMPORTED_MODULE_0__.moment);\n\n\n//# sourceURL=webpack://debug-example/./src/index.js?'
        );

        /***/
      },

    /***/ "./src/moment.js":
      /*!***********************!*\
  !*** ./src/moment.js ***!
  \***********************/
      /***/ (
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) => {
        eval(
          '/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   moment: () => (/* binding */ moment)\n/* harmony export */ });\nconst moment = "moment";\n\n\n//# sourceURL=webpack://debug-example/./src/moment.js?'
        );

        /***/
      },

    /******/
  };
  /************************************************************************/
  /******/ // The module cache
  /******/ var __webpack_module_cache__ = {};
  /******/
  /******/ // The require function
  /******/ function __webpack_require__(moduleId) {
    /******/ // Check if module is in cache
    /******/ var cachedModule = __webpack_module_cache__[moduleId];
    /******/ if (cachedModule !== undefined) {
      /******/ return cachedModule.exports;
      /******/
    }
    /******/ // Create a new module (and put it into the cache)
    /******/ var module = (__webpack_module_cache__[moduleId] = {
      /******/ // no module.id needed
      /******/ // no module.loaded needed
      /******/ exports: {},
      /******/
    });
    /******/
    /******/ // Execute the module function
    /******/ __webpack_modules__[moduleId](
      module,
      module.exports,
      __webpack_require__
    );
    /******/
    /******/ // Return the exports of the module
    /******/ return module.exports;
    /******/
  }
  /******/
  /************************************************************************/
  /******/ /* webpack/runtime/define property getters */
  /******/ (() => {
    /******/ // define getter functions for harmony exports
    /******/ __webpack_require__.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],
          });
          /******/
        }
        /******/
      }
      /******/
    };
    /******/
  })();
  /******/
  /******/ /* webpack/runtime/hasOwnProperty shorthand */
  /******/ (() => {
    /******/ __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
    /******/
  })();
  /******/
  /************************************************************************/
  /******/
  /******/ // startup
  /******/ // Load entry module and return exports
  /******/ // This entry module can't be inlined because the eval devtool is used.
  /******/ var __webpack_exports__ = __webpack_require__("./src/index.js");
  /******/
  /******/
})();

这段代码是 webpack 开发模式下的打包输出,清晰展示了 Tree Shaking 的"标记"阶段实现:

webpack 通过静态分析精确识别出模块中的未使用导出,并用特殊注释标记它们。在这个例子中:

  • index.js 文件中的三个导出被标记为未使用:

    • 变量barfoo通过/* unused harmony exports bar, foo */标记
    • 默认导出"foo-bar"通过/* unused harmony default export */标记
  • 而 moment.js 模块中的moment导出被正确识别为已使用,因它在 index.js 中被引用: console.log(_moment__WEBPACK_IMPORTED_MODULE_0__.moment)

这种标记机制让 webpack 在不影响代码功能的情况下,为后续的压缩阶段提供明确的"删除指南"——在开发模式下保留全部代码以便调试,而在生产模式下由 Terser 等工具移除这些已标记的未使用代码,从而减小最终包体积。

整个过程依赖于 webpack 精心设计的模块系统,其中两个关键组件发挥着核心作用:

__webpack_module_cache__作为模块缓存仓库,存储已执行模块的结果,确保每个模块只被执行一次,无论它被引用多少次。这不仅提高了运行效率,也保证了模块状态的一致性,尤其对含有副作用的模块至关重要。

__webpack_require__函数则模拟了模块加载器,它智能管理模块的加载、执行与缓存流程:首先检查模块是否已缓存,若已缓存则直接返回;否则创建新模块实例、执行模块代码并缓存结果。这个精密的加载机制使 webpack 能够在运行时准确还原静态分析所建立的模块依赖关系。

通过这套机制,webpack 不仅实现了代码模块化,更让 Tree Shaking 成为可能——它可以精确追踪哪些导出真正被使用,哪些仅仅是"死代码"。这个过程完美展示了 Tree Shaking 的工作原理:先标记,后清除,最终生成高效精简的代码包。

1. 收集模块导出

首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程就是将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:

  1. 具名导出转换为 HarmonyExportSpecifierDependency 对象

  2. default 导出转换为 HarmonyExportExpressionDependency 对象

如下代码所示:

20250325221218

对应的 dependencies 值为:

20250325224918

所有模块都编译完毕之后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调。

FlagDependencyExportsPlugin 插件执行流程详解

FlagDependencyExportsPlugin 是 Webpack 中负责标记模块导出信息的核心插件,它在 Tree Shaking 的第一阶段(标记阶段)发挥关键作用。下面我会详细解释这个插件的执行流程,特别是关于缓存内容和导出规范的部分。

1. 插件注册和初始化
apply(compiler) {
  compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    // 插件逻辑
  });
}

插件通过 compiler.hooks.compilation.tap 注册到编译器的 compilation 钩子上,当 Webpack 创建新的 compilation 对象时会触发这个钩子。

2. 主要执行阶段

插件主要在 finishModules 钩子阶段执行,这个阶段是所有模块构建完成后、开始优化之前:

compilation.hooks.finishModules.tapAsync(PLUGIN_NAME, (modules, callback) => {
  // 主要逻辑
});
3. 初始化阶段
const logger = compilation.getLogger(PLUGIN_LOGGER_NAME);
let statRestoredFromMemCache = 0;
// 其他统计变量...
const { moduleMemCaches } = compilation;
const queue = new Queue();

在初始化阶段,插件首先设置日志记录器并创建统计变量以跟踪缓存恢复情况,然后获取编译过程的内存缓存(moduleMemCaches),最后建立处理队列用于存放待分析的模块,为后续的导出信息收集和标记做好准备。

4. 尝试从缓存恢复导出信息
缓存中存储了什么?

缓存中存储的是模块的导出信息,具体包括:

  1. 模块的导出变量列表:模块中导出的所有变量名称

  2. 每个导出变量的提供状态(provided):表示该导出是否确实被提供

    • true - 确定提供

    • false - 确定不提供

    • null - 不确定

  3. 导出变量的混淆可能性(canMangle):表示该导出名称是否可以在压缩时被混淆

  4. 导出变量的使用状态(used):表示该导出是否被其他模块使用

  5. 导出变量的重定向信息:如果导出是重定向到其他模块的导出,则包含目标信息

// 恢复缓存的示例代码
asyncLib.each(
  modules,
  (module, callback) => {
    const exportsInfo = moduleGraph.getExportsInfo(module);

    // 处理没有声明导出类型的模块
    if (
      (!module.buildMeta || !module.buildMeta.exportsType) &&
      exportsInfo.otherExportsInfo.provided !== null
    ) {
      // 对于没有明确声明导出类型的模块(如CommonJS模块),
      // 假定它可能导出任何内容
      statNoExports++;
      exportsInfo.setHasProvideInfo();
      exportsInfo.setUnknownExportsProvided();
      return callback();
    }

    // 处理不可缓存的模块(没有hash值的模块)
    if (typeof module.buildInfo.hash !== "string") {
      statFlaggedUncached++;
      queue.enqueue(module);
      exportsInfo.setHasProvideInfo();
      return callback();
    }

    // 首先尝试从内存缓存恢复(更快)
    const memCache = moduleMemCaches && moduleMemCaches.get(module);
    const memCacheValue = memCache && memCache.get(this);
    if (memCacheValue !== undefined) {
      statRestoredFromMemCache++;
      // 从缓存恢复导出信息
      exportsInfo.restoreProvided(memCacheValue);
      return callback();
    }

    // 其次尝试从持久化缓存恢复
    cache.get(module.identifier(), module.buildInfo.hash, (err, result) => {
      if (err) return callback(err);

      if (result !== undefined) {
        statRestoredFromCache++;
        // 从持久化缓存恢复导出信息
        exportsInfo.restoreProvided(result);
      } else {
        // 没有缓存,需要重新分析
        statNotCached++;
        queue.enqueue(module);
        exportsInfo.setHasProvideInfo();
      }
      callback();
    });
  }
  // 继续下一阶段...
);

这段代码是 Webpack 中 Tree Shaking 功能的基础部分,它通过 FlagDependencyExportsPlugin 插件来分析和收集每个模块的导出信息。具体来说,它会遍历项目中的所有模块,对每个模块进行分析,确定该模块导出了哪些变量(如函数、类、常量等),以及这些导出是通过什么方式实现的(如直接导出、重命名导出、星号导出等)。

在处理每个模块时,代码采用了一个三层的处理策略:首先,对于那些使用 CommonJS(如 module.exports)这样的模块,由于无法静态分析其导出内容,会直接将其标记为"可能导出任何内容";其次,对于之前处理过的模块,会尝试从内存缓存或磁盘缓存中恢复其导出信息,这样可以显著提升构建性能;最后,对于那些没有缓存信息的模块,则将其放入队列中,等待后续进行完整的代码分析。

这些收集到的导出信息会被存储在 Webpack 的模块图(ModuleGraph)中,成为后续 Tree Shaking 过程的重要依据。通过这些信息,Webpack 可以准确地知道每个模块提供了哪些导出,这些导出是否被其他模块使用,从而在最终打包时可以安全地移除那些未被使用的代码。

ModuleGraph 是 Webpack 内部的核心数据结构,负责追踪和管理所有模块间的依赖关系与导出信息。它通过 exportsInfo 系统精确记录每个模块"导出了什么"以及"这些导出如何被使用",为 Tree Shaking 优化提供关键决策依据。作为 Webpack 构建流程的中枢神经系统,ModuleGraph 使编译器能够理解代码间的内在联系,从而实现智能代码生成和高效打包优化。

如下图所示:

20250326101318

这种设计不仅确保了 Tree Shaking 的准确性,还通过多级缓存机制显著提升了构建性能,特别是在开发模式下的反复构建场景中。同时,它还能安全地处理不同类型的模块系统(ES Modules 和 CommonJS),保证了打包结果的正确性。

5. 分析导出信息 - 何为导出规范(ExportsSpec)?

**导出规范(ExportsSpec)**是描述模块如何导出变量的对象,它包含以下关键信息:

  1. exports:可以是以下三种情况之一

    • true - 表示模块导出所有内容(如 export * from './other'

    • 数组 - 包含具体导出变量的名称和规范

    • false/undefined - 不导出任何内容

  2. canMangle:表示导出名称是否可以被混淆(重命名为短名称)

    • 对于库来说,通常设为 false,因为外部代码可能依赖这些名称

    • 对于应用内部模块,通常设为 true,允许压缩工具重命名以减小体积

  3. from:如果导出是重定向,指向源模块

  4. priority:优先级,在多个模块提供相同导出名称时使用

  5. terminalBinding:表示这是最终绑定,不应该被其他导出覆盖

  6. hideExports:需要隐藏的导出列表

  7. excludeExports:要排除的导出列表(当 exports 为 true 时使用)

// 导出规范的例子
{
  exports: [
    "default",
    { name: "helper", canMangle: true },
    { name: "utils", exports: ["format", "parse"] }
  ],
  canMangle: true,
  priority: 0
}

// 或者未知的所有导出
{
  exports: true,
  excludeExports: ["private"],
  canMangle: false
}
canMangle 详解

canMangle(可混淆)字面意思是指"是否可以改变/混淆该变量的名称":

  1. 为什么需要混淆名称

    • 在代码压缩过程中,长变量名会被重命名为短变量名(如 longVariableNamea

    • 这样可以显著减小最终打包文件的大小

    • 对于模块内部使用的导出,混淆是安全的

  2. 何时不能混淆名称

    • 当导出可能被外部代码通过动态方式访问时(如使用字符串形式的属性名)

    • 当导出是公共 API 的一部分时(如发布为库)

    • 当导出使用了 Object.defineProperty 等特殊方式定义时

可以混淆的情况:

// module.js
export const calculateTotal = (a, b) => a + b;

// 导入时使用静态导入
import { calculateTotal } from "./module";
calculateTotal(1, 2);

这种情况下,calculateTotalcanMangletrue,因为导入使用了静态命名导入,Webpack 可以安全地将 calculateTotal 重命名为更短的名称(如 a):

// 压缩后可能变成这样
const a = (t, n) => t + n;
a(1, 2);

不能混淆的情况:

// library.js
export const VERSION = "1.0.0";
export const helper = { format: () => {} };

// 用户可能这样使用
import * as lib from "./library";
console.log(lib["VERSION"]); // 使用字符串访问

这种情况下,VERSIONcanMangle 应为 false,因为它可能被动态访问,如果重命名会破坏外部代码的正常工作。

canMangle 在 Tree Shaking 中的作用:

  1. Webpack 中的 canMangle 标记用于控制代码压缩过程中的变量名重命名,被标记为 canMangle: true 的导出可以被压缩工具更激进地重命名,这不仅能减小代码体积,还能带来更多优化机会。

  2. 对于 canMangle: false 的导出,压缩工具会保留其原始名称,这主要是为了保证那些通过动态方式引用的代码能够正常工作。

  3. Webpack 通过导出声明分析来确定 canMangle 状态,默认情况下,普通的导出都会被标记为 canMangle: true

  4. 不同类型的模块有不同的 canMangle 处理策略:ES 模块的导出通常可以被混淆,而 CommonJS 模块由于其动态特性,会更保守地设置为 canMangle: false

  5. 在库模式下(output.library),Webpack 会将暴露的导出标记为 canMangle: false,因为这些导出可能会被外部代码以各种方式引用。

  6. 对于包含副作用的模块,其导出通常会被标记为 canMangle: false,这是为了避免重命名导致的潜在问题。

  7. 这种标记机制是 Tree Shaking 优化的重要组成部分,通过精确控制哪些变量名可以被重命名,在保证代码正确性的同时实现最大程度的代码压缩。

canMangle 状态是 Webpack 中决定导出变量名是否可以被压缩工具重命名的标志。它是 Tree Shaking 优化的重要组成部分,通过允许安全的名称重命名,帮助减小最终打包文件的大小。

当 Webpack 分析模块时,会为每个导出确定适当的 canMangle 状态,并将这些信息传递给后续的压缩工具,以实现安全且高效的代码优化。

6. 处理依赖块和依赖 - 收集导出规范
const processDependenciesBlock = (depBlock) => {
  for (const dep of depBlock.dependencies) {
    processDependency(dep);
  }
  for (const block of depBlock.blocks) {
    processDependenciesBlock(block);
  }
};

const processDependency = (dep) => {
  // 从每个依赖中获取导出规范
  const exportDesc = dep.getExports(moduleGraph);
  if (!exportDesc) return;
  exportsSpecsFromDependencies.set(dep, exportDesc);
};

这两个函数的目的是递归地遍历模块的所有依赖,并收集每个依赖提供的导出规范:

  1. **依赖块(DependenciesBlock)**是包含依赖的容器,可以是:

    • 模块本身

    • 代码分割点(如动态 import())

    • 条件加载(如 if 语句中的 require())

  2. **依赖(Dependency)**表示模块间的依赖关系,例如:

    • ImportDependency:对应 import { foo } from './bar'

    • CommonJsRequireDependency:对应 require('./foo')

    • HarmonyExportSpecifierDependency:对应 export const foo = 5

20250326094247

  1. dep.getExports(moduleGraph):每种依赖类型都有自己的逻辑来提供导出规范

    • 对于导入语句,它描述导入了哪些变量

    • 对于导出语句,它描述导出了哪些变量

如下图所示:

20250326093837

7. 处理导出规范 - 核心逻辑详解
const processExportsSpec = (dep, exportDesc) => {
  // 获取导出描述信息
  const exports = exportDesc.exports;
  const globalCanMangle = exportDesc.canMangle;
  const globalFrom = exportDesc.from;
  const globalPriority = exportDesc.priority;
  const globalTerminalBinding = exportDesc.terminalBinding || false;
  const exportDeps = exportDesc.dependencies;

  // 处理隐藏的导出
  if (exportDesc.hideExports) {
    for (const name of exportDesc.hideExports) {
      const exportInfo = exportsInfo.getExportInfo(name);
      exportInfo.unsetTarget(dep);
    }
  }

  // 处理未知导出情况 - 如 export * from './module'
  if (exports === true) {
    if (
      exportsInfo.setUnknownExportsProvided(
        globalCanMangle,
        exportDesc.excludeExports,
        globalFrom && dep,
        globalFrom,
        globalPriority
      )
    ) {
      changed = true;
    }
  }
  // 处理具名导出列表
  else if (Array.isArray(exports)) {
    const mergeExports = (exportsInfo, exports) => {
      for (const exportNameOrSpec of exports) {
        // 解析导出规范详情
        let name;
        let canMangle = globalCanMangle;
        let terminalBinding = globalTerminalBinding;
        let exports;
        let from = globalFrom;
        let fromExport;
        let priority = globalPriority;
        let hidden = false;

        // 处理字符串形式的导出名称
        if (typeof exportNameOrSpec === "string") {
          name = exportNameOrSpec;
        }
        // 处理对象形式的导出规范
        else {
          name = exportNameOrSpec.name;
          if (exportNameOrSpec.canMangle !== undefined)
            canMangle = exportNameOrSpec.canMangle;
          if (exportNameOrSpec.export !== undefined)
            fromExport = exportNameOrSpec.export;
          if (exportNameOrSpec.exports !== undefined)
            exports = exportNameOrSpec.exports;
          if (exportNameOrSpec.from !== undefined) from = exportNameOrSpec.from;
          if (exportNameOrSpec.priority !== undefined)
            priority = exportNameOrSpec.priority;
          if (exportNameOrSpec.terminalBinding !== undefined)
            terminalBinding = exportNameOrSpec.terminalBinding;
          if (exportNameOrSpec.hidden !== undefined)
            hidden = exportNameOrSpec.hidden;
        }

        // 获取或创建导出信息对象
        const exportInfo = exportsInfo.getExportInfo(name);

        // 更新导出的提供状态
        if (exportInfo.provided === false || exportInfo.provided === null) {
          exportInfo.provided = true;
          changed = true;
        }

        // 更新是否可以被混淆
        if (exportInfo.canMangleProvide !== false && canMangle === false) {
          exportInfo.canMangleProvide = false;
          changed = true;
        }

        // 更新终端绑定状态
        if (terminalBinding && !exportInfo.terminalBinding) {
          exportInfo.terminalBinding = true;
          changed = true;
        }

        // 处理嵌套导出(如对象中的属性)
        if (exports) {
          const nestedExportsInfo = exportInfo.createNestedExportsInfo();
          // 递归处理嵌套导出
          mergeExports(nestedExportsInfo, exports);
        }

        // 设置导出目标(对于re-export情况)
        if (
          from &&
          (hidden
            ? exportInfo.unsetTarget(dep)
            : exportInfo.setTarget(
                dep,
                from,
                fromExport === undefined ? [name] : fromExport,
                priority
              ))
        ) {
          changed = true;
        }

        // 重新计算目标导出信息
        const target = exportInfo.getTarget(moduleGraph);
        let targetExportsInfo;
        if (target) {
          // 如果导出是重定向,获取目标导出信息
          const targetModuleExportsInfo = moduleGraph.getExportsInfo(
            target.module
          );
          targetExportsInfo = targetModuleExportsInfo.getNestedExportsInfo(
            target.export
          );
          // 添加模块依赖关系
          const set = dependencies.get(target.module);
          if (set === undefined) {
            dependencies.set(target.module, new Set([module]));
          } else {
            set.add(module);
          }
        }

        // 更新导出信息中的重定向
        if (exportInfo.exportsInfoOwned) {
          if (exportInfo.exportsInfo.setRedirectNamedTo(targetExportsInfo)) {
            changed = true;
          }
        } else if (exportInfo.exportsInfo !== targetExportsInfo) {
          exportInfo.exportsInfo = targetExportsInfo;
          changed = true;
        }
      }
    };
    // 开始处理导出列表
    mergeExports(exportsInfo, exports);
  }

  // 处理依赖关系
  if (exportDeps) {
    cacheable = false;
    for (const exportDependency of exportDeps) {
      // 添加模块依赖关系
      const set = dependencies.get(exportDependency);
      if (set === undefined) {
        dependencies.set(exportDependency, new Set([module]));
      } else {
        set.add(module);
      }
    }
  }
};

这个函数的核心作用是根据收集到的导出规范,更新模块的导出信息:

  1. 处理不同类型的导出

    • 未知导出exports === true):如 export * from './module'

    • 具名导出列表(数组):明确列出的导出变量

  2. 为每个导出设置状态

    • provided:标记导出是否被提供

    • canMangle:标记导出名称是否可以被混淆

    • terminalBinding:标记是否为终端绑定

  3. 处理导出重定向

    • 设置导出目标(对于 re-export 情况)

    • 建立导出信息与目标导出信息的连接

    • 维护模块间的依赖关系图

  4. 处理嵌套导出:对于如 export const utils = { format, parse } 这样的嵌套导出,递归处理其内部结构

8. 通知依赖 - 级联更新
const notifyDependencies = () => {
  const deps = dependencies.get(module);
  if (deps !== undefined) {
    for (const dep of deps) {
      queue.enqueue(dep);
    }
  }
};

当一个模块的导出信息发生变化时,所有依赖于它的模块都需要重新分析,这个函数实现了级联更新:

  1. 从依赖图中查找依赖于当前模块的所有模块

  2. 将这些模块加入处理队列,以便在后续迭代中重新分析它们

  3. 这确保了导出信息的变化能正确地传播到整个依赖图

9. 缓存导出信息 - 为下次构建做准备
asyncLib.each(
  modulesToStore,
  (module, callback) => {
    // 跳过不可缓存的模块
    if (typeof module.buildInfo.hash !== "string") {
      return callback();
    }

    // 获取要缓存的导出信息
    const cachedData = moduleGraph
      .getExportsInfo(module)
      .getRestoreProvidedData();

    // 更新内存缓存
    const memCache = moduleMemCaches && moduleMemCaches.get(module);
    if (memCache) {
      memCache.set(this, cachedData);
    }

    // 存储到持久化缓存
    cache.store(
      module.identifier(),
      module.buildInfo.hash,
      cachedData,
      callback
    );
  },
  (err) => {
    logger.timeEnd("store provided exports into cache");
    callback(err);
  }
);

这段代码实现了 Webpack 构建过程中的关键优化机制:将模块导出信息存储到缓存系统。通过这种方式,Webpack 能在后续构建中快速恢复模块的导出状态,避免重复分析,显著提升构建性能。

它有如下实现细节

  1. 缓存资格判断:首先检查模块是否具备缓存条件,通过验证 module.buildInfo.hash 是否为字符串。这个哈希值代表模块内容的指纹,只有带有有效哈希的模块才会进入缓存流程。

  2. 导出数据提取:对符合条件的模块,调用 moduleGraph.getExportsInfo(module).getRestoreProvidedData() 获取其导出信息。这些数据包含了模块所有导出的详细状态,是 Tree Shaking 优化的基础。

  3. 分层缓存策略

    • 内存缓存:通过 memCache.set(this, cachedData) 将数据存入内存,优化同一构建周期内的重复访问

    • 持久化缓存:通过 cache.store() 将数据持久保存,支持跨构建会话的信息复用

  4. 精确缓存标识:持久化缓存采用 module.identifier()module.buildInfo.hash 作为键,确保缓存的精确匹配和失效控制。只有模块内容完全相同时才能命中缓存,保证了优化的安全性。

  5. 非阻塞执行模式:整个缓存过程通过 asyncLib.each 异步执行,防止在处理大型项目时阻塞主线程,提高了构建的响应性和效率。

这个缓存机制是 Webpack 增量构建和 Tree Shaking 优化的关键支撑,通过智能复用分析结果,大幅降低了重复构建的计算开销。

10. 模块重建支持 - 热更新的基础
const providedExportsCache = new WeakMap();

// 当模块开始重建时,保存其当前的导出信息
compilation.hooks.rebuildModule.tap(PLUGIN_NAME, (module) => {
  providedExportsCache.set(
    module,
    moduleGraph.getExportsInfo(module).getRestoreProvidedData()
  );
});

// 重建完成后,恢复之前的导出信息
compilation.hooks.finishRebuildingModule.tap(PLUGIN_NAME, (module) => {
  moduleGraph
    .getExportsInfo(module)
    .restoreProvided(providedExportsCache.get(module));
});

这部分代码是为增量构建和热模块替换(HMR)设计的:

  1. 当模块需要重新构建时(如文件变化),先保存其当前的导出信息
  2. 模块重建完成后,恢复这些导出信息
  3. 这确保了即使模块重建,其导出信息也能保持一致,避免不必要的级联更新
导出规范的实际例子

为了更直观地理解导出规范,下面是几个具体的例子:

例子 1:具名导出

// module.js
export const foo = 123;
export function bar() {}

对应的导出规范:

{
  exports: [
    { name: "foo", canMangle: true },
    { name: "bar", canMangle: true }
  ],
  canMangle: true
}

例子 2:默认导出

// module.js
export default class MyClass {}

对应的导出规范:

{
  exports: [
    { name: "default", canMangle: true }
  ],
  canMangle: true
}

例子 3:重导出

// module.js
export { foo, bar as baz } from "./other-module";

对应的导出规范:

{
  exports: [
    { name: "foo", from: "./other-module", export: ["foo"] },
    { name: "baz", from: "./other-module", export: ["bar"] }
  ],
  canMangle: true
}

例子 4:星号导出

// module.js
export * from "./utils";

对应的导出规范:

{
  exports: true,
  from: "./utils",
  canMangle: true
}
小结

FlagDependencyExportsPlugin 的执行流程可以归纳为以下步骤:

  1. 缓存恢复阶段:插件首先尝试从内存和持久化缓存中恢复模块的导出状态信息,避免不必要的重复分析,提高构建速度。

  2. 导出信息收集:对于缓存未命中的模块,插件深入分析其依赖图谱和导出结构,收集包含变量名称、混淆选项、优先级和重定向路径等关键数据的导出规范。

  3. 状态处理与更新:基于收集到的导出规范,插件更新模块的导出信息表,标记每个导出的提供状态、混淆可能性和目标引用,构建精确的导出映射。

  4. 依赖传播机制:当某个模块的导出信息变更时,插件自动将依赖于该模块的所有模块加入处理队列,确保导出状态变化能在整个依赖网络中正确传递。

  5. 智能缓存更新:分析完成后,插件将最新的导出状态信息同时写入内存和持久化缓存系统,优化后续构建过程。

该插件通过精确标记和追踪模块导出信息,为 Webpack 的 Tree Shaking 机制提供了关键的决策依据。它构建了一个详尽的"导出-使用"关系图,使 Webpack 能够准确识别并保留仅被实际引用的代码,从而显著减小最终打包体积。

经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。

最终 FlagDependencyExportsPlugin 插件的执行流程如下流程图所示:

┌──────────────────────────────────────────────────────────────────────┐
│                        注册到编译系统                                 │
│                compiler.hooks.compilation.tap                        │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       模块构建完成触发点                              │
│               compilation.hooks.finishModules.tapAsync               │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                          初始化阶段                                  │
├──────────────────────────────────────────────────────────────────────┤
│ ▸ 创建模块处理队列                                                   │
│ ▸ 设置统计变量                                                       │
│ ▸ 准备缓存访问                                                       │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       导出信息缓存恢复                                │
├──────────────────────────────────────────────────────────────────────┤
│ ① 对每个模块执行缓存检查:                                             │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │无导出类型├───→│标记为提供未知导出      │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │不可缓存  ├───→│加入分析队列            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │内存缓存  ├───→│恢复导出信息            │                         │
│    │命中     │    │跳过后续分析            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │持久缓存  ├───→│恢复导出信息            │                         │
│    │命中     │    │跳过后续分析            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │缓存未命中├───→│加入分析队列            │                         │
│    └─────────┘    └────────────────────────┘                         │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                         模块分析循环                                 │
├──────────────────────────────────────────────────────────────────────┤
│ ① 从队列取出一个模块                                                 │
│                        ↓                                             │
│ ② 遍历模块依赖,收集导出规范 ──→ processDependenciesBlock            │
│                        ↓                                             │
│ ③ 处理每个依赖的导出规范    ──→ processExportsSpec                   │
│     • 处理隐藏导出                                                   │
│     • 处理未知导出 (exports === true)                                │
│     • 处理具名导出列表                                               │
│                        ↓                                             │
│ ④ 检测导出信息变化        ┌──────┐                                   │
│                        │ 有变化 ├──→ 通知依赖此模块的模块重新分析    │
│                        └──────┘                                      │
│                        ↓                                             │
│ ⑤ 标记可缓存模块                                                     │
│                        ↓                                             │
│ ⑥ 重复执行直到队列为空                                               │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       导出信息缓存存储                                │
├──────────────────────────────────────────────────────────────────────┤
│ ① 遍历所有可缓存模块                                                 │
│                 ↓                                                    │
│ ② 提取模块导出信息 → getRestoreProvidedData()                        │
│                 ↓                                                    │
│ ③ 同时更新双层缓存:                                                  │
│    ┌────────────────┐    ┌────────────────┐                          │
│    │ 内存缓存       │    │ 持久化缓存     │                          │
│    │ memCache.set   │    │ cache.store    │                          │
│    └────────────────┘    └────────────────┘                          │
└──────────────────────────────────────────────────────────────────────┘

2. 标记模块导出

FlagDependencyUsagePlugin 是 webpack 中 Tree Shaking 机制的"使用端"标记器,它与 FlagDependencyExportsPlugin 形成完整的标记系统:前者负责标记"模块提供了什么",而本插件精确追踪"这些提供的内容被如何使用"。通过这种双向标记,webpack 能够精确识别未使用的代码并将其移除。

该插件接收一个 global 参数决定是执行全局分析还是按运行时环境分别分析,影响最终的代码分割和优化策略。

2.1 初始化与准备阶段

constructor(global) {
  this.global = global;
}

apply(compiler) {
  compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    const moduleGraph = compilation.moduleGraph;
    compilation.hooks.optimizeDependencies.tap(
      { name: PLUGIN_NAME, stage: STAGE_DEFAULT },
      modules => {
        // 缓存检查
        if (compilation.moduleMemCaches) {
          throw new Error("optimization.usedExports can't be used with cacheUnaffected...");
        }

        const logger = compilation.getLogger(PLUGIN_LOGGER_NAME);
        const exportInfoToModuleMap = new Map();
        const queue = new TupleQueue();

插件在 optimizeDependencies 钩子的默认阶段执行,确保在所有模块解析完成但优化尚未开始时运行。初始化阶段建立了两个关键数据结构:

  • exportInfoToModuleMap: exportInfoToModuleMap 是一个映射表,将导出信息对象关联回其所属的模块。它解决了嵌套导出场景下的模块定位问题 - 当处理深层嵌套导出路径(如obj.nested.property)时,需要找到嵌套导出信息所属的模块,以便在导出状态变化时将正确的模块加入处理队列。

如下图所:

20250327004616

这些都是我们前面讲过的。

  • queue: 用于存储待处理的模块和其运行时环境信息

2.2 模块导出使用处理器 (processReferencedModule)

这个核心函数精确标记每个导出的使用状态:

const processReferencedModule = (module, usedExports, runtime, forceSideEffects) => {
  const exportsInfo = moduleGraph.getExportsInfo(module);

  if (usedExports.length > 0) {
    // 处理模块有被使用的导出

这部分逻辑根据不同情况进行处理:

  1. 无导出类型模块处理:
if (!module.buildMeta || !module.buildMeta.exportsType) {
  if (exportsInfo.setUsedWithoutInfo(runtime)) {
    queue.enqueue(module, runtime);
  }
  return;
}

对于 CommonJS 或没有明确导出类型的模块,由于无法静态分析导出,标记为"完全使用"。

  1. 精确导出路径处理:
   for (const usedExportInfo of usedExports) {
     // 提取导出路径和混淆选项
     let usedExport;
     let canMangle = true;
     if (Array.isArray(usedExportInfo)) {
       usedExport = usedExportInfo;
     } else {
       usedExport = usedExportInfo.name;
       canMangle = usedExportInfo.canMangle !== false;
     }

这段代码处理两种导出引用格式:简单数组形式和带混淆标志的对象形式。如下代码所示:

import { moment } from "./moment";

moment.click();

最终输出结果如下:

20250327094809

  1. 整体导出对象使用:
if (usedExport.length === 0) {
  if (exportsInfo.setUsedInUnknownWay(runtime)) {
    queue.enqueue(module, runtime);
  }
}

空数组表示使用了整个导出对象(如import * as mod from './mod'),将导出标记为"未知方式使用"。

如下代码所示:

// moment.js
export const a = 1;

export const b = 2;

export const c = 3;

export const d = 4;

export const e = 5;

export const f = 6;

// index.js
import * as moment from "./moment";

console.log(moment);

最终输出结果如下图所示:

20250330171226

  1. 副作用处理:

    } else {
      // 没有使用导出,但可能有副作用
      if (
        !forceSideEffects &&
        module.factoryMeta !== undefined &&
        module.factoryMeta.sideEffectFree
      ) {
        return; // 跳过无副作用的未使用模块
      }
    
      if (exportsInfo.setUsedForSideEffectsOnly(runtime)) {
        queue.enqueue(module, runtime);
      }
    }
    

    当模块没有被使用的导出时,检查是否有副作用:

    • 如果标记为无副作用且未强制保留副作用,则完全跳过

    • 否则标记为"仅用于副作用"

2.3 小结

FlagDependencyUsagePlugin 是 webpack 树摇机制的核心实现,负责精确标记哪些导出被使用,哪些可以安全移除。在 Seal 阶段,它通过全面分析依赖图来优化最终产物体积。

主要流程如下:

  1. 初始化阶段:通过 compilation.hooks.optimizeDependencies 钩子触发插件执行,为每个模块创建导出信息 (exportInfo) 并存入 exportInfoToModuleMap,调用 setHasUseInfo() 初始化导出使用状态追踪。

  2. 入口分析:从所有入口依赖开始,调用 processEntryDependency 函数,处理全局入口、命名入口及其包含的依赖,初始入口模块默认标记为"副作用使用",确保入口代码保留。

  3. 依赖遍历与标记:使用队列和广度优先搜索遍历整个依赖图,对每个模块调用 processModule 收集其依赖引用的导出,通过 getDependencyReferencedExports 确定每个依赖使用了哪些导出,使用 setUsedConditionally 将被引用的导出标记为已使用。

  4. 特殊情况处理:对整个导出对象引用 (EXPORTS_OBJECT_REFERENCED) 特殊处理,对无导出引用但有副作用的模块标记为仅副作用使用,对无副作用 (sideEffectFree) 模块在无导出使用时可能完全跳过。

  5. 结果存储:所有标记结果记录在 exportInfo._usedInRuntime 属性中,这些信息将直接影响代码生成阶段保留或移除哪些代码。

这个精确的导出使用分析是 webpack 实现高效树摇的关键环节,确保最终包中只包含实际使用的代码,显著优化应用性能和加载时间。无论是基础类型导出还是复杂对象,插件都能准确追踪其使用情况,为后续优化提供可靠依据。

3. 删除 Dead Code

在执行了 FlagDependencyExportsPlugin 和 FlagDependencyUsagePlugin 插件的导出标记和使用标记过程后,Webpack 将会准确地知道哪些模块和导出被实际使用,哪些是未使用的。接下来,Webpack 会进行 删除未使用代码(Dead Code Elimination)的优化,这个过程在 Tree Shaking 的最后阶段,确保未被使用的代码从最终的打包结果中移除。

首先我们需要编写这样的 webpack 配置:

const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  mode: "production",
  optimization: {
    usedExports: true,
    sideEffects: true,
    minimize: true,
    minimizer: [
      new TerserPlugin({
        extractComments: false,
      }),
    ],
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },
};

最终我们要编写这样的实际代码:

20250330212340

在这里,我们在 moment.js 文件中定义了三个变量,导出了两个,有一个没有导出,我们再 index.js 文件中只使用了一个,当我们执行构建的时候,最终输出的结果如下图所示:

(() => {
  "use strict";
  console.log(1);
})();

完美 🎉🎉🎉🎉🎉🎉

总结

Tree Shaking 是一种基于 ES Module 的静态代码分析技术,通过精确识别并移除未被引用的代码来减小最终打包体积。它通过"标记-清除"两阶段流程实现:先由 FlagDependencyExportsPlugin 识别模块提供了什么,再由 FlagDependencyUsagePlugin 标记这些导出如何被使用,最后在生产环境中通过压缩工具物理移除未使用代码。Tree Shaking 的成功依赖于 ES Module 的静态结构特性,这也是它无法在 CommonJS 等动态模块系统中有效工作的根本原因。