基于源码的 Webpack 结构分析

共 38765字,需浏览 78分钟

 ·

2024-04-16 20:08

分享背景

即使目前优秀的构建工具层出不穷,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(https://github.com/webpack/webpack)源码拉到本地,方便学习。

「编译的开始」

核心实现

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

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 创建的,我们先来看看相关的实现:

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() 的具体实现:

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()」

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

// 启动编译流程
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 中。

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

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

添加 Entry

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

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(nullmodule);
  });
}

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

添加 Module

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

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

    // 使用模块工厂创建模块,并处理模块创建后的逻辑
    this.handleModuleCreation({
        factory: moduleFactory,
        dependencies: [dependency], // 传入的依赖作为数组
        originModulenull// 原始模块,这里为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

addModule(module, callback) {
  this.addModuleQueue.add(module, callback);
}

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

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

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

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

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

this.buildQueue = new AsyncQueue({
    name"build",
    parentthis.factorizeQueue,
    processorthis._buildModule.bind(this)
});

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

  // 实际进行模块构建
  module.build(this.options, thisthis.resolverFactory.get("normal"module.resolveOptions), this.inputFileSystem,
    (err) => {
      // 将构建后的模块存储到缓存中
      this._modulesCache.store(module.identifier(), nullmodule, (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,
        currentthis,
        modulethis,
        compilation: compilation,
        options: options,
    });

    handleParseResult();
  });
}

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

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

_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(
    {
      resourcethis.resource,
      loadersthis.loaders,
      // 定义如何处理资源的函数
      processResource(loaderContext, resourcePath, callback) => {
        // ...资源处理逻辑...
      },
    },
    (err, result) => {
      // 处理加载器返回的最终结果(设置模块的源码和抽象语法树 AST)
      processResult(err, result.result);
    }
  );
};

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

Loader 处理

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

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

const { getContext, runLoaders } = require("loader-runner");

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

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(arguments1);
      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 中传入的。

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

const source = this._source.source();
this.parser.parse(this._ast || source, {
  source,
  currentthis,
  modulethis,
  compilation: compilation,
  options: options,
});

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

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

parse(source, state) {
  comments = []; // 初始化注释数组
  ast = JavascriptParser._parse(source, {
    sourceTypethis.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的广泛应用(https://juejin.cn/post/7155151377013047304)

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

打包封装模块

封装 Chunk

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

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

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 中的具体实现:

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 做了些什么吧!

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 属性输出文件至目标路径,至此,全部流程就完成了!

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

image.png

一些常见问题总结

Asset 和 Bundle 的区别

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

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

如何手写 Webpack 插件

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

干货!撸一个webpack插件(内含tapable详解+webpack流程)(https://juejin.cn/post/6844903713312604173)

构建工具的横向对比

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

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

怎么读源码

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

  • 调整状态、心态

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

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

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

最后

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

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

与此同时,对于文中不清晰或有误的地方,欢迎阅读原文评论区讨论!

浏览 413
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
1点赞
评论
收藏
分享

手机扫一扫分享

分享
举报