[万字总结] 一文吃透 Webpack 核心原理

若是以为文章有用,欢迎点赞关注,但写做实属不易,未经做者赞成,禁止任何形式转载!!!

image.png

背景

Webpack 特别难学!!!javascript

时至 5.0 版本以后,Webpack 功能集变得很是庞大,包括:模块打包、代码分割、按需加载、HMR、Tree-shaking、文件监听、sourcemap、Module Federation、devServer、DLL、多进程等等,为了实现这些功能,webpack 的代码量已经到了惊人的程度:css

  • 498 份JS文件
  • 18862 行注释
  • 73548 行代码
  • 54 个 module 类型
  • 69 个 dependency 类型
  • 162 个内置插件
  • 237 个hook

在这个数量级下,源码的阅读、分析、学习成本很是高,加上 webpack 官网语焉不详的文档,致使 webpack 的学习、上手成本极其高。为此,社区围绕着 Webpack 衍生出了各类手脚架,好比 vue-clicreate-react-app,解决“用”的问题。html

但这又致使一个新的问题,大部分人在工程化方面逐渐变成一个配置工程师,停留在“会用会配”可是不知道黑盒里面究竟是怎么转的阶段,遇到具体问题就瞎了:vue

  • 想给基础库作个升级,出现兼容性问题跑不动了,直接放弃
  • 想优化一下编译性能,可是不清楚内部原理,无从下手

究其缘由仍是对 webpack 内部运行机制没有造成必要的总体认知,没法迅速定位问题 —— 对,连问题的本质都经常看不出,所谓的不能透过现象看本质,那本质是啥?我我的将 webpack 整个庞大的体系抽象为三方面的知识:java

  1. 构建的核心流程
  2. loader 的做用
  3. plugin 架构与经常使用套路

三者协做构成 webpack 的主体框架:node

理解了这三块内容就算是入了个门,对 Webpack 有了一个最最基础的认知了,工做中再遇到问题也就能按图索骥了。补充一句,做为一份入门教程,本文不会展开太多 webpack 代码层面的细节 —— 个人精力也不容许,因此读者也不须要看到一堆文字就产生特别大的心理负担。react

核心流程解析

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

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

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

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

  1. 初始化阶段:

    1. 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
    2. 建立编译器对象:用上一步获得的参数建立 Compiler 对象
    3. 初始化编译环境:包括注入内置插件、注册各类模块工厂、初始化 RuleSet 集合、加载配置的插件等
    4. 开始编译:执行 compiler 对象的 run 方法
    5. 肯定入口:根据配置中的 entry 找出全部的入口文件,调用 compilition.addEntry 将入口文件转换为 dependence 对象
  2. 构建阶段:

    1. 编译模块(make):根据 entry 对应的 dependence 建立 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到全部入口依赖的文件都通过了本步骤的处理
    2. 完成模块编译:上一步递归处理全部能触达到的模块后,获得了每一个模块被翻译后的内容以及它们之间的 依赖关系图
  3. 生成阶段:

    1. 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每一个 Chunk 转换成一个单独的文件加入到输出列表,这步是能够修改输出内容的最后机会
    2. 写入文件系统(emitAssets):在肯定好输出内容后,根据配置肯定输出的路径和文件名,把文件内容写入到文件系统

单次构建过程自上而下按顺序执行,下面会展开聊聊细节,在此以前,对上述说起的各种技术名词不太熟悉的同窗,能够先看看简介:

  • Entry:编译入口,webpack 编译的起点
  • Compiler:编译管理器,webpack 启动后会建立 compiler 对象,该对象一直存活知道结束退出
  • Compilation:单次编辑过程的管理器,好比 watch = true 时,运行过程当中只有一个 compiler 但每次文件变动触发从新编译时,都会建立一个新的 compilation 对象
  • Dependence:依赖对象,webpack 基于该类型记录模块间依赖关系
  • Module:webpack 内部全部资源都会以“module”对象形式存在,全部关于资源的操做、转译、合并都是以 “module” 为基本单位进行的
  • Chunk:编译完成准备输出时,webpack 会将 module 按特定的规则组织成一个一个的 chunk,这些 chunk 某种程度上跟最终输出一一对应
  • Loader:资源内容转换器,其实就是实现从内容 A 转换 B 的转换器
  • Plugin:webpack构建过程当中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程

webpack 编译过程都是围绕着这些关键对象展开的,更详细完整的信息,能够参考 Webpack 知识图谱

初始化阶段

基本流程

学习一个项目的源码一般都是从入口开始看起,按图索骥慢慢摸索出套路的,因此先来看看 webpack 的初始化过程:

解释一下:

  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 函数:

// 取自 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 => {...});
            });
          });
        });
      });
    });
  }

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次跳转才开始进入主题,前戏太足了,若是读者对 webpack 的概念、架构、组件没有足够了解时,源码阅读过程会很痛苦。

关于这个问题,我在文章最后总结了一些技巧和建议,有兴趣的能够滑到附录阅读模块。

构建阶段

基本流程

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

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

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

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

  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 依赖关系,后续就能够根据这些内容打包输出。

示例:层级递进

假若有以下图所示的文件依赖树:

其中 index.jsentry 文件,依赖于 a/b 文件;a 依赖于 c/d 文件。初始化编译环境以后,EntryPlugin 根据 entry 配置找到 index.js 文件,调用 compilation.addEntry 函数触发构建流程,构建完毕后内部会生成这样的数据结构:

此时获得 module[index.js] 的内容以及对应的依赖对象 dependence[a.js]dependence[b.js] 。OK,这就获得下一步的线索:a.js、b.js,根据上面流程图的逻辑继续调用 module[index.js]handleParseResult 函数,继续处理 a.js、b.js 文件,递归上述流程,进一步获得 a、b 模块:

从 a.js 模块中又解析到 c.js/d.js 依赖,因而再再继续调用 module[a.js]handleParseResult ,再再递归上述流程:

到这里解析完全部模块后,发现没有更多新的依赖,就能够继续推动,进入下一步。

总结

回顾章节开始时提到的问题:

  • 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 函数:

// 取自 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 的转化,核心流程:

简单梳理一下:

  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 会对应打包出一个资源,而经过动态引入语句引入的模块,也对应会打包出相应的资源,咱们来看个示例。

示例:多入口打包

假若有这样的配置:

const path = require("path");

module.exports = {
  mode: "development",
  context: path.join(__dirname),
  entry: {
    a: "./src/index-a.js",
    b: "./src/index-b.js",
  },
  output: {
    filename: "[name].js",
    path: path.join(__dirname, "./dist"),
  },
  devtool: false,
  target: "web",
  plugins: [],
};

实例配置中有两个入口,对应的文件结构:

index-a 依赖于c,且动态引入了 e;index-b 依赖于 c/d 。根据上面说的规则:

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

生成的 chunks 结构为:

也就是根据依赖关系,chunk[a] 包含了 index-a/c 两个模块;chunk[b] 包含了 c/index-b/d 三个模块;chunk[e-hash] 为动态引入 e 对应的 chunk。

不知道你们注意到没有,chunk[a]chunk[b] 同时包含了 c,这个问题放到具体业务场景可能就是,一个多页面应用,全部页面都依赖于相同的基础库,那么这些全部页面对应的 entry 都会包含有基础库代码,这岂不浪费?为了解决这个问题,webpack 提供了一些插件如 CommonsChunkPluginSplitChunksPlugin,在基本规则以外进一步优化 chunks 结构。

SplitChunksPlugin 的做用

SplitChunksPlugin 是 webpack 架构高扩展的一个绝好的示例,咱们上面说了 webpack 主流程里面是按 entry / 动态引入 两种状况组织 chunks 的,这必然会引起一些没必要要的重复打包,webpack 经过插件的形式解决这个问题。

回顾 compilation.seal 函数的代码,大体上能够梳理成这么4个步骤:

  1. 遍历 compilation.modules ,记录下模块与 chunk 关系
  2. 触发各类模块优化钩子,这一步优化的主要是模块依赖关系
  3. 遍历 module 构建 chunk 集合
  4. 触发各类优化钩子

上面 1-3 都是预处理 + chunks 默认规则的实现,不在咱们讨论范围,这里重点关注第4个步骤触发的 optimizeChunks 钩子,这个时候已经跑完主流程的逻辑,获得 chunks 集合,SplitChunksPlugin 正是使用这个钩子,分析 chunks 集合的内容,按配置规则增长一些通用的 chunk :

module.exports = class SplitChunksPlugin {
  constructor(options = {}) {
    // ...
  }

  _getCacheGroup(cacheGroupSource) {
    // ...
  }

  apply(compiler) {
    // ...
    compiler.hooks.thisCompilation.tap("SplitChunksPlugin", (compilation) => {
      // ...
      compilation.hooks.optimizeChunks.tap(
        {
          name: "SplitChunksPlugin",
          stage: STAGE_ADVANCED,
        },
        (chunks) => {
          // ...
        }
      );
    });
  }
};

理解了吗?webpack 插件架构的高扩展性,使得整个编译的主流程是能够固化下来的,分支逻辑和细节需求“外包”出去由第三方实现,这套规则架设起了庞大的 webpack 生态,关于插件架构的更多细节,下面 plugin 部分有详细介绍,这里先跳过。

写入文件系统

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

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

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

资源形态流转

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

  • 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 写入文件系统

Plugin 解析

网上很多资料将 webpack 的插件架构归类为“事件/订阅”模式,我认为这种概括有失偏颇。订阅模式是一种松耦合架构,发布器只是在特定时机发布事件消息,订阅者并不或者不多与事件直接发生交互,举例来讲,咱们日常在使用 HTML 事件的时候不少时候只是在这个时机触发业务逻辑,不多调用上下文操做。而 webpack 的钩子体系是一种强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。

学习插件架构,须要理解三个关键问题:

  • WHAT: 什么是插件
  • WHEN: 什么时间点会有什么钩子被触发
  • HOW: 在钩子回调中,如何影响编译状态

What: 什么是插件

从形态上看,插件一般是一个带有 apply 函数的类:

class SomePlugin {
    apply(compiler) {
    }
}

apply 函数运行时会获得参数 compiler ,以此为起点能够调用 hook 对象注册各类钩子回调,例如: compiler.hooks.make.tapAsync ,这里面 make 是钩子名称,tapAsync 定义了钩子的调用方式,webpack 的插件架构基于这种模式构建而成,插件开发者可使用这种模式在钩子回调中,插入特定代码。webpack 各类内置对象都带有 hooks 属性,好比 compilation 对象:

class SomePlugin {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
            compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin', ()=>{});
        })
    }
}

钩子的核心逻辑定义在 Tapable 仓库,内部定义了以下类型的钩子:

const {
        SyncHook,
        SyncBailHook,
        SyncWaterfallHook,
        SyncLoopHook,
        AsyncParallelHook,
        AsyncParallelBailHook,
        AsyncSeriesHook,
        AsyncSeriesBailHook,
        AsyncSeriesWaterfallHook
 } = require("tapable");

不一样类型的钩子根据其并行度、熔断方式、同步异步,调用方式会略有不一样,插件开发者须要根据这些的特性,编写不一样的交互逻辑,这部份内容也特别多,回头展开聊聊。

When: 何时会触发钩子

了解 webpack 插件的基本形态以后,接下来须要弄清楚一个问题:webpack 会在什么时间节点触发什么钩子?这一块我认为是知识量最大的一部分,毕竟源码里面有237个钩子,但官网只介绍了不到100个,且官网对每一个钩子的说明都太简短,就我我的而言看完并无太大收获,因此有必要展开聊一下这个话题。先看几个例子:

  • compiler.hooks.compilation

    • 时机:启动编译建立出 compilation 对象后触发
    • 参数:当前编译的 compilation 对象
    • 示例:不少插件基于此事件获取 compilation 实例
  • compiler.hooks.make

    • 时机:正式开始编译时触发
    • 参数:一样是当前编译的 compilation 对象
    • 示例:webpack 内置的 EntryPlugin 基于此钩子实现 entry 模块的初始化
  • compilation.hooks.optimizeChunks

    • 时机: seal 函数中,chunk 集合构建完毕后触发
    • 参数:chunks 集合与 chunkGroups 集合
    • 示例: SplitChunksPlugin 插件基于此钩子实现 chunk 拆分优化
  • compiler.hooks.done

    • 时机:编译完成后触发
    • 参数: stats 对象,包含编译过程当中的各种统计信息
    • 示例: webpack-bundle-analyzer 插件基于此钩子实现打包分析

这是我总结的钩子的三个学习要素:触发时机、传递参数、示例代码。

触发时机

触发时机与 webpack 工做过程紧密相关,大致上从启动到结束,compiler 对象逐次触发以下钩子:

compilation 对象逐次触发:

因此,理解清楚前面说的 webpack 工做的主流程,基本上就能够捋清楚“何时会触发什么钩子”。

参数

传递参数与具体的钩子强相关,官网对这方面没有作出进一步解释,个人作法是直接在源码里面搜索调用语句,例如对于 compilation.hooks.optimizeTree ,能够在 webpack 源码中搜索 hooks.optimizeTree.call 关键字,就能够找到调用代码:

// lib/compilation.js#2297
this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {
});

结合代码所在的上下文,能够判断出此时传递的是通过优化的 chunksmodules 集合。

找到示例

Webpack 的钩子复杂程度不一,我认为最好的学习方法仍是带着目的去查询其余插件中如何使用这些钩子。例如,在 compilation.seal 函数内部有 optimizeModulesafterOptimizeModules 这一对看起来很对偶的钩子,optimizeModules 从字面上能够理解为用于优化已经编译出的 modules ,那 afterOptimizeModules 呢?

从 webpack 源码中惟一搜索到的用途是 ProgressPlugin ,大致上逻辑以下:

compilation.hooks.afterOptimizeModules.intercept({
  name: "ProgressPlugin",
  call() {
    handler(percentage, "sealing", title);
  },
  done() {
    progressReporters.set(compiler, undefined);
    handler(percentage, "sealing", title);
  },
  result() {
    handler(percentage, "sealing", title);
  },
  error() {
    handler(percentage, "sealing", title);
  },
  tap(tap) {
    // p is percentage from 0 to 1
    // args is any number of messages in a hierarchical matter
    progressReporters.set(compilation.compiler, (p, ...args) => {
      handler(percentage, "sealing", title, tap.name, ...args);
    });
    handler(percentage, "sealing", title, tap.name);
  }
});

基本上能够猜想出,afterOptimizeModules 的设计初衷就是用于通知优化行为的结束。

apply 虽然是一个函数,可是从设计上就只有输入,webpack 不 care 输出,因此在插件中只能经过调用类型实体的各类方法来或者更改实体的配置信息,变动编译行为。例如:

  • compilation.addModule :添加模块,能够在原有的 module 构建规则以外,添加自定义模块
  • compilation.emitAsset:直译是“提交资产”,功能能够理解将内容写入到特定路径

到这里,插件的工做机理和写法已经有一个很粗浅的介绍了,回头单拎出来细讲吧。

How: 如何影响编译状态

解决上述两个问题以后,咱们就能理解“如何将特定逻辑插入 webpack 编译过程”,接下来才是重点 —— 如何影响编译状态?强调一下,webpack 的插件体系与日常所见的 订阅/发布 模式差异很大,是一种很是强耦合的设计,hooks 回调由 webpack 决定什么时候,以何种方式执行;而在 hooks 回调内部能够经过修改状态、调用上下文 api 等方式对 webpack 产生 side effect

好比,EntryPlugin 插件:

class EntryPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "EntryPlugin",
      (compilation, { normalModuleFactory }) => {
        compilation.dependencyFactories.set(
          EntryDependency,
          normalModuleFactory
        );
      }
    );

    compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
      const { entry, options, context } = this;

      const dep = EntryPlugin.createDependency(entry, options);
      compilation.addEntry(context, dep, options, (err) => {
        callback(err);
      });
    });
  }
}

上述代码片断调用了两个影响 compilation 对象状态的接口:

  • compilation.dependencyFactories.set
  • compilation.addEntry

操做的具体含义能够先忽略,这里要理解的重点是,webpack 会将上下文信息以参数或 this (compiler 对象) 形式传递给钩子回调,在回调中能够调用上下文对象的方法或者直接修改上下文对象属性的方式,对原定的流程产生 side effect。因此想纯熟地编写插件,除了要理解调用时机,还须要了解咱们能够用哪一些api,例如:

  • compilation.addModule:添加模块,能够在原有的 module 构建规则以外,添加自定义模块
  • compilation.emitAsset:直译是“提交资产”,功能能够理解将内容写入到特定路径
  • compilation.addEntry:添加入口,功能上与直接定义 entry 配置相同
  • module.addError:添加编译错误信息
  • ...

Loader 介绍

Loader 的做用和实现比较简单,容易理解,因此简单介绍一下就好了。回顾 loader 在编译流程中的生效的位置:

流程图中, runLoaders 会调用用户所配置的 loader 集合读取、转译资源,此前的内容能够千奇百怪,但转译以后理论上应该输出标准 JavaScript 文本或者 AST 对象,webpack 才能继续处理模块依赖。

理解了这个基本逻辑以后,loader 的职责就比较清晰了,不外乎是将内容 A 转化为内容 B,可是在具体用法层面还挺多讲究的,有 pitch、pre、post、inline 等概念用于应对各类场景。

为了帮助理解,这里补充一个示例: Webpack 案例 -- vue-loader 原理分析

附录

源码阅读技巧

  • 拈轻怕重:挑软柿子捏,好比初始化过程虽然绕,可是相对来讲是概念最少、逻辑最清晰的,那从这里入手摸清整个工做过程,能够习得 webpack 的一些通用套路,例如钩子的设计与做用、编码规则、命名习惯、内置插件的加载逻辑等,至关于先入了个门
  • 学会调试:多用 ndb 单点调试功能追踪程序的运行,虽然 node 的调试有不少种方法,可是我我的更推荐 ndb ,灵活、简单,配合 debugger 语句是大杀器
  • 理解架构:某种程度上能够将 webpack 架构简化为 compiler + compilation + plugins ,webpack 运行过程当中只会有一个 compiler ;而每次编译 —— 包括调用 compiler.run 函数或者 watch = true 时文件发生变动,都会建立一个 compilation 对象。理解这三个核心对象的设计、职责、协做,差很少就能理解 webpack 的核心逻辑了
  • 抓大放小: plugin 的关键是“钩子”,我建议战略上重视,战术上忽视!钩子毕竟是 webpack 的关键概念,是整个插件机制的根基,学习 webpack 根本不可能绕过钩子,可是相应的逻辑跳转实在太绕太不直观了,看代码的时候一直揪着这个点的话,复杂性会剧增,个人经验是:

    • 认真看一下 tapable 仓库的文档,或者粗略看一下 tapable 的源码,理解同步钩子、异步钩子、promise 钩子、串行钩子、并行钩子等概念,对 tapable 提供的事件模型有一个较为精细的认知,这叫战略上重视
    • 遇到不懂的钩子别慌,个人经验我连这个类都不清楚干啥的,要去理解这些钩子实在太难了,不如先略过钩子自己的含义,去看那些插件用到了它,而后到插件哪里去加 debugger 语句单点调试,等你缕清后续逻辑的时候,大几率你也知道钩子的含义了,这叫战术上忽视
  • 保持好奇心:学习过程保持旺盛的好奇心和韧性,善于 \& 勇于提出问题,而后基于源码和社区资料去总结出本身的答案,问题可能会不少,好比:

    • loader 为何要设计 pre、pitch、post、inline?
    • compilation.seal 函数内部设计了不少优化型的钩子,为何须要区分的这么细?webpack 设计者对不一样钩子有什么预期?
    • 为何须要那么多 module 子类?这些子类分别在何时被使用?

ModuleModule 子类

从上文能够看出,webpack 构建阶段的核心流程基本上都围绕着 module 展开,相信接触过、用过 Webpack 的读者对 module 应该已经有一个感性认知,可是实现上 module 的逻辑是很是复杂繁重的。

以 webpack\@5.26.3 为例,直接或间接继承自 Module (webpack/lib/Module.js 文件) 的子类有54个:

没法复制加载中的内容

要一个一个捋清楚这些类的做用实在太累了,咱们须要抓住本质:module 的做用是什么?

module 是 webpack 资源处理的基本单位,能够认为 webpack 对资源的路径解析、读入、转译、分析、打包输出,全部操做都是围绕着 module 展开的。有不少文章会说 module = 文件, 其实这种说法并不许确,好比子类 AsyncModuleRuntimeModule 就只是一段内置的代码,是一种资源而不能简单等价于实际文件。

Webpack 扩展性很强,包括模块的处理逻辑上,好比说入口文件是一个普通的 js,此时首先建立 NormalModule 对象,在解析 AST 时发现这个文件里还包含了异步加载语句,例如 requere.ensure ,那么相应地会建立 AsyncModuleRuntimeModule 模块,注入异步加载的模板代码。上面类图的 54 个 module 子类都是为适配各类场景设计的。

image.png