程序猿东哥

V1

2022/10/29阅读:37主题:全栈蓝

初探Webpack编译原理

前言

最近有同学问了我一个问题"Webpack如何处理异步模块加载的?",我想了一下说了个大概,觉得没有说太明白,所以决定对webpack深入了解一下,本文是我对webpack的大致了解。把学些过程记录下来,供大家参考

为什么要了解webpack

webpack对于我们前端同学来说再熟悉的不过了,可能有同学会问题,为什么要去了解webpack,只需要按照webpack的官方文档配置就好了。当然能配置出来仅仅停留在使用阶段,如果我们在使用过程中出现各类问题

  • 配置出现问题如何解决?
  • 如果webpack 打包流程时间很长,如何去优化?
  • 如果使用基础包兼容出现问题,想升级怎么处理?
  • 想写一个自定义插件去处理文件,怎么写?

究其原因还是需要对webpack整体的构建流程和设计框架做深入的了解。才能去解决构建过程中遇到的问题,只要心中有‘术’,在难的问题也不怕。 由于webpack体系非常庞大,包括:模块打包、代码分割、按需加载、HMR、Tree-shaking、文件监听、sourcemap、Module Federation、devServer、DLL、多进程等等,为了实现这些功能,webpack 的代码量已经到了惊人的程度。所以在这么复杂的体系中,了解其过程。首先需要自我问几个问题,你需要了解什么。以下是我在了解之前,自我提问

  • webpack是如何查找文件的依赖?
  • loader如何如何运行?输入输入是什么?
  • 文件需要转化成AST解析吗?
  • webpack的plugin机制如何,如何工作?如果要自定义一个插件怎么做?
  • webpack 对异步模块加载逻辑如何处理的?

虽然webpack比较复杂,也是可以从上而下逐步拆解,归纳起来核心主要三个部分

  • 『构建的核心流程』
  • 『loader的作用』
  • 『plugin架构和常用套路』

所以本文主要围绕三个方面来讨论,了解了这三部分的内容算是对webpack入门了,后续遇到相关问题也就能按图索骥了

术语

Webpack体系比较大,要了解的点很多,首先要对一些术语要清楚,官方的术语链接可参考

  1. entry 告诉webpack工程的入口文件,可以为多个
  2. module 一个入口文件可以为多个模块组成,比如index.js 依赖a.js和b.js 即3个模块组成
  3. chunk 为webpack内部用于管理的模块术语即“块”,一般情况下一个chunk跟一个bundle对应,例如在异步加载模块的场景下会不一样
  4. bundle经过wepback编译后的模块,输出的文件
  5. plugin插件在webpack体系中,比例占得最重的一部分,后面会详细聊。即在扩展webpack编译功能,具有apply方法,在编译时会被compiler调用
  6. loader是对模块的源码转义的工具,例如es6 需要转义成es5
  7. Compiler编译管理器,webpack整个生命周期只会有个对象
  8. Compilation单词编译过程的管理器,例如在watch模式下,Compiler对象只会创建一个,但Compilation对象会每次构建创建一个
  9. Denpendence依赖对象,webpack会根据该类型记录模块之间的依赖

核心流程解析

首先来看看webpack的核心功能是什么: At its core, webpack is a static module bundler for modern Javascript applications。 一句话总结就是将各类静态资源,包括图片、js、css等,转义、组合、拼接、生成JS格式的bundle文件。webpack官网首页可以直观的说明这点

这个过程核心完成了内容转化 + 资源合并的功能,可以归纳为三个阶段

1)初始化阶段:初始化参数从配置文件、配置对象、shell中读取参数,与webpack默认的配置参数合并成最终的参数;「创建编译器对象」用上一步获得参数创建Compiler对象;「初始化编译环境」注册内置插件,注册各种模块工厂,初始化RuleSet集合,加载配置插件等;「开始编译」执行compiler的run方法;「确定入口」根据配置文件的entry找出所有的入口文件,执行compilation的addEntry方法,把所有的入口文件转化成dependence对象

2)构建阶段:模块编译make根据entry对应的dependence创建的module, 调用配置的loader将模块转译成标准的Js内容,再调用JS解析器(acron)转成AST 逐步解析模块的依赖,在递归本步骤将所有的entry文件的依赖都经过本步骤的处理;最后将上一步处理能够触达的模块后,得到没个模块的依赖图谱(ChunkGraph)

3)输出阶段:输出资源seal根据入口和模块之间的依赖关系,组成一个个包含多个模块Chunk,再把Chunk转成一个单独文件加入到输出列表,在这一步可以修改文件内容的最后机会。在确定好输出内容后,根据配置的输出配置(output)最终将文件内容写入文件系统 至此单次构建流程就完成。后面会针对单个的阶段内容继续深入了解。

1.初始化阶段

该阶段主要是根据webpack的用户配置文件、命令行以及默认配置创建webpack对象。包括创建compiler对象和环境的准备。详细流程图如下:

结合图形详细描述一下:

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

核心在WebpackOptionsApply类,webpack内置了很多插件,这些插件不需要用户手动配置,webpack会根据用户配置的信息动态注入对应的插件,例如包括:

a) 注入 EntryOptionPlugin 插件,处理entry配置

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

c) 注入RuntimePlugin,用户根据动态内容注入webpack运行时

完成上面步骤后,compiler对象就创建好了,相应的环境信息也准备好了。后面就开始调用compiler.run 和 compiler.compile方法了

/**
 * webpack compile 方法
 */
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钩子链路很长。到这里初始化流程就基本结束了,后面就是开始进入编译阶段

2.构建阶段

还记得前面说道自我问几个问题吗,例如

  • webpack是如何查找文件的依赖?
  • loader如何如何运行?输入输入是什么?
  • 文件需要转化成AST解析吗?

等这些问题,在这一阶段会逐步进行阐述。构建阶段是从entry文件开始递归解析出资源与资源的依赖,在Compilation对象中逐步构建出module结合和module之间的依赖。核心流程大致如下

大致描述一下每个阶段的功能:

  1. 调用handleModuleCreate,根据文件类型的Module 子类
  2. 调用 loader-runner 仓库的runLoaders方法转义module的内容,这块就是核心loader的作用
  3. 调用arcon 将JavaScript 内容解析成AST
  4. 遍历AST触发各种钩子,在HarmonyExportDenpendencyParsePlugin中监听exportImportSpecifier钩子,解析js文本对应的资源,调用module对象的addDependency将依赖对象添加到module的依赖集合中
  5. 遍历AST完成后,调用handleParseResult处理模块依赖
  6. 对于module的新增依赖,继续调用handleModuleCreate进行递归
  7. 所有调用完成后,构建阶段就结束了

总结核心流程是 module => ast => dependences => module。先解析成AST,再从AST中遍历依赖。这就要求经过loader处理过的内容必须是可以被arcon处理的标准JavaScript语法。 Compilation按照这个流程就行递归解析内容,逐步解析出模块以及模块的依赖,后续就可以进行打包输出了。

简单示例

// webpack.config.js 配置
{
  //...
  entry: {
    index: 'index.js'
  }
}
// index.js文件
import a from 'a.js';
import b from 'b.js';

// a.js
import c from 'c.js';

以上示例看看如何进行构建,index.js作为入口文件, 依赖a.js和b.js ,a.js又依赖c.js。所以第一步EntryPlugin插件根据entry配置找到index.js 入口文件,调用Compilation.addEntry方法触发构建流程。在构建完毕后变成如下结构:

根据上面的大流程图里面的逻辑调用完成之后,得到了模块a和b,之后又对a和b进行如上操作。最终递归出来的结果如下:

到这里解析完成所有模块后,发现更多的依赖模块,就进行推进下一步了。

3.输出阶段

在上一步构建阶段主要围绕 module 展开,而在输出阶段更多围绕 chunks展开。在经过构建之后,webpack 得到足够的模块以及模块依赖的信息,接下来就是开始把内容生产文件的阶段,体现代码就是compilation.seal函数:

/**
 * webpack compile 方法
 */
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方法
            compilation.seal(err => {...});
          });
        });
      });
    });
  });
}

seal函数主要完成 module 到chunk的转化,核心流程如下:

注:绿色标识hook;灰色标识compiler方法

具体步骤描述:

  • 构建本次编译的块图(ChunkGraph)对象
  • 遍历Compilation.modules集合,按entry或者动态引入的规则分配不同的chunk对象
  • 遍历完Compilation.modules集合后,得到完整chunks集合对象,调用createXxxAssets方法
  • createXxxAssets遍历module 或 chunk,调用Compilation.emitAssets方法将assets信息记录到Compilation.assets对象中
  • 触发seal回调,控制流回到compiler对象

这一步关键是将module按照规则组合成chunk,webpack内置的chunk规则比较简单

  • entry和依赖模块,组合成一个chunk
  • 使用动态语句的模块,单独组合成一个chunk

chunk是输出的基本单位,默认情况下这些chunks与最终生成的资源一一对应,对应上面的规则,一个entry会对应打出一个资源,而通过动态引入的模块,也会对应生成对应的资源

写入文件系统

在经历构建阶段后,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.write方法将assets写入真正的文件。至此webpack的整体构建流程基本已结束

资源的流转形态

最后来梳理一下,资源在webpack构建中的各阶段的流转形态是什么的,以便加深理解

compilation.make: entry 文件以dependence 对象的形式加入到compilation的依赖列表,dependence 对象记录有entry文件类型、路径等信息,根据dependence对象对用的工厂方法创建module对象,之后读取module对应文件的内容,再调用run-loaders方法对内容进行转化,转化结果如有依赖继续读入依赖资源,重复此过程直到依赖被均被转化为module

compilation.seal : 遍历module集合,根据entry的配置及其引入资源的方式,将module分配到不同的chunk中。再遍历chunk集合,调用compilation.emitAssets方法标记chunk的输出规则,即转化为essets集合

compiler.emitAssets: 将assets集合的内容写入文件中

最后

以上是webpack的大致构建流程,当然还有很多过程中的细节没有深入,会逐步去了解和学习。后面会从一下几个方面继续了解

  • plugin的机制以及运行过程
  • 什么是钩子,钩子在webpack的作用
  • 动态加载的具体实现机制

分类:

前端

标签:

前端

作者介绍

程序猿东哥
V1