likes
comments
collection
share

Webpack5源码解读系列1 - 一文了解Webpack核心流程

作者站长头像
站长
· 阅读数 17

Webpack5源码解读系列1 - 一文了解Webpack核心流程

什么是webpack?

Webpack是一个现代 JavaScript 应用静态资源打包器Webpack打包应用时会从入口出发,根据模块依赖关系图谱图将所有模块连接起来并输出为静态资源文件。

Webpack是JS时代的破局者。在Webpack出现之前,前端社区陆陆续续出现多种模块化管理方案,如esm、cjs、amd、system,导致社区中存在各种模块管理方案依赖库,而不同模块管理方案不能够互相使用。Webpack的出现打破了这个局面,Webpack能够识别转译所有模块管理方案,从而屏蔽模块管理方案差距,做到应用层无感知。

Webpack在设计上大量使用了事件钩子解耦模块,其中编译器(Compiler)通过提供插件机制将Webpack核心能力“外包出去”,使得Webpack具有较高的能力可拓展性,Webpack在编译过程中通过抛出事件通知插件完成编译任务。


核心概念

Compiler & Compilation

一句话介绍CompilerCompilationCompilerWebpack运行的引擎,应用运行时常驻,内部每次构建任务都会由一个Compilation实例完成,下面统一将上CompilerCompilation称为「编译器。

Compiler对于使用者来说相对会比较熟悉,使用Node脚本启动Webpack时,可以先获取Compiler实例在根据场景启动编译器编译。

const webpack = require('webpack');
const config = require('./webpack.config');
// 1. 获取编译器实例
const compiler = webpack(config);

// 2. 执行编译操作
compiler.run((err, stats) => {
  if (err) {
    console.error(err);
  }
  // ...code
  
  compiler.close((closeErr) => {
    // ...code
  });
});

// or 3. 开启watch
const watching = compiler.watch(
  {
    aggregateTimeout: 300,
    poll: undefined,
  },
  (err, stats) => {
    // Print watch/build result here...
    console.log(stats);
  }
);

Plugin

插件(即plug-in)指一种满足特定接口协议且能够扩充应用能力程序。对于插件而言没有主程序时无法正常运行,而主程序无需插件可正常运行,所以插件是一种可插拔、对主流程无影响的应用。

Webpack通过提供基于Tapable的插件机制提高编译器的灵活性,插件内部可在CompilerCompilation实例上注册钩子。构建任务进行过程中会不断抛出事件钩子,插件通过订阅这些钩子丰富Webpack能力。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

Loader

模块预处理器(即Loader)为Webpack提供了处理各种各样文件的能力。如果没有Loader,那么Webpack只能处理语法解析器支持的语法,而Loader的存在能够让Webpack支持JSX语法、css拓展语法、甚至是图片、文本等非代码文件。

Module

在模块化编程中,开发者会将功能拆分成不同模块,以此方便单元测试和代码解耦,所以模块化可以理解为代码单元。Webpack将所有的文件都视为ModuleModule是项目文件在内存中的映射。

Chunk

ChunkModule代码的封装,在代码生成阶段会将模块代码塞入Chunk中,并最终输出为产物文件。

Source

资源文件的抽象,具备各种字符串操作方法。内容可以是源代码,也可以是产物代码,在不同模块之间流通。


核心构建流程

配置处理

Webpack内部高度插件化,通过插件将功能模块“外包”出去。Webpack大多数配置项逻辑都会交由插件实现,具体体现为配置项在初始化时会转为一个个插件,如下面的配置:

module.exports = {
  // 1. 使用EntryPlugin
  entry: path.resolve(__dirname, 'src', 'index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
  },
  // 2. 使用ExternalsPlugin
  externals: {
    react: 'react'
  },
  resolve: {
    // 3. 应用于一些插件的入参,如DllReferencePlugin的入参
    extensions: [
      '.ts',
      '.tsx',
      '.js',
      '.json'
    ],
  },
  // 4. 根据配置项转为EvalDevToolModulePlugin或EvalSourceMapDevToolPlugin等插件
  devtool: false,
}

配置项在初始化时处理为插件,插件通过监听CompilerCompilation钩子事件从而内部闭环完成逻辑,这样做能够较大程度降低Webpack模块耦合度。这样做的好处在于新增或修改与主流程无关的能力时,无需修改核心代码,降低了维护成本。

构建模块依赖拓扑图

概念

项目文件在文件系统中,Webpack不能够直接分析文件,而是需要将文件映射到内存中才能够分析模块依赖拓扑。文件在磁盘中以有向图的形式存在,并且大多数情况下是成环图(存在循环依赖),应用文件在内存中映射为有成环向图:

Webpack5源码解读系列1 - 一文了解Webpack核心流程 在介绍构建过程之前,需要引入两个概念:DependencyModuleGraph

  • Dependency

Webpack官网描述到一个模块依赖于另外一个模块称为Dependency,在Webpack内部,模块引用另外一个模块内容,会处理为Dependency

import "./index.less";
import { Home } from './App';
import Index from './Index';

上面模块引用表达式会解析为多个Dependency,每个Dependency都有具体模块导入信息:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

事实上,除了模块导入语句会被处理为Dependency,模块导出语句、其他Webpack专属语法都会处理为Dependency,后续代码生成时会根据这些Dependency做代码转译。不过在模块拓扑图构建阶段我们暂时可以认为 Dependency 等同于模块引用

  • ModuleGraph

    •   ModuleGraph是项目文件引用拓扑图在内存中的映射,内部由ModuleGraphModuleModuleGraphConnection组成:
    • ModuleGraphModule:Graph的节点,是模块在ModuleGraph的映射;
    • ModuleGraphConnection:Graph的边,是DependencyModuleGraph的映射。

构建过程

单个模块构建

开始进入构建时,编译器抛出事件,EntryPlugin根据入口配置往Compilation注册入口DependencyCompilation以此Dependency为起点进入构建阶段,每个模块构建都会经过四个阶段:

  1. 实例化模块

Dependency是模块的前身,如果用网络请求来形容的话,那么Dependency则是描述请求信息,其中Dependencyrequest字段相当于请求的URL,用于描述资源位置,request可以按照Loader规范添加参数,参数不同时编译器会视为不同资源,从而构建出不同实例。如下面两个例子:

// 不使用loader处理文件
import './index.css';

// 使用loader处理文件
import '!style-loader!css-loader?modules!./index.css';

由于使用Loader会改变文件内容,所以使用不同Loader会造成文件内容不一致,故编译器将上面两个请求链接视为不同的资源,所以会产生两个模块实例,注意,此时并不会读取模块内容。

  1. 添加模块

模块创建完毕之后,需要根据引用关系逐步构建ModuleGraph,所以本阶段会模块存放到ModuleGraph中并建立父子关系。

  1. 构建模块

当所有工作准备就绪时,进入到模块构建阶段,构建阶段需要读取文件内容,并对文件内容做处理。Webpack使用Source对文件内容做一层抽象,Source不仅仅用于文件内容读取,在代码生成时也会使用并最终将文件内容提交出去。

实例化模块过程中会经历模块预处理和代码解析,前者会将文件处理为符合JavaScript语法的代码,后者会使用JavaScript语法解析器解析代码并抛出事件,由各个语法插件根据解析事件创建Dependency

Webpack5源码解读系列1 - 一文了解Webpack核心流程

  1. 处理模块依赖

模块构建完成时,会根据模块导入语法产生Dependency,此时需要处理Dependency,做去重操作并进入下一轮模块构建。

整体构建流程

每个模块构建时都会经历四个步骤,每个步骤内部都含有异步操作,如果按照普通的DFS或者BFS算法去遍历,那么会因为异步等待时间过多而导致构建性能下降。所以编译器使用异步执行队列提高多个异步任务并发执行速率。

编译器一共提供了四个队列,这四个队列分别对应模块的四个构建过程,并且任务队列之间存在优先级别,优先级别从高到低分别是模块实例化队列、模块添加队列、模块构建队列、依赖处理队列

Webpack5源码解读系列1 - 一文了解Webpack核心流程

编译器使用四条异步队列的原因有两个:

  • 提高性能:相比于只使用一条异步队列而言,四条队列根据构建阶段细分队列,从而提高模块构建速率;
  • 多编译器协同:在未来可能会有多个编译器协同工作,通过拦截这些队列来完成作业编排。

当所有模块都构建完毕时,编译器会得到一份项目模块引用关系在内存中的映射,存放于ModuleGraph

Webpack5源码解读系列1 - 一文了解Webpack核心流程

分析产物组织形式

分包规则

Web应用代码体积会影响到应用的加载速度,大型项目中往往会使用到代码异步加载能力,如Vue或React的路由懒加载能力、或直接使用import()语句异步加载模块,这些能力都是基于Webpack的代码分割功能实现。代码分割功能本质上是项目产物代码拆分成多个文件(下面称为分包),并在运行时按需下载文件,从而提高应用加载速度。

Webpack一共有三种文件拆分类型:

  • 入口分包

Webpack每个入口配置在编译结束时会产生单独文件,如下面配置后,代码生成时会产生entry-1.jsentry-2.js文件:

module.exports = {
  entry: {
    entry1: {
      import: 'A.js',
    },
    entry2: {
      import: 'B.js',
    }
  }
}
  • 异步分包

在代码中使用require.ensure('xxx')或者import('xxx')模块都能够使用代码分割能力,代码分割能力底层都是将目标模块代码从本包中抽离出来,单独生成一个代码包,在运行时通过异步加载代码方式实现。

import './Home.js';

// 使用异步加载能力,会将 target.js模块从本模块所属chunk独立出去
import('./target.js');
  • Runtime分包

默认地,Webpack应用运行时代码都会放到应用入口Chunk上,这是为了能够让所有代码都能够使用运行时能力。如果一个项目存在多个入口,那么可以选择将Runtime相关能力独立出来,减少代码冗余,下面编译会产出runtime.js文件存储Runtime能力:

module.exports = {
  entry: {
    entry1: {
      import: 'A.js',
      runtime: 'runtime'
    },
    entry2: {
      import: 'B.js',
      runtime: 'runtime',
    }
  }
}

模块分包处理

模块分包

经过分包规则处理后编译器会为每个分包创建一个ChunkGroup实例,ChunkGroup 可以理解为 Chunk 的集合并存在父子引用关系,用于描述 Chunk 引用关系ChunkGroup包含分包涉及到的所有模块,在代码生成阶段将模块分到Chunk中用于文件生成。

如下面例子中使用异步导入语法将模块进行分包,使用ChunkGroup分析引用关系,可以看到ChunkGroup-1会加载ChunkGroup-2,那么在打包产物文件会输出为两个文件Chunk-1Chunk-2,且Chunk-1内部会加载Chunk-2文件:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

上面模块引用关系比较简单,很容易进行分包,但实际项目模块模块引用往往很复杂,很容易出现同一个模块在不同分包内使用,如下面例子:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

上面例子根据异步分包规则会产生三个ChunkGroup,此时会发现出现了冗余模块,它们分别是BG模块,冗余模块会导致两个问题:

  • 打包产物体积增大,影响代码下载速率;
  • 如果冗余模块中存在副作用,那么产生一些意料之外的bug;

所以在模块分包完毕之后,编译器需要通过分析ChunkGroup引用关系去除冗余模块。

剔除冗余模块

经过模块分包之后ChunkGroup以有向无环图(DAG)形式存在,比如:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

上面ChunkGroup组成的DAG代表着产物代码包的加载顺序,该DAG的两个拓扑排序代表着两种代码包加载的可能性,分别是:

  • 1 -> 2 -> 4 -> 5
  • 1 -> 3 -> 4 -> 5

拓扑排序代表着运行时代码某个场景下的加载顺序,拓扑排序中靠前代码包模块会比靠后代码包模块先加载,不过这并不意味着靠后ChunkGroup模块能够直接使用靠前的ChunkGroup模块,这里分为两种情况:

  • ChunkGroup为DAG必经节点,那么可以直接使用,如ChunkGroup-2能够使用ChunkGroup-1的模块。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

  • ChunkGroup为可选节点,那么可访问模块为可选节点模块的交集,如ChunkGroup-4可访问模块为ChunkGroup-2ChunkGroup-3的模块交集以及ChunkGroup-1模块。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

我们以前面例子剔除冗余模块,可以得到以下结果:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

在上面例子中需要特别注意:因为 ChunkGroup-2 ChunkGroup-3 之间无法确定加载顺序(它们不在同一个拓扑排序中),所以 G 模块是需要出现冗余以保证应用正常运行

编译器基于ModuleGraph和分包规则分析生成ChunkGroup,之后经过剔除冗余模块后,建立了描述产物文件引用图ChunkGraph,用于后续代码生成使用:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

模块代码转译及输出文件封装

运行时能力

在讲解模块转译前,需要介绍Webpack运行时(即Runtime)能力,Runtime可以理解为应用在浏览器运行的基石,如果将应用比喻成一座建筑,那么Runtime则是这座建筑的地基。Runtime不仅仅包括模块管理、代码异步加载等基础能力,还可以包括Webpack一些特性能力,如HMR、Module Ferdaration以及未来可能新增的能力。下面是Webpack编译产物,黄色区域为Runtime的模块管理部分代码:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

WebpackRuntime按能力拆分处理为多个RuntimeModuleRuntimeModule可以理解为Webpack官方依赖库,编译后会注入到产物中,提供应用运行基础能力。

模块代码转译

转译原理

模块代码转译目的是将特定语法转为符合Runtime要求的语法,这是Webpack能够处理兼容所有模块语法的实现原理。模块代码转译分为两个阶段:搜集阶段和转译阶段

  • 搜集阶段

搜集阶段发生在模块构建时,模块构建时使用JavaScript Parser解析代码文件并抛出遍历AST节点的事件,此时插件通过监听Parser事件并生成Dependency实例记录语法以及代码位置。(这里Dependency实际上是各个语法基于Dependency实现类,这里为了方便统一叫做Dependency)。

  • 转译阶段

每个Dependency都有对应的Template工具类负责将Dependency解释为特定代码。在代码转译阶段,会取出模块所有Dependency,并由对应Template负责转译Dependency并输出Source,由此完成模块转译。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

这里的DependencyTemplatePlugin三者配合起来能够转译任何模块语法,如HMR能力中,使用HotModuleReplacePlugin + ModuleHotAcceptDependency + ModuleHotAcceptDependency.Template处理热更新的特殊语法module.hot.accept

模块代码生成流程

应用中会经历两次模块代码生成任务,目的各有不同:

  1. 第一次模块代码生成

第一次代码生成目的是获取模块使用到的Runtime能力。编译器根据ChunkGraph获取到项目所有文件模块(NormalModule),此时根据Dependency.Template进行模块代码转译,在转译过程中获取语法所需的Runtime能力,将其注入到全局能力集合中,后续会根据该集合生成RuntimeModule

  1. 第二次模块代码生成

第二次代码会将NormalModuleRuntimeModule一起做代码生成,并最终交由Chunk进行封装。

下面是完整的模块代码生成流程:

Webpack5源码解读系列1 - 一文了解Webpack核心流程

Chunk文件封装

模块代码生成完毕后不能直接运行,而是需要将模块代码封装到Chunk中并生成Chunk代码内容,进而保证代码能够在浏览器中正常运行。Chunk文件有多种类型,如js、css、json类型文件,这些文件的封装是由各个插件实现,如JavaScriptModulePluginCssModulePluginJSONModulePlugin等插件提供各种语言文件生成逻辑,与模块代码生成相似,Chunk代码生成结果也是Source

Webpack5源码解读系列1 - 一文了解Webpack核心流程

下面以JavaScript文件生成为例子讲解Chunk代码生成流程。在分析产物组织形式阶段分析Chunk包含文件以及Chunk的引用关系,并输出ChunkGraphChunk代码生成在ChunkGraph基础上做代码生成工作。首先Chunk会分为两种类型:含Runtime和不含RuntimeChunk

Webpack5源码解读系列1 - 一文了解Webpack核心流程

含有RuntimeChunk:代码分为三部分:

  1. Chunk容器代码,创建作用域并提供存放模块代码
  2. Runtime代码:根据模块代码时获取所有RuntimeModule生成的代码
  3. 模块代码:NormalModule生成的代码

Webpack5源码解读系列1 - 一文了解Webpack核心流程

  1. 不含RuntimeChunk:将本Chunk包含所有NormalModule代码生成存放于容器中即可

由于Runtime能力全局只需要一份,且需要所有代码均能够访问到Runtime能力,所以一般情况下都会将代码生成在入口Chunk文件中,除非有特殊说明需要将Runtime抽离出去。编译器通过ChunkGraph为各个Chunk生成代码之后,将产物以Source形式提交出去。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

ChunkGraph经过代码生成后,会转为一个个描述ChunkSource

Webpack5源码解读系列1 - 一文了解Webpack核心流程

之后编译器将Source逐个输出为文件,至此,Webpack完成编译任务。


小结

本文浅介绍了Webpack核心工作流程,Webpack核心构建流程有三个阶段,分别是构建模块依赖拓扑图、分析产物组织形式、模块转译及模块封装。

  1. 构建模块依赖拓扑图

Webpack无法直接在文件系统上分析文件拓扑,需要将应用文件从文件系统上映射到内存中,用于后续分析。应用文件从文件系统映射到磁盘过程中使用了Loader机制将非JavaScript语法的文件转为JavaScript代码,这是Webpack能够识别所有文件底层实现。

Webpack5源码解读系列1 - 一文了解Webpack核心流程

  1. 分析产物组织形式

构建完ModuleGraph后,Webpack需要确定产物文件组织形式。本阶段Webpack根据分包规则将模块分为多个代码包并将它们的引用关系记录到ChunkGraph中,用于后续代码生成。

Webpack5源码解读系列1 - 一文了解Webpack核心流程 3. 模块代码转译及输出文件封装

ChunkGraph生成完毕后,已经确定了产物输出形式,接下来进行模块代码生成和输出文件封装:

  • 模块代码生成:转译模块语法,如将ES6模块、CommonJS、AMD等语法转译为符合Webpack Runtime语法。
  • 输出文件封装:根据ChunkGraph将模块装入Chunk

Webpack5源码解读系列1 - 一文了解Webpack核心流程

转载自:https://juejin.cn/post/7231804508081258555
评论
请登录