前言
webpack 是一个强大的模块打包工具,之所以强大的一个原因在于它拥有灵活、丰富的插件机制。但是 webpack 的文档不太友好,就我自己的学习经历来说,官方的文档并不详细,网上的学习资料又少有完整的概述和例子。所以,在研究了一段时间的 webpack 源码之后,自己希望写个系列文章,结合自己的实践一起来谈谈 webpack 插件这个主题,也希望能够帮助其他人更全面地了解 webpack。
这篇文章是系列文章的第二篇,将会从对象的角度来讲解 webpack。如果你想从整体角度了解 webpack,可以先阅读系列文章的第一篇:玩转webpack(一)
P.S. 以下的分析都基于 webpack 3.6.0
P.S. 本文将继续沿用第一篇文章的名词,任务点表示通过 plugin
方法注册的名称
webpack中的核心对象
跟第一篇文章类似,我们不会讲所有 webpack 中的对象都拿出来讲解,而是整理了一些比较核心的概念。我们可以先看看下面的类图:
下面的论述将会逐一讲述类图中的对象,首先我们先来看一下最顶层的类 Tapable
。
Tapable
Tapable
提供了 webpack 中基于任务点的架构基础,它将提供任务点注册的方法以及触发的方法。
一个简单的例子,使用 plugin
方法来注册一个任务点,然后使用 applyPlugins
方法触发:
|
|
Tapable
里面注册任务点只有 plugin
方法,但是触发任务点的方法是提供了很多,可以分为同步和异步执行两类:
同步执行:
applyPlugins(name, ...params)
applyPlugins0(name)
applyPlugins1(name, param)
applyPlugins2(name, param1, param2)
applyPluginsWaterfall(name, init, ...params)
applyPluginsWaterfall0(name, init)
applyPluginsWaterfall1(name, init, param)
applyPluginsWaterfall2(name, init, param1, param2)
applyPluginsBailResult(name, ...params)
applyPluginsBailResult0(name)
applyPluginsBailResult1(name, param)
applyPluginsBailResult2(name, param1, param2 )
applyPluginsBailResult3(name, param1, param2, param3)
applyPluginsBailResult4(name, param1, param2, param3, param4)
applyPluginsBailResult5(name, param1, param2, param3, param4, param5)
异步执行:
applyPluginsAsync(name, ...params, callback)
applyPluginsAsyncSeries(name, ...params, callback)
applyPluginsAsyncSeries1(name, param, callback)
applyPluginsAsyncSeriesBailResult(name, ...params, callback)
applyPluginsAsyncSeriesBailResult1(name, param, callback)
applyPluginsAsyncWaterfall(name, init, ...params, callback)
applyPluginsParallel(name, ...params, callback)
applyPluginsParallelBailResult(name, ...params, callback)
applyPluginsParallelBailResult1(name, param, callback)
虽然上面的方法看起来很多,但从函数名就联想到函数的实际功能:
*Waterfall
的方法会将上一个监听器的执行结果传给下一个*BailResult
的方法只会执行到第一个返回结果不为undefined的监听器*Series
的方法会严格线性来执行异步监听器,只有上一个结束下一个才会开始*Parallel
的方法会并行执行异步监听器- 函数名称最后如果带有数字,那么会按照实际的参数传给监听器。如果有数字,则严格按照数字来传递参数个数。
最后 Tapable
类还提供了一个方法 apply
,它的作用是提供了外部插件注册任务点的统一接口,要求都在 apply
方法内部进行任务点注册逻辑:
webpack 中自定义插件就是调用 Compiler
实例对象(继承于 Tapable
)的 apply
方法:
webpack 源码中随处可以见 Tapable
的身影,在了解其工作原理对理解源码很有帮助。Compiler
继承了 Tapable
,同时也作为构建的入口对象,下面我们来看一下。
Compiler
Compiler
是一个编译器实例,在 webpack 的每个进程中只会创建一个对象,它用来创建构建对象 Compilation
,本身需要注意的属性和方法并不是很多。下面我们找几个主要的属性来说一下。
options属性
当 webpack 开始运行时,第一件事就是解析我们传入的配置,然后将配置赋值给 Compiler
实例:
因此,我们可以直接通过这个属性来获取到解析后的 webpack 配置:
如果你不满足于官网给出的配置文档,想要了解更多配置解析,可以看看 WebpackOptionsDefaulter.js 这个文件,这里不再赘述。
输入输出
Compiler
实例在一开始也会初始化输入输出,分别是 inputFileSystem
和 outputFileSystem
属性,一般情况下这两个属性都是对应的 nodejs 中拓展后的 fs
对象。但是有一点要注意,当 Compiler
实例以 watch
模式运行时, outputFileSystem
会被重写成内存输出对象。也就是说,实际上在 watch
模式下,webpack 构建后的文件并不会生成真正的文件,而是保存在内存中。
我们可以使用 inputFileSystem
和 outputFileSystem
属性来帮助我们实现一些文件操作,如果你希望自定义插件的一些输入输出行为能够跟 webpack 尽量同步,那么最好使用 Compiler
提供的这两个变量:
|
|
webpack 的 inputFileSystem
会相对更复杂一点,它内部实现了一些缓存的机制,使得性能效率更高。如果对这部分有兴趣,可以从这个 NodeEnvironmentPlugin
插件开始看起,它是内部初始化了 inputFileSystem
和 outputFileSystem
:
|
|
创建子编译器
在第一篇文章讲解 Compilation
实例化的时候,有略微提及到创建子编译器的内容:
这里为什么会有
compilation
和this-compilation
两个任务点?其实是跟子编译器有关,Compiler
实例通过createChildCompiler
方法可以创建子编译器实例childCompiler
,创建时childCompiler
会复制compiler
实例的任务点监听器。任务点compilation
的监听器会被复制,而任务点this-compilation
的监听器不会被复制。 更多关于子编译器的内容,将在其他文章中讨论。
这里我们来仔细看一下子编译器是如何创建的,Compiler
实例通过 createChildCompiler
的方法来创建:
上面的代码看起来很多,但其实主要逻辑基本都是在拷贝父编译器的属性到子编译器上面。值得注意的一点是第10 - 13行,子编译器在拷贝父编译器的任务点时,会过滤掉make
, compile
, emit
, after-emit
, invalid
, done
, this-compilation
这些任务点。
如果你阅读过第一篇文章(如果没有,推荐先看一下),应该会知道上面任务点在整个构建流程中的位置。从这里我们也可以看出来,子编译器跟父编译器的一个差别在于,子编译器并没有完整的构建流程。 比如子编译器没有文件生成阶段(emit
任务点),它的文件生成必须挂靠在父编译器下面来实现。
另外需要注意的是,子编译器的运行入口并非 run
方法 ,而是有单独的 runAsChild
方法来运行,从代码上面也能够直接看出来,它马上调用了 compile
方法,跳过了 run
, make
等任务点:
那么子编译器有什么作用呢?从上面功能和流程来看,子编译器仍然拥有完整的模块解析和chunk生成阶段。 也就是说我们可以利用子编译器来独立(于父编译器)跑完一个核心构建流程,额外生成一些需要的模块或者chunk。
事实上一些外部的 webpack 插件就是这么做的,比如常用的插件 html-webpack-plugin
中,就是利用子编译器来独立完成 html 文件的构建,为什么不能直接读取 html 文件?因为 html 文件中可能依赖其他外部资源(比如 img 的src属性),所以加载 html 文件时仍然需要一个额外的完整的构建流程来完成这个任务,子编译器的作用在这里就体现出来了:
在下一篇文章中我们将亲自实现一个插件,关于子编译器的具体实践到时再继续讨论。
Compilation
接下来我们来看看最重要的 Compilation
对象,在上一篇文章中,我们已经说明过部分属性了,比如我们简单回顾一下
modules
记录了所有解析后的模块chunks
记录了所有chunkassets
记录了所有要生成的文件
上面这三个属性已经包含了 Compilation
对象中大部分的信息,但是我们也只是有个大致的概念,特别是 modules
中每个模块实例到底是什么东西,我们并不太清楚。所以下面的内容将会比较细地讲解。
但如果你对这部分内容不感兴趣也可以直接跳过,因为能真正使用的场景不会太多,但它能加深对 webpack 构建的理解。
所谓的模块
Compilation
在解析过程中,会将解析后的模块记录在 modules
属性中,那么每一个模块实例又是什么呢?
首先我们先回顾一下最开始的类图,我们会发现跟模块相关的类非常多,看起来类之间的关系也十分复杂,但其实只要记住下面的公式就很好理解:
这个公式的解读是: 一个依赖对象(Dependency)经过对应的工厂对象(Factory)创建之后,就能够生成对应的模块实例(Module)。
首先什么是 Dependency?
我个人的理解是,还未被解析成模块实例的依赖对象。比如我们运行 webpack 时传入的入口模块,或者一个模块依赖的其他模块,都会先生成一个 Dependency
对象。作为基类的 Dependency
十分简单,内部只有一个 module
属性来记录最终生成的模块实例。但是它的派生类非常多,webpack 中有单独的文件夹(webpack/lib/dependencies
)来存放所有的派生类,这里的每一个派生类都对应着一种依赖的场景。比如从 CommonJS 中require一个模块,那么会先生成 CommonJSRequireDependency
。
有了 Dependency 之后,如何找到对应的工厂对象呢?Dependecy
的每一个派生类在使用前,都会先确定对应的工厂对象,比如 SingleEntryDependency
对应的工厂对象是 NormalModuleFactory
。这些信息全部是记录在 Compilation
对象的 dependencyFactories
属性中,这个属性是 ES6 中的 Map
对象。直接看下面的代码可能更容易理解:
一种工厂对象只会生成一种模块,所以不同的模块实例都会有不同的工厂对象来生成。模块的生成过程我们在第一篇文章有讨论过,无非就是解析模块的 request
,loaders
等信息然后实例化。
模块对象有哪些特性呢?
同样在第一篇文章中,我们知道一个模块在实例化之后并不意味着构建就结束了,它还有一个内部构建的过程。所有的模块实例都有一个 build
方法,这个方法的作用是开始加载模块源码(并应用loaders),并且通过 js 解析器来完成依赖解析。这里要两个点要注意:
- 模块源码最终是保存在
_source
属性中,可以通过_source.source()
来得到。注意在build
之前_source
是不存在的。 - js 解析器解析之后会记录所有的模块依赖,这些依赖其实会分为三种,分别记录在
variables
,dependencies
,blocks
属性。模块构建之后的递归构建过程,其实就是读取这三个属性来重复上面的过程:依赖 => 工厂 => 模块
我们再来看看这些模块类,从前面的类图看,它们是继承于 Module
类。这个类实际上才是我们平常用来跟 chunk
打交道的类对象,它内部有 _chunks
属性来记录后续所在的 chunk 信息,并且提供了很多相关的方法来操作这个对象:addChunk
,removeChunk
,isInChunk
,mapChunks
等。后面我们也会看到,Chunk
类与之对应。
Module
类往上还会继承于 DependenciesBlock
,这个是所有模块的基类,它包含了处理依赖所需要的属性和方法。上面所说的 variables
,dependencies
,blocks
也是这个基类拥有的三个属性。它们分别是:
variables
对应需要对应的外部变量,比如__filename
,__dirname
,process
等node环境下特有的变量dependencies
对应需要解析的其他普通模块,比如require("./a")
中的a
模块会先生成一个CommonJSRequireDependency
blocks
对应需要解析的代码块(最终会对应成一个chunk
),比如require.ensure("./b")
,这里的b
会生成一个DependenciesBlock
对象
经过上面的讨论之后,我们基本将 webpack 中于模块相关的对象、概念都涉及到了,剩下还有模块渲染相关的模板,会在下面描述 Template
时继续讨论。
Chunk
讨论完 webpack 的模块之后,下面需要说明的是 Chunk
对象。关于 chunk
的生成,在第一篇文章中有涉及,这里不再赘述。chunk
只有一个相关类,而且并不复杂。Chunk
类内部的主要属性是 _modules
,用来记录包含的所有模块对象,并且提供了很多方法来操作:addModule
,removeModule
,mapModules
等。
另外有几个方法可能比较实用,这里也列出来:
integrate
用来合并其他chunksplit
用来生成新的子 chunkhasRuntime
判断是否是入口 chunk
其他关于 chunk 的内容,有兴趣的同学可以直接查看源码。
Template
Compilation
实例在生成最终文件时,需要将所有的 chunk 渲染(生成代码)出来,这个时候需要用到下面几个属性:
mainTemplate
对应MainTemplate
类,用来渲染入口 chunkchunkTemplate
对应ChunkTemplate
类,用来传染非入口 chunkmoduleTemplate
对应ModuleTemplate
,用来渲染 chunk 中的模块dependencyTemplates
记录每一个依赖类对应的模板
在第一篇文章时,有略微描述过 chunk 渲染的过程,这里再仔细地过一遍,看看这几个属性是如何应用在渲染过程中的:
首先 chunk 的渲染入口是 mainTemplate
和 chunkTemplate
的 render
方法。根据 chunk 是否是入口 chunk 来区分使用哪一个:
两个类的 render
方法将生成不同的”包装代码”,MainTemplate
对应的入口 chunk 需要带有 webpack 的启动代码,所以会有一些函数的声明和启动。
这两个类都只负责这些”包装代码”的生成,包装代码中间的每个模块代码,是通过调用 renderChunkModules
方法来生成的。这里的 renderChunkModules
是由他们的基类 Template
类提供,方法会遍历 chunk 中的模块,然后使用 ModuleTemplate
来渲染。
ModuleTemplate
做的事情跟 MainTemplate
类似,它同样只是生成”包装代码”来封装真正的模块代码,而真正的模块代码,是通过模块实例的 source
方法来提供。该方法会先读取 _source
属性,即模块内部构建时应用loaders之后生成的代码,然后使用 dependencyTemplates
来更新模块源码。
dependencyTemplates
是 Compilation
对象的一个属性,它跟 dependencyFactories
同样是个 Map
对象,记录了所有的依赖类对应的模板类。
上面用文字来描述这个过程可能十分难懂,所以我们直接看实际的例子。比如下面这个文件:
|
|
我们来看看使用 webpack 构建后最终的文件:
其中,从 1-66 行都是 MainTemplate
生成的启动代码,剩余的代码生成如下图所示:
总结
通过这篇文章,我们将 webpack 中的一些核心概念和对象都进行了不同程度的讨论,对于 webpack 中有哪些对象可以操作有了一定的认识。下一篇文章将手动写一个 webpack 插件,来验证这两篇文章的讨论。
最后再次说明,本文内容是由个人理解和整理,如果有不正确的地方欢迎大家指正。如果需要转载,请注明出处。