基于源码的 Webpack 结构分析

简历缺少有技术深度的项目吗?最近在做开源,实现一个脚手架项目,涉及广泛的工程化知识

如果你感兴趣参与贡献,或者想加入社区聊聊技术、工作、八卦,可以加我:Tongxx_yj。

GitHub 链接:github.com/xun082/crea...

分享背景

即使目前优秀的构建工具层出不穷,Webpack 还是保持着其在现代前端开发工具链中不可替代的地位。这主要得益于其优秀的灵活性以及强大的生态系统。

然而,随着版本更替,Webpack 的功能越来越庞大,整体的代码量日渐夸张,大大提高了学习难度。与此同时,大多数人对 Webpack 的使用都停留在配置层面,这容易陷入几个问题:

  • 想实现某个功能,但是不清楚原理,只能花大量时间调研方案。

  • 简历上写了用 Webpack 实现了 xx 功能,结果面试官连续追问直至原理🫠。

因此,深入学习 Webpack 底层原理以及架构设计是非常必要的,考虑到 Webpack 体系庞大,这边依据结构将其分为三个部分:

  1. JS 打包的核心流程
  2. Plugin 的作用与原理
  3. Loader 的作用与原理

Attention:

  • 这篇分享着重在给读者梳理一个对 Webpack 完整的认知体系,并不会具体涉及到代码分割、按需加载、HMR、sourcemap、Tree-shaking 这一系列的功能实现,如果感兴趣,可以在浏览器单点搜索,学习相关的实现,后续我也会抽时间对这些重要功能逐一研究,整理一些文章出来。
  • 文中涉及大量源码,防止篇幅过长,已经做过压缩处理,同时加上了注释,方便阅读。
  • 本文会聚焦于 JS 打包的核心流程进行介绍,Plugin、Loader 会简单带过。

基本概念

摘自 webpack:

Webpack is a module bundler. Its main purpose is to bundle JavaScript files for >usage in a browser, yet it is also capable of transforming, bundling, or >packaging just about any resource or asset.

Webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler),它通过分析目标项目结构,找到 JavaScript 模块以及其项目中一些不能直接在浏览器运行的扩展语言(如 SCSS,TypeScript 等),并将其转换和打包为合适的格式供浏览器使用。

在了解 Webpack 原理前,我们需要先了解几个核心名词的概念:

  • 入口(Entry):构建的起点 。Webpack 从这里开始执行构建。通过 Entry 配置能够确定哪个文件作为构建过程的开始,进而识别出应用程序的依赖图谱

  • 模块(Module):构成应用的单元 。在 Webpack 的视角中,一切文件皆可视为模块,包括 JavaScript、CSS、图片或者是其他类型的文件。Webpack 从 Entry 出发,递归地构建出一个包含所有依赖文件的模块网络。

  • 代码块(Chunk):代码的集合体。Chunk 由模块合并而成,被用来优化输出文件的结构。Chunk 使得 Webpack 能够更灵活地组织和分割代码,支持代码的懒加载、拆分等高级功能。

  • 加载器(Loader):模块的转换器。Loader 让 Webpack 有能力去处理那些非 JavaScript 文件(Webpack 本身只理解 JavaScript)。通过 Loader,各种资源文件可以被转换为 Webpack 能够处理的模块,如将 CSS 转换为 JS 模块,或者将高版本的 JavaScript 转换为兼容性更好的形式(降级)。

  • 插件(Plugin):构建流程的参与者。Webpack 的构建流程中存在众多的事件钩子(hooks),Plugin 可以监听这些事件的触发,在触发时加入自定义的构建行为,如自动压缩打包后的文件、生成应用所需的 HTML 文件等。

我们可以根据一个结构图来理解 Webpack 的全流程:

JS 打包的核心流程

在这一部分,我们会淡化 Plugin、Loader 在构建过程中的影响,大家可以把 Webpack源码拉到本地,方便学习。

编译的开始

核心实现

Webpack 的执行入口在 ./lib/webpack.js,我们先来看一下核心函数的实现:

javascript 复制代码
const webpack = (
    // 接收 webpack 配置和可选的回调函数
    (options, callback) => {
      // 根据配置创建编译器的简化版函数
      const create = () => {
        let compiler // 定义编译器实例
        let watch = false // 是否开启观察模式的标志
        let watchOptions // 观察模式的配置
  
        // 如果配置是数组,处理为多重编译配置
        // ......
        return { compiler, watch, watchOptions };
      };
  
      // 核心创建和运行逻辑
      const { compiler, watch, watchOptions } = create();
      if (watch) {
        // 如果开启观察模式,调用 compiler.watch
        compiler.watch(watchOptions, callback);
      } else if (callback) {
        // 如果有回调函数,但没有开启观察模式,调用 compiler.run
        compiler.run(callback);
      }
      return compiler // 返回创建的编译器实例
    }
  );

其中,compiler.run(callback) 的执行正式开启了 Webpack 的编译过程。

compiler 和 compilation

在 Webpack 中,存在两个非常重要的核心对象:compilercompilation,它们的作用如下:

  • Compiler:Webpack 的核心,贯穿于整个构建周期。Compiler 封装了 Webpack 环境的全局配置,包括但不限于配置信息、输出路径等。
  • Compilation:表示单次的构建过程及其产出。与 Compiler 不同,Compilation 对象在每次构建中都是新创建的,描述了构建的具体过程,包括模块资源、编译后的产出资源、文件的变化,以及依赖关系的状态。在watch mode 下,每当文件变化触发重新构建时,都会生成一个新的 Compilation 实例。

Compiler 是一个长期存在的环境描述,贯穿整个 Webpack 运行周期;而 Compilation 是对单次构建的具体描述,每一次构建过程都可能有所不同。接下来我们主要会对 Compiler 进行深入的研究。

compiler 的创建过程

可以看到 compiler 是通过同一文件中的 createCompiler 创建的,我们先来看看相关的实现:

js 复制代码
const createCompiler = rawOptions => {
    // 标准化 webpack 配置,确保配置格式正确
    const options = getNormalizedWebpackOptions(rawOptions);
    // 应用基本的 webpack 配置默认值
    applyWebpackOptionsBaseDefaults(options);
    // 创建一个新的 Compiler 实例
    const compiler = new Compiler(options.context, options);
    // 应用 Node 环境相关的插件,设置基础设施日志
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    // 注册自定义插件(并不会立马执行,而是订阅相关 hooks 的触发)
    if (Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
            if (typeof plugin === "function") {
                // 如果插件是一个函数,直接调用并传入 compiler
                plugin.call(compiler, compiler);
            } else if (plugin) {
                // 如果插件是一个具有 apply 方法的对象,调用其 apply 方法
                plugin.apply(compiler);
            }
        }
    }
    // 应用剩余的 webpack 配置默认值
    applyWebpackOptionsDefaults(options);
    // 触发环境设置相关的钩子
    compiler.hooks.environment.call();
    // 触发环境设置完成后的钩子
    compiler.hooks.afterEnvironment.call();
    // 负责最终的配置合成与应用,注册所有内置插件
    new WebpackOptionsApply().process(options, compiler);
    // 触发编译器初始化完成的钩子
    compiler.hooks.initialize.call();
    return compiler;
};

从中我们可以得到这些信息:

  • 函数调用并返回了 compilercompiler.run(callback) 的逻辑会在后面研究;
  • 函数遍历了 plugins 数组,将用户配置的 plugin 进行注册,等待后续触发,这里涉及到 Tapable 相关的知识,我会在后面进行相关介绍。
  • 函数执行 compiler.hooks 来触发相关生命周期,这个行为会使相关的 plugin 进入执行状态。

WebpackOptionsApply().process 初始化

同时我们可以看看 new WebpackOptionsApply().process(options, compiler); 具体做了些什么。

打开 ./lib/WebpackOptionsApply.js,可以看到 WebpackOptionsApply 类中,只有一个 process 方法,代码体积非常庞大,做的主要工作就是:注册内置插件、依据 options 做初始化工作(大部分也是注册内置插件)。

可以看到,在执行 compiler.run() 之前,做了十分充足的准备工作,然后才是真正执行编译的过程,接下来我们来仔细研究一下 compiler.run() 的内容。

编译阶段

Compiler 的执行

打开 ./lib/Compiler.js,我们直接来看看 compiler.run() 的具体实现:

js 复制代码
run(callback) {
    // 编译完成的回调
    const onCompiled = (err, _compilation) => {
      const compilation = _compilation;
      // 检查是否应该输出结果
      if (this.hooks.shouldEmit.call(compilation) === false) {
        // 处理完成后的逻辑...
        return;
      }
  
      process.nextTick(() => {
        // 处理资源输出
        this.emitAssets(compilation, (err) => {
          // 其他处理逻辑...
        });
      });
    };
  
    // 真正开始编译的逻辑
    const run = () => {
      // 调用 beforeRun 和 run 钩子
      this.hooks.beforeRun.callAsync(this, (err) => {
        this.hooks.run.callAsync(this, (err) => {
          // 读取记录后开始编译
          this.readRecords((err) => {
            this.compile(onCompiled);
          });
        });
      });
    };
  
    run();
  }

可以看到,在整个函数实现中,触发了很多的 hooks,比如:beforeRun、run、afterDone......

其中的核心就是 run 周期中的回调函数:this.compile(onCompiled);

调用 compiler.compile()

同样的套路,我们直接来看源码:

js 复制代码
// 启动编译流程
compile(callback) {
  const params = this.newCompilationParams();

  // 在编译之前调用的钩子
  this.hooks.beforeCompile.callAsync(params, (err) => {
    // 触发编译开始的钩子
    this.hooks.compile.call(params);

    // 创建一个新的编译实例
    const compilation = this.newCompilation(params);

    this.hooks.make.callAsync(compilation, (err) => {
      // 完成模块构建
      this.hooks.finishMake.callAsync(compilation, (err) => {
        process.nextTick(() => {
          // 完成编译过程的准备工作
          compilation.finish((err) => {
            // 封闭编译记录,准备输出文件
            compilation.seal((err) => {
              // 编译完成后的钩子
              this.hooks.afterCompile.callAsync(compilation, (err) => {
                // 返回编译成功的回调
                return callback(null, compilation);
              });
            });
          });
        });
      });
    });
  });
}

好家伙,回调地狱被 Webpack 玩透了,我们先整理一下钩子的执行顺序: beforeCompile - compile - make - finishMake - afterCompile(其实并不完全,比如 seal)

其中核心的就是 complie 和 make 阶段。其中 complie 在函数中首先实现了 compilation 实例的创建,这一点我们不需要关心,那么接下来我们着重关注一下 make 阶段,顾名思义,这个阶段实现了整个编译过程。

然而我们在代码中并没有看到回调函数中与编译相关的逻辑,由此可以想到,相关的编译逻辑应该是通过钩子触发而调用的,所以我们要在全局中搜索 compiler.hooks.make.tapAsync,通过筛选最后锁定到相关的编译逻辑在 ./lib/EntryPlugin.js 中。

js 复制代码
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
    compilation.addEntry(context, dep, options, err => {
        callback(err);
    });
});

那么我们要研究一下这个插件是在哪里被注册的,通过全局搜索,发现它在 EntryOptionPlugin 中被实例化,再搜索 EntryOptionPlugin,发现在 WebpackOptionsApply 中被引入,很显然,这个插件在 compiler.``run() 之前就被注册好了(一切都成了闭环~)。

添加 Entry

回过头来,我们再来看看 EntryPlugin 中,compilation.addEntry 都干了什么。

js 复制代码
function addEntry(context, entry, optionsOrName, callback) {
  // TODO webpack 6 remove
  const options =
    typeof optionsOrName === "object" ? optionsOrName : { name: optionsOrName };

  this._addEntryItem(context, entry, "dependencies", options, callback);
}

function _addEntryItem(context, entry, target, options, callback) {
  const { name } = options;
  // 尝试获取或初始化入口数据
  let entryData = this.entries.get(name) || this.globalEntry;

  // 添加入口依赖
  entryData[target].push(entry);

  // 检查和合并选项,这里简化为直接使用传入选项
  entryData.options = { ...entryData.options, ...options };

  // 触发添加入口的钩子
  this.hooks.addEntry.call(entry, options);

  // 处理入口依赖的模块树,这里简化异步处理逻辑
  this.addModuleTree({ context, dependency: entry }, (err, module) => {
    // 入口添加成功
    this.hooks.succeedEntry.call(entry, options, module);
    callback(null, module);
  });
}

可以看到这里的工作就是处理 Entry,Entry 的添加过程中,会调用 addModuleTree(),依据代码的依赖关系递归构建模块树(Module Tree)

添加 Module

具体涉及到 addModuleTree() 及后续进程,其实就是生成 Module 的过程,为了让大家完全了解其中的内容,我们再继续看看其中的实现吧:

js 复制代码
addModuleTree({ context, dependency, contextInfo }, callback) {
    // 获取依赖的构造函数,并尝试从dependencyFactories中获取相应的模块工厂
    const Dep = dependency.constructor;
    const moduleFactory = this.dependencyFactories.get(Dep);

    // 使用模块工厂创建模块,并处理模块创建后的逻辑
    this.handleModuleCreation({
        factory: moduleFactory,
        dependencies: [dependency], // 传入的依赖作为数组
        originModule: null, // 原始模块,这里为null,因为是入口模块
        contextInfo, // 传入的上下文信息
        context // 传入的上下文路径
    }, (err, result) => {});
}

handleModuleCreation({factory, dependencies, originModule, contextInfo, context, recursive = true}, callback) {
    const moduleGraph = this.moduleGraph;
    const currentProfile = this.profile ? new ModuleProfile() : undefined;

    // 使用给定的工厂函数创建模块
    this.factorizeModule({currentProfile, factory, dependencies, originModule, contextInfo, context}, (err, factoryResult) => {
        const newModule = factoryResult.module;

        // 将新模块添加到编译过程中
        this.addModule(newModule, (err, module) => {
            // 更新模块图,设置解析后的模块和依赖
            this.updateModuleGraph({module, dependencies, originModule, factoryResult});

            // 处理模块的构建和依赖关系(这里存在递归)
            this._handleModuleBuildAndDependencies(originModule, module, recursive, callback);
        });
    });
}

紧接着在 addModule 中,添加相关的 Module

js 复制代码
addModule(module, callback) {
  this.addModuleQueue.add(module, callback);
}

this.addModuleQueue = new AsyncQueue({
  name: "addModule",
  parent: this.processDependenciesQueue,
  getKey: (module) => module.identifier(),
  processor: this._addModule.bind(this),
});

_addModule(module, callback) {
  // 将模块添加到编译过程中
  this._modules.set(identifier, module);
  this.modules.add(module);
  // 完成模块添加,执行回调
  callback(null, module);
}

但是在这里看不到构建的内容,经过查找,发现是在 addModule 回调中的_handleModuleBuildAndDependencies() 中执行构建:

js 复制代码
_handleModuleBuildAndDependencies(originModule, module, recursive, checkCycle, callback) {
    // 构建模块
    this.buildModule(module, err => {
        // 递归处理模块依赖
        this.processModuleDependencies(module, err => callback(err ? err : null, module));
    });
}

buildModule(module, callback) {
    this.buildQueue.add(module, callback);
}

this.buildQueue = new AsyncQueue({
    name: "build",
    parent: this.factorizeQueue,
    processor: this._buildModule.bind(this)
});

_buildModule(module, callback) {
  // 调用构建模块钩子,并添加到已构建模块集合中
  this.hooks.buildModule.call(module);
  this.builtModules.add(module);

  // 实际进行模块构建
  module.build(this.options, this, this.resolverFactory.get("normal", module.resolveOptions), this.inputFileSystem,
    (err) => {
      // 将构建后的模块存储到缓存中
      this._modulesCache.store(module.identifier(), null, module, (err) => {
        // 调用构建成功钩子,并返回成功
        this.hooks.succeedModule.call(module);
        return callback();
      });
    }
  );
}

// ./lib/Module.js 抽象的build方法,定义了模块构建的接口,子类应该实现这个方法以完成具体的构建逻辑。
build(options, compilation, resolver, fs, callback) {
    const AbstractMethodError = require("./AbstractMethodError");
    throw new AbstractMethodError();
}

// ./lib/NormalModule.js
build(options, compilation, resolver, fs, callback) {
  // 初始化模块构建状态
  this.resetBuildState();

  // 获取钩子
  const hooks = NormalModule.getCompilationHooks(compilation);

  // 执行构建过程
  return this._doBuild(options, compilation, resolver, fs, hooks, (err) => {
    hooks.beforeParse.call(this); // 调用解析前的钩子
    const source = this._source.source();
    // 解析模块内容
    this.parser.parse(this._ast || source, {
        source,
        current: this,
        module: this,
        compilation: compilation,
        options: options,
    });

    handleParseResult();
  });
}

虽然代码很长,但是其中的逻辑十分顺畅,唯一要注意的是 build() 存在一个继承的问题。

在 ./lib/NormalModule.js 的 build() 中,还可以看到 _doBuild()

js 复制代码
_doBuild(options, compilation, resolver, fs, hooks, callback) {
  // 调用加载器之前的钩子
  hooks.beforeLoaders.call(this.loaders, this, loaderContext);

  const processResult = (err, result) => {
    // 解析加载器返回的结果
    const source = result[0]; // 源代码
    const sourceMap = result.length >= 1 ? result[1] : null; // 源代码映射
    const extraInfo = result.length >= 2 ? result[2] : null; // 额外信息

    // 创建模块的源代码对象
    // ......
    return callback();
  };

  // 执行加载器处理流程
  runLoaders(
    {
      resource: this.resource,
      loaders: this.loaders,
      // 定义如何处理资源的函数
      processResource: (loaderContext, resourcePath, callback) => {
        // ...资源处理逻辑...
      },
    },
    (err, result) => {
      // 处理加载器返回的最终结果(设置模块的源码和抽象语法树 AST)
      processResult(err, result.result);
    }
  );
};

在这个过程中,Webpack 会使用 loader 处理 resource 并转化为 JS,将结果返回后于 processResult() 处理。

Loader 处理

毫无疑问,我们得看看 loader 的处理过程了。

可以看到 runLoaders 即为 loader 的处理函数,从 loader-runner 包中导入:

js 复制代码
const { getContext, runLoaders } = require("loader-runner");

那我们直接来看看 loader-runner 中的 loader 处理逻辑吧:

js 复制代码
exports.runLoaders = function runLoaders(options, callback) {
  var loaders = options.loaders || [];
  var loaderContext = options.context || {};

  // 创建 loader 对象
  loaders = loaders.map(createLoaderObject);
  loaderContext.loaders = loaders;
  // loaderContext 各类配置 ......

  // 迭代处理 loaders
  iteratePitchingLoaders(processOptions, loaderContext, function (err, result) {
    callback(null, {
      // ......
    });
  });
};

function iteratePitchingLoaders(options, loaderContext, callback) {
  // 如果已处理完所有 loader,开始处理资源
  if (loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);

  // 获取当前 loader
  var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

  // 如果当前 loader 的 pitch 方法已执行,移至下一个 loader
  if (currentLoaderObject.pitchExecuted) {
    loaderContext.loaderIndex++;
    return iteratePitchingLoaders(options, loaderContext, callback);
  }

  // 加载当前 loader 模块
  loadLoader(currentLoaderObject, function (err) {
    var fn = currentLoaderObject.pitch;
    currentLoaderObject.pitchExecuted = true;
    // 如果没有定义 pitch 方法,继续处理下一个 loader
    if (!fn) return iteratePitchingLoaders(options, loaderContext, callback);

    // 执行 pitch 方法
    runSyncOrAsync(fn, loaderContext, [xxx], function (err) {
      var args = Array.prototype.slice.call(arguments, 1);
      var hasArg = args.some(function (value) {
        return value !== undefined;
      });
      // 根据 pitch 方法的返回值决定是继续执行下一个 pitch 方法,还是转向正常的 loader 处理流程
      if (hasArg) {
        loaderContext.loaderIndex--;
        iterateNormalLoaders(options, loaderContext, args, callback);
      } else {
        iteratePitchingLoaders(options, loaderContext, callback);
      }
    });
  });
}

可以看到,在 loader-runner ,通过迭代处理每一个 loader,在 loadLoader 中检验并加载好 loader 后,在 loadLoader 的回调中执行了 fn(即 loader 的 pitch),完成了 loader 的处理。

Parse 处理

Parse 主要是对模块代码进行解析,构建出一个能够描述模块依赖关系的抽象语法树 (AST)。在解析阶段,webpack 会分析代码中的 importrequire 等语句,找出模块间的依赖关系,并据此构建出模块依赖图。这对于后续模块的合并、代码分割和 Tree Shaking 等优化操作至关重要。

简单来说,Loader 的工作是 "翻译" ,Parse 的工作是 "理解" 。那么我们接下来看看 parse 是如何运作的。

紧接 loader 之后,就要处理 runLoaders 中的回调函数了,在函数最后的 processResult() 中,可以看到又执行了另一个回调函数 callback(),通过回溯可以看到是在 build 中传入的。

里面可以看到有一个核心逻辑:

js 复制代码
const source = this._source.source();
this.parser.parse(this._ast || source, {
  source,
  current: this,
  module: this,
  compilation: compilation,
  options: options,
});

parse 的主要作用其实是处理模块间的依赖关系,并将关系数据存储在module.dependencies数组中。

this.parser.parse 这个函数从 ./lib/javascript/JavascriptParser.js 引入,其中 JavascriptParser 继承自 Parser,我们看看具体的实现:

js 复制代码
parse(source, state) {
  comments = []; // 初始化注释数组
  ast = JavascriptParser._parse(source, {
    sourceType: this.sourceType, // 设置源码类型(模块或脚本)
    onComment: comments, // 收集注释的回调
    onInsertedSemicolon: (pos) => semicolons.add(pos), // 收集插入的分号位置
  });

  // 返回传入的状态对象
  return state;
}

const { Parser: AcornParser } = require("acorn");
const parser = AcornParser.extend(importAssertions);

_parse(code, options) {
  ast = parser.parse(code, parserOptions);
  return ast;
}

可以看到 parse() 借助第三方库 acorn 实现了 AST 转换。

对 AST 不理解的同学可以看看相关的文章:前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用🔥

接下来就是打包封装模块的过程了,为了保持良好状态继续阅读,这边先用一个结构图来总结一下目前为止所有的事件进程:

打包封装模块

封装 Chunk

在处理好 Module 之后,我们就要研究 Webpack 是如何将 Module 打为 Chunk 了。这个过程是在哪里触发的呢?可以看到,后续的执行过程中并没有相关的逻辑了,那不妨再回去看看在 this.hooks.finishMake 之后还有什么逻辑:

我们看到一个单词:seal ,有"密封"的意思,顾名思义,应该就是封装 chunk 相关的实现,查看 compilation.seal(),确实有 chunk 处理的逻辑,那么我们就再来研究一下 seal 的内容:

js 复制代码
seal(callback) {
  // 创建ChunkGraph,是模块和chunks之间关系的核心数据结构
  this.chunkGraph = new ChunkGraph(xxx);

  // 创建chunks的初始化Map,用于记录入口点与其直接和间接依赖的映射
  const chunkGraphInit = new Map();
  for (const [name, { dependencies, includeDependencies, options }] of this.entries) {
    // 为每个入口点创建一个chunk
    const chunk = this.addChunk(name);

    // 创建Entrypoint对象,并设置其对应的chunk
    const entrypoint = new Entrypoint(options);
    entrypoint.setRuntimeChunk(chunk);
    entrypoint.setEntrypointChunk(chunk);

    // 记录入口点和其关联的chunk group
    this.namedChunkGroups.set(name, entrypoint);
    this.entrypoints.set(name, entrypoint);
    this.chunkGroups.push(entrypoint);

    // 连接chunk group和chunk
    connectChunkGroupAndChunk(entrypoint, chunk);

    // 处理入口点直接和间接依赖的模块
    const entryModules = new Set();
    for (const dep of [...this.globalEntry.dependencies, ...dependencies]) {
      const module = this.moduleGraph.getModule(dep);
      if (module) {
        // 将模块与chunk和entrypoint关联起来
        chunkGraph.connectChunkAndEntryModule(chunk, module, entrypoint);
        entryModules.add(module);
        // 记录模块到chunkGraphInit中,为后续构建chunk graph做准备
        const modulesList = chunkGraphInit.get(entrypoint) || [];
        modulesList.push(module);
        chunkGraphInit.set(entrypoint, modulesList);
      }
    }

    // 处理包括的模块
    const includedModules = [
      ...this.mapAndSortDependencies(includeDependencies),
    ];
    let modulesList = chunkGraphInit.get(entrypoint) || [];
    for (const module of includedModules) {
      modulesList.push(module);
    }
    chunkGraphInit.set(entrypoint, modulesList);
  }

  // 构建chunk graph,确定模块如何分布在chunks中
  buildChunkGraph(this, chunkGraphInit);
  this.hooks.afterChunks.call(this.chunks);

  // this.hooks.afterSeal.callAsync(()=>{}) 内容省略
  this.hooks.beforeChunkAssets.call();
  this.createChunkAssets((err) => {
    cont();
  });

  callback();
}

seal 的内容非常多,经过压缩后,可以提炼出一个核心的处理过程:

  1. 创建 ChunkGraph :初始化ChunkGraph实例,确定哪些模块属于哪个 chunk,以及 chunks 之间如何相互引用。

  2. 遍历入口点:对于配置中定义的每个入口点,执行以下步骤:

    1. 创建 chunk:为每个入口点创建一个新的 chunk。这个 chunk 将作为从该入口点构建出的所有模块的容器。
    2. 创建并设置 Entrypoint:为每个 chunk 创建一个Entrypoint对象,确保了每个入口点都有一个对应的 chunk,且该 chunk 包含了入口点及其依赖的所有模块。
    3. 处理依赖关系:将入口点的直接和间接依赖的模块与创建的 chunk 进行关联。
  3. 构建 Chunk Graph :使用收集的信息(入口点、依赖等)构建完整的chunkGraph

  4. 生成 Chunk Assets:将 chunk 转换为输出的 assets,这一步会在后面进行探究。

这里还有一个值得琢磨的点:代码拆分是如何实现的?chunk 都有什么类型?封装规则如何?

如果详细研究,篇幅估计要难以承受,这边考虑放到后面单独起一篇文章进行研究🤤。

不过我们可以先了解最基础的两种 chunk:

  • Entry Chunks

    • 规则:每个入口点(entry point)至少生成一个entry chunk。
    • 目的:确保应用或页面的入口有一个对应的chunk,包含所有必要的启动代码。
    • 配置:通过 entry 配置指定。
  • Async Chunks

    • 规则:使用 import() 语句导入的模块会被封装到一个新的 async chunk 中。
    • 目的:实现代码拆分和懒加载,优化初始加载时间,按需加载额外功能。
    • 配置:无需特殊配置,Webpack 自动处理动态导入。

通过 emit 将 Assets 输出

那么接下来我们在聚焦于将 chunk 转换为 assets 的实现逻辑,可以看到 createChunkAssets 中的具体实现:

js 复制代码
function createChunkAssets(callback) {
  asyncLib.forEachLimit(
    this.chunks,
    15,
    (chunk, callback) => {
      let manifest;
      // 获取chunk将要生成的assets的清单
      manifest = this.getRenderManifest({ chunk, xxx});

      // 处理manifest中的每个文件
      asyncLib.forEach(
        manifest,
        (fileManifest, callback) => {
          // 调用render方法渲染出最终的资源内容,触发renderManifest钩子
          source = fileManifest.render();
          this.emitAsset(file, source || alreadyWritten.source, assetInfo);
        },
        callback
      );
    },
    callback
  );
}

可以看到核心的处理过程在 render()emitAsset() 中,我们先来看看 render() 中 renderManifest 触发了哪些插件运行。

可见 render() 触发了对不同资源的处理,打出最终的资源内容 Source。它用于表示文件的内容及其相关的 source map 信息(如果存在),通常包含以下主要的方法和属性:

  • source(): 返回文件内容的字符串表示。

  • map(): 返回与文件内容关联的 source map。

  • size(): 返回内容的大小。

紧接着,最终 Source 被传入了 emitAsset() 中,用来将生成的资源(assets)添加到最终输出的一部分。可是观察代码,并没有最后的 emit hook 触发,那么最后的输出在哪里呢?

不妨全局搜索 this.hooks.emit,发现在 compiler.emitAssets 中触发,同时 compiler.emitAssetscompiler.runonCompiled 中被调用,可见这个操作就是一开始的 this.compile(onCompiled) 中传入的回调函数,一切又形成了闭环🤣。

那么我们来看看 compiler.emitAssets 做了些什么吧!

js 复制代码
emitAssets(compilation, callback) {
  let outputPath = compilation.getPath(this.outputPath, {});

  // 负责将资源写入文件系统
  const emitFiles = () => {
    const assets = compilation.getAssets(); // 获取所有准备好的资源

    // 遍历所有资源,并写入文件系统
    for (const { name: file, source } of assets) {
      let targetFile = file; // 目标文件名
      const targetPath = join(this.outputFileSystem, outputPath, targetFile); // 目标路径

      // 获取资源的内容
      const getContent = () => {
        return typeof source.buffer === "function"
          ? source.buffer()
          : Buffer.from(source.source(), "utf8");
      };

      // 获取内容并写入文件系统
      const content = getContent();
      this.outputFileSystem.writeFile(targetPath, content, (err) => {
        if (err) return callback(err); // 如果有错误,执行回调函数
        compilation.emittedAssets.add(file); // 标记资源已发射
        // 可以在这里调用更多的钩子,例如 assetEmitted
      });
    }
  };

  // 调用emit钩子并开始写入文件
  this.hooks.emit.callAsync(compilation, (err) => {
    if (err) return callback(err); // 如果有错误,执行回调函数
    mkdirp(this.outputFileSystem, outputPath, emitFiles); // 确保输出目录存在,然后开始写入文件
  });
}

可以看到在 compiler.emitAssets 中执行了 mkdirp(),会根据 webpack.config.js 中的 output.path 属性输出文件至目标路径,至此,全部流程就完成了!

同样的,我们再通过一个完整的结构图来回顾一下具体的核心流程:

一些常见问题总结

Chunk 和 Bundle 的区别

其实可以把 Bundle 理解为 Asset 的子集。

  • Bundle:主要是 JavaScript 文件,也可以包含其他类型的文件(如通过插件或 loader 生成的 CSS、HTML )。
  • Asset:指构建过程中生成的任何类型的文件,包括 Bundle 本身和其他所有资源(如图片、字体、样式表等)。

如何手写 Webpack 插件

这需要进一步了解一下 Tapable 的内容了,目前有很多优秀的文章可以直接学习:

干货!撸一个webpack插件(内含tapable详解+webpack流程)

构建工具的横向对比

目前流行的构建工具其实很多,我们通过一个表格来进行初步比对:

工具 Webpack Rollup Parcel Vite esbuild Rspack Grunt
主要特点 模块打包器 ES模块打包 零配置 快速启动 基于 Go 基于 Rust 任务运行器
适用场景 大型项目,需要复杂配置的应用 库、小型项目,需要优化包大小 快速原型开发,小到中型项目,零配置 现代Web应用,利用ESM快速开发 快速开发、测试时使用,对产物大小优化有限 类似Esbuild,适合现代Web开发快速迭代 多任务自动化,适合广泛的自动化工作流场景
构建速度 较慢 中等 极快 极快 中等
配置复杂度 复杂 简单 零配置 简单 简单 简单 繁琐
插件生态 丰富 适中 适中 丰富 成长中 成长中 丰富

不同的构建工具都有各自的优点,创建项目时,需要综合自己的需求进行选择。

怎么读源码

阅读源码确实是一件非常艰难且挑战耐心的事情,我将结合自身的一些经验,分享一些阅读源码的心得:

  • 调整状态、心态:

    • 自驱力:为什么要读源码?想要什么结果?自驱力是持续学习的动力源。
    • 好奇心、探究心: 对代码背后的逻辑、架构设计和技术决策保持好奇心,不满足于表面的了解,而是深入探究其原理和实现方式。对不懂的、不熟悉的部分保持探究心,通过查阅文档、搜索、实验和询问他人来获得理解
    • 耐力:读源码是一个复杂且耗时的任务,需要长时间的专注和努力。在面对难以理解的代码时,耐心地分析、逐步深入,有时也许需要多次阅读和反复实验才能获得透彻的理解。
    • 安静:保持安静!让自己能够深入地专注于阅读和理解代码。尤其是不要边听歌边读源码(很难专注)。
  • 掌握工具:

    • 调试源码:尝试使用 debugger 调试代码,跟踪整个运行过程。
    • 查阅文档:基于一些社区资料(可以是他人的总结,也可以是官方文档......)协助源码的阅读。
    • 结合 AI 精简源码:可以将各种代码投喂给 AI 工具,帮代码打出注释,删除异常处理、日志等不重要的内容。
    • 文档记录:阅读的过程中可以同步的记录于文档,这篇文章就是一个参考,这样方便上下翻看精简后的代码,同时也能让自己的思路更加清晰。

最后,我们还可以基于目标源码的特点来协助阅读。拿 Webpack 来说,虽然代码结构十分复杂,回调地狱满天飞,但是它的 hooks 机制能十分有效地帮助我们了解整体的进程,可以考虑在阅读之前,先从整体了解项目的机制,结合画图来拆解架构,大家不妨试试看!

最后

不得不说,Webpack 的内容实在太多了,本想控制一下篇幅,但源码的战线实在拉的太长了,尽管去除了大量无关的内容,还是让文章达到了接近 2w 的字符数。

为了进一步深入 Webpack,后续还会坚持更新更多相关的内容,既然这一篇已经将整体结构梳理完了,那么后面就会考虑从一个具体的模块中进行分析,探索 Webpack 更为细节的内容实现~

与此同时,对于文中不清晰或有误的地方,欢迎大家参与讨论!

相关推荐
twins352042 分钟前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky1 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n02 小时前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
昨天;明天。今天。2 小时前
案例-任务清单
前端·javascript·css
zqx_73 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己3 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称4 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色4 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript