Skip to content

webpack工作原理

首先,我们要理解一个点,Webpack 最核心的功能:

At its core, webpack is a static module bundler for modern JavaScript applications.

也就是将各种类型的资源,包括图片、css、js等,转译、组合、拼接、生成 JS 格式的 bundler 文件。官网首页的动画很形象地表达了这一点:

1688558122245.png

基本概念

在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:

  • Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  • Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  • Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

流程概括

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

流程细节

这个过程核心完成了 内容转换 + 资源合并 两种功能,实现上包含三个阶段:

  1. 初始化阶段
    1. 初始化参数: 从配置文件、配置对象、Shell参数中读取,与默认配置结合的到最终参数
    2. 创建初始化编译器对象: 用上一步得到的参数创建 Complier 对象
    3. 初始化编译环境: 包括注入的插件、注册各种模块工厂、初始化RuleSet集合 、加载配置插件等
    4. 开始编译: 执行complierrun 方法
    5. 确定入口: 根据配置文件中的entry 抓出所有的入口文件,调用complication.addEntry 将 入口文件转化成 dependence 对象
  2. 构建阶段
    1. 编译模块(make): 根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理
    2. 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
  3. 生成阶段:
    1. 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
    2. 写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

1688556234449.png

在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用。

初始化阶段

JavaScript
/**
 * @param {WebpackOptions} rawOptions options object
 * @returns {Compiler} a compiler
 */
const createCompiler = rawOptions => {
        const options = getNormalizedWebpackOptions(rawOptions);
        applyWebpackOptionsBaseDefaults(options);
  			// 实例化 Compiler
        const compiler = new Compiler(options.context, options);
        new NodeEnvironmentPlugin({
                infrastructureLogging: options.infrastructureLogging
        }).apply(compiler);
        if (Array.isArray(options.plugins)) {
                for (const plugin of options.plugins) {
                        if (typeof plugin === "function") {
                                plugin.call(compiler, compiler);
                        } else {
                                plugin.apply(compiler);
                        }
                }
        }
        applyWebpackOptionsDefaults(options);
        compiler.hooks.environment.call();
        compiler.hooks.afterEnvironment.call();
        new WebpackOptionsApply().process(options, compiler);
        compiler.hooks.initialize.call();
        return compiler;
};

1688556350376.png

解释一下:

  1. process.args + webpack.config.js 合并成用户配置
  2. 调用 validateSchema 校验配置
  3. 调用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults 合并出最终配置
  4. 创建 compiler 对象
  5. 遍历用户定义的 plugins 集合,执行插件的 apply 方法
  6. 调用 new WebpackOptionsApply().process 方法,加载各种内置插件

主要逻辑集中在 WebpackOptionsApply 类,webpack 内置了数百个插件,这些插件并不需要我们手动配置,WebpackOptionsApply 会在初始化阶段根据配置内容动态注入对应的插件,包括:

  • 注入 EntryOptionPlugin 插件,处理 entry 配置
  • 根据 devtool 值判断后续用那个插件处理 sourcemap,可选值:EvalSourceMapDevToolPluginSourceMapDevToolPluginEvalDevToolModulePlugin
  • 注入 RuntimePlugin ,用于根据代码内容动态注入 webpack 运行时

到这里,compiler 实例就被创建出来了,相应的环境参数也预设好了,紧接着开始调用 compiler.compile 函数:

JavaScript
// 取自 webpack/lib/compiler.js 
/**
 * @param {Callback<Compilation>} callback signals when the compilation finishes
 * @returns {void}
 */
compile(callback) {
        const params = this.newCompilationParams();
        this.hooks.beforeCompile.callAsync(params, err => {
                if (err) return callback(err);
                this.hooks.compile.call(params);
                const compilation = this.newCompilation(params);
                const logger = compilation.getLogger("webpack.Compiler");
                logger.time("make hook");
                this.hooks.make.callAsync(compilation, err => {
                        logger.timeEnd("make hook");
                        if (err) return callback(err);
                        logger.time("finish make hook");
                        this.hooks.finishMake.callAsync(compilation, err => {
                                logger.timeEnd("finish make hook");
                                if (err) return callback(err);
                                process.nextTick(() => {
                                        logger.time("finish compilation");
                                        compilation.finish(err => {
                                                logger.timeEnd("finish compilation");
                                                if (err) return callback(err);
                                                logger.time("seal compilation");
                                                compilation.seal(err => {
                                                        logger.timeEnd("seal compilation");
                                                        if (err) return callback(err);
                                                        logger.time("afterCompile hook");
                                                        this.hooks.afterCompile.callAsync(compilation, err => {
                                                                        logger.timeEnd("afterCompile hook");
                                                                        if (err) return callback(err);
                                                                        return callback(null, compilation);
                                                                });
                                                        });
                                                });
                                        });
                                });
                        });
                });
        }

Webpack 架构很灵活,但代价是牺牲了源码的直观性,比如说上面说的初始化流程,从创建 compiler 实例到调用 make 钩子,逻辑链路很长:

  • 启动 webpack ,触发 lib/webpack.js 文件中 createCompiler 方法
  • createCompiler 方法内部调用 WebpackOptionsApply 插件
  • WebpackOptionsApply 定义在 lib/WebpackOptionsApply.js 文件,内部根据 entry 配置决定注入 entry 相关的插件,包括:DllEntryPluginDynamicEntryPluginEntryPluginPrefetchPluginProgressPluginContainerPlugin
  • Entry 相关插件,如 lib/EntryPlugin.jsEntryPlugin 监听 compiler.make 钩子
  • lib/compiler.jscompile 函数内调用 this.hooks.make.callAsync
  • 触发 EntryPluginmake 回调,在回调中执行 compilation.addEntry 函数
  • compilation.addEntry 函数内部经过一坨与主流程无关的 hook 之后,再调用 handleModuleCreate 函数,正式开始构建内容

这个过程需要在 webpack 初始化的时候预埋下各种插件,经历 4 个文件,7次跳转才开始进入主题。

构建阶段

基本流程

lib/Compilation.js

你有没有思考过这样的问题:

  • Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
  • Webpack 编译过程中,如何识别资源对其他资源的依赖?
  • 相对于 grunt、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?

这些问题,基本上在构建阶段都能看出一些端倪。构建阶段从 entry 开始递归解析资源与资源的依赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:

1688557492468.png

暂时无法在{app_display_name}文档外展示此内容

解释一下,构建阶段从入口文件开始:

  1. 调用 handleModuleCreate ,根据文件类型构建 module 子类

  2. 调用 loader-runner 仓库的 runLoaders 转译 module 内容,通常是从各类资源类型转译为 JavaScript 文本

  3. 调用 acorn 将 JS 文本解析为AST

  4. 遍历 AST,触发各种钩子

    1. HarmonyExportDependencyParserPlugin 插件监听 exportImportSpecifier 钩子,解读 JS 文本对应的资源依赖
    2. 调用 module 对象的 addDependency 将依赖对象加入到 module 依赖列表中
  5. AST 遍历完毕后,调用 module.handleParseResult 处理模块依赖

  6. 对于 module 新增的依赖,调用 handleModuleCreate ,控制流回到第一步

  7. 所有依赖都解析完毕后,构建阶段结束

这个过程中数据流 module => ast => dependences => module ,先转 AST 再从 AST 找依赖。这就要求 loaders 处理完的最后结果必须是可以被 acorn 处理的标准 JavaScript 语法,比如说对于图片,需要从图像二进制转换成类似于 export default "data:image/png;base64,xxx" 这类 base64 格式或者 export default "http://xxx" 这类 url 格式。

compilation 按这个流程递归处理,逐步解析出每个模块的内容以及 module 依赖关系,后续就可以根据这些内容打包输出。

总结

  • Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?
    • 构建阶段会读取源码,解析为 AST 集合。
    • Webpack 读出 AST 之后仅遍历 AST 集合;babel 则对源码做等价转换
  • Webpack 编译过程中,如何识别资源对其他资源的依赖?
    • Webpack 遍历 AST 集合过程中,识别 require/ import 之类的导入语句,确定模块对其他资源的依赖关系
  • 相对于 grant、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?
    • Grant、Gulp 仅执行开发者预定义的任务流;而 webpack 则深入处理资源的内容,功能上更强大

生成阶段

基本流程

构建阶段围绕 module 展开,生成阶段则围绕 chunks 展开。经过构建阶段之后,webpack 得到足够的模块内容与模块关系信息,接下来开始生成最终资源了。代码层面,就是开始执行 compilation.seal 函数:

js
// 取自 webpack/lib/compiler.js 
compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err => {
      // ...
      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 => {...});
            });
          });
        });
      });
    });
  }

seal 原意密封、上锁,我个人理解在 webpack 语境下接近于 “将模块装进蜜罐” 。seal 函数主要完成从 modulechunks 的转化,核心流程:

1688557640618.png

简单梳理一下:

  1. 构建本次编译的 ChunkGraph 对象;
  2. 遍历 compilation.modules 集合,将 moduleentry/动态引入 的规则分配给不同的 Chunk 对象;
  3. compilation.modules 集合遍历完毕后,得到完整的 chunks 集合对象,调用 createXxxAssets 方法
  4. createXxxAssets 遍历 module/chunk ,调用 compilation.emitAssets 方法将资 assets 信息记录到 compilation.assets 对象中
  5. 触发 seal 回调,控制流回到 compiler 对象

这一步的关键逻辑是将 module 按规则组织成 chunks ,webpack 内置的 chunk 封装规则比较简单:

  • entry 及 entry 触达到的模块,组合成一个 chunk
  • 使用动态引入语句引入的模块,各自组合成一个 chunk

chunk 是输出的基本单位,默认情况下这些 chunks 与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个 entry 会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源,我们来看个示例。

写入文件系统

经过构建阶段后,compilation 会获知资源模块的内容与依赖关系,也就知道“输入”是什么;而经过 seal 阶段处理后, compilation 则获知资源输出的图谱,也就是知道怎么“输出”:哪些模块跟那些模块“绑定”在一起输出到哪里。seal 后大致的数据结构:

js
compilation = {
  // ...
  modules: [
    /* ... */
  ],
  chunks: [
    {
      id: "entry name",
      files: ["output file name"],
      hash: "xxx",
      runtime: "xxx",
      entryPoint: {xxx}
      // ...
    },
    // ...
  ],
};

seal 结束之后,紧接着调用 compiler.emitAssets 函数,函数内部调用 compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,实现逻辑比较曲折,但是与主流程没有太多关系,所以这里就不展开讲了。

资源形态流转

OK,上面已经把逻辑层面的构造主流程梳理完了,这里结合资源形态流转的角度重新考察整个过程,加深理解:

1688557748616.png

  • compiler.make 阶段:
    • entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息
    • 根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module
  • compilation.seal 阶段:
    • 遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk
    • 遍历 chunk 集合,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合
  • compiler.emitAssets 阶段:
    • assets 写入文件系统

前端知识体系 · wcrane