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

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

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:

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

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

    json 复制代码
    {
      "name": "moment",
      "sideEffects": false
    }

    或指定有副作用的文件:

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

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

javascript 复制代码
// 不推荐(无法 tree shake)
import _ from "lodash";

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

为什么 Commonjs 不能实现 tree shaking

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

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

javascript 复制代码
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 中是非法的:

javascript 复制代码
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 开启,如下代码所示:

js 复制代码
const path = require("path");

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

  optimization: {
    usedExports: true,
  },
};

然后我们编写如下代码:

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

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

js 复制代码
/*
 * 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 对象

如下代码所示:

对应的 dependencies 值为:

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

FlagDependencyExportsPlugin 插件执行流程详解

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

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

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

2. 主要执行阶段

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

javascript 复制代码
compilation.hooks.finishModules.tapAsync(PLUGIN_NAME, (modules, callback) => {
  // 主要逻辑
});
3. 初始化阶段
javascript 复制代码
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. 导出变量的重定向信息:如果导出是重定向到其他模块的导出,则包含目标信息

javascript 复制代码
// 恢复缓存的示例代码
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 使编译器能够理解代码间的内在联系,从而实现智能代码生成和高效打包优化。

如下图所示:

这种设计不仅确保了 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 时使用)

javascript 复制代码
// 导出规范的例子
{
  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 等特殊方式定义时

可以混淆的情况:

javascript 复制代码
// module.js
export const calculateTotal = (a, b) => a + b;

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

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

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

不能混淆的情况:

javascript 复制代码
// 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. 处理依赖块和依赖 - 收集导出规范
javascript 复制代码
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

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

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

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

如下图所示:

7. 处理导出规范 - 核心逻辑详解
javascript 复制代码
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. 通知依赖 - 级联更新
javascript 复制代码
const notifyDependencies = () => {
  const deps = dependencies.get(module);
  if (deps !== undefined) {
    for (const dep of deps) {
      queue.enqueue(dep);
    }
  }
};

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

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

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

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

9. 缓存导出信息 - 为下次构建做准备
javascript 复制代码
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. 模块重建支持 - 热更新的基础
javascript 复制代码
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:具名导出

javascript 复制代码
// module.js
export const foo = 123;
export function bar() {}

对应的导出规范:

javascript 复制代码
{
  exports: [
    { name: "foo", canMangle: true },
    { name: "bar", canMangle: true }
  ],
  canMangle: true
}

例子 2:默认导出

javascript 复制代码
// module.js
export default class MyClass {}

对应的导出规范:

javascript 复制代码
{
  exports: [
    { name: "default", canMangle: true }
  ],
  canMangle: true
}

例子 3:重导出

javascript 复制代码
// module.js
export { foo, bar as baz } from "./other-module";

对应的导出规范:

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

例子 4:星号导出

javascript 复制代码
// module.js
export * from "./utils";

对应的导出规范:

javascript 复制代码
{
  exports: true,
  from: "./utils",
  canMangle: true
}
小结

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

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

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

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

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

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

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

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

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

text 复制代码
┌──────────────────────────────────────────────────────────────────────┐
│                        注册到编译系统                                 │
│                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 初始化与准备阶段

javascript 复制代码
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)时,需要找到嵌套导出信息所属的模块,以便在导出状态变化时将正确的模块加入处理队列。

如下图所:

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

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

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

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

javascript 复制代码
const processReferencedModule = (module, usedExports, runtime, forceSideEffects) => {
  const exportsInfo = moduleGraph.getExportsInfo(module);

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

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

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

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

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

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

js 复制代码
import { moment } from "./moment";

moment.click();

最终输出结果如下:

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

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

如下代码所示:

js 复制代码
// 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);

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

  1. 副作用处理:

    javascript 复制代码
    } 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. 生成代码

4. 删除 Dead Code

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

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

js 复制代码
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",
  },
};

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

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

js 复制代码
(() => {
  "use strict";
  console.log(1);
})();

完美 🎉🎉🎉🎉🎉🎉

总结

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

相关推荐
醉城夜风~31 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子4 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅4 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc886 小时前
微软EDGE浏览器功能学习
前端·学习·edge
破z晓6 小时前
javascript 导出excel表
开发语言·javascript·excel