webpack核心原理

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: webpack核心原理

a7a90671aa533435ead0664d597257d6.png

背景

Webpack 特别难学!!!


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


498 份JS文件

18862 行注释

73548 行代码

54 个 module 类型

69 个 dependency 类型

162 个内置插件

237 个hook

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


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


想给基础库做个升级,出现兼容性问题跑不动了,直接放弃

想优化一下编译性能,但是不清楚内部原理,无从下手

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


构建的核心流程

loader 的作用

plugin 架构与常用套路

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



857fd31523dce3be20b7a24f16ae1a93.png


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


核心流程解析

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

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

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

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



初始化阶段:

初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数

创建编译器对象:用上一步得到的参数创建 Compiler 对象

初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等

开始编译:执行 compiler 对象的 run 方法

确定入口:根据配置中的 entry 找出所有的入口文件,调用 compilition.addEntry 将入口文件转换为 dependence 对象

构建阶段:

编译模块(make):根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理

完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图

生成阶段:

输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

写入文件系统(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 的初始化过程:

5055861a05769143741480b444b8da18.png



解释一下:


将 process.args + webpack.config.js 合并成用户配置

调用 validateSchema 校验配置

调用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults 合并出最终配置

创建 compiler 对象

遍历用户定义的 plugins 集合,执行插件的 apply 方法

调用 new WebpackOptionsApply().process 方法,加载各种内置插件

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


注入 EntryOptionPlugin 插件,处理 entry 配置

根据 devtool 值判断后续用那个插件处理 sourcemap,可选值:EvalSourceMapDevToolPlugin、SourceMapDevToolPlugin、EvalDevToolModulePlugin

注入 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 相关的插件,包括:DllEntryPlugin、DynamicEntryPlugin、EntryPlugin、PrefetchPlugin、ProgressPlugin、ContainerPlugin

Entry 相关插件,如 lib/EntryPlugin.js 的 EntryPlugin 监听 compiler.make 钩子

lib/compiler.js 的 compile 函数内调用 this.hooks.make.callAsync

触发 EntryPlugin 的 make 回调,在回调中执行 compilation.addEntry 函数

compilation.addEntry 函数内部经过一坨与主流程无关的 hook 之后,再调用 handleModuleCreate 函数,正式开始构建内容

这个过程需要在 webpack 初始化的时候预埋下各种插件,经历 4 个文件,7次跳转才开始进入主题,前戏太足了,如果读者对 webpack 的概念、架构、组件没有足够了解时,源码阅读过程会很痛苦。


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

构建阶段

基本流程


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


Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?

Webpack 编译过程中,如何识别资源对其他资源的依赖?

相对于 grunt、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?

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

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


赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:

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


示例:层级递进

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




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

a28a887e2cda80cd32e374b70c51bb47.png


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


38a26cfa5f7b72b67457706f24872ee3.png



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

简单梳理一下:

构建本次编译的 ChunkGraph 对象;

遍历 compilation.modules 集合,将 module 按 entry/动态引入 的规则分配给不同的 Chunk 对象;

compilation.modules 集合遍历完毕后,得到完整的 chunks 集合对象,调用 createXxxAssets 方法

createXxxAssets 遍历 module/chunk ,调用 compilation.emitAssets 方法将资 assets 信息记录到 compilation.assets 对象中

触发 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 提供了一些插件如 CommonsChunkPlugin 、SplitChunksPlugin,在基本规则之外进一步优化 chunks 结构。


SplitChunksPlugin 的作用

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


回顾 compilation.seal 函数的代码,大致上可以梳理成这么4个步骤:


遍历 compilation.modules ,记录下模块与 chunk 关系

触发各种模块优化钩子,这一步优化的主要是模块依赖关系

遍历 module 构建 chunk 集合

触发各种优化钩子

a8819c5d1f0bdc4e085dc321c257fece.png


上面 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}
      // ...
    },
    // ...
  ],
};
复制代码


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


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

compiler.make
• 1
  • 阶段:

entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息

根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module


阶段:


遍历 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");
复制代码


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


目录
相关文章
|
JavaScript 前端开发 API
webpack核心原理-2
webpack核心原理-2
77 0
|
19天前
|
监控 前端开发 JavaScript
Webpack 中 HMR 插件的工作原理
【10月更文挑战第23天】可以进一步深入探讨 HMR 工作原理的具体细节、不同场景下的应用案例,以及与其他相关技术的结合应用等方面的内容。通过全面、系统地了解 HMR 插件的工作原理,能够更好地利用这一功能,为项目的成功开发提供有力保障。同时,要不断关注技术的发展动态,以便及时掌握最新的 HMR 技术和最佳实践。
|
19天前
|
缓存 前端开发 JavaScript
Webpack 动态加载的原理
【10月更文挑战第23天】Webpack 动态加载通过巧妙的机制和策略,实现了模块的按需加载和高效运行,提升了应用程序的性能和用户体验。同时,它也为前端开发提供了更大的灵活性和可扩展性,适应了不断变化的业务需求和技术发展。
|
19天前
|
缓存 前端开发 JavaScript
webpack 原理
【10月更文挑战第23天】Webpack 原理是一个复杂但又非常重要的体系。它通过模块解析、依赖管理、加载器和插件的协作,实现了对各种模块的高效打包和处理,为现代前端项目的开发和部署提供了强大的支持。同时,通过代码分割、按需加载、热模块替换等功能,提升了应用程序的性能和用户体验。随着前端技术的不断发展,Webpack 也在不断演进和完善,以适应不断变化的需求和挑战。
|
1月前
|
缓存 前端开发 JavaScript
Webpack 打包的基本原理
【10月更文挑战第5天】
|
2月前
|
JavaScript 前端开发
手写一个简易bundler打包工具带你了解Webpack原理
该文章通过手写一个简易的打包工具bundler,帮助读者理解Webpack的工作原理,包括模块解析、依赖关系构建、转换源代码以及生成最终输出文件的整个流程。
|
3月前
|
缓存 前端开发 JavaScript
Webpack 模块解析:打包原理、构造形式、扣代码补参数和全局导出
Webpack 模块解析:打包原理、构造形式、扣代码补参数和全局导出
133 1
|
6月前
|
API 开发工具 开发者
webpack热更新原理
Webpack的Hot Module Replacement(HMR)提升开发效率,无需刷新页面即可更新模块。开启HMR需在配置中设`devServer.hot: true`。Webpack构建时插入HMR Runtime,通过WebSocket监听并处理文件变化。当模块改变,Webpack发送更新到浏览器,HMR Runtime找到对应模块进行热替换,保持应用状态。开发者可利用`module.hot` API处理热替换逻辑。
|
6月前
|
前端开发 测试技术 开发者
深入理解 Webpack 热更新原理:提升开发效率的关键
深入理解 Webpack 热更新原理:提升开发效率的关键
|
6月前
|
缓存 前端开发 算法
Webpack 进阶:深入理解其工作原理与优化策略
Webpack 进阶:深入理解其工作原理与优化策略
171 2