前一阵子研究了tapable
的实现后,就一直在学习webpack
的源码。虽然事先有预料tapable
在webpack
中是非常核心的存在,但过了一遍主流程后,发现比运用的想象中更普遍。可以说整个webpack
就是用tapable
串联起来的。这篇文章主要是记录webpack 主流程中的tapable
运用,并不会涉及到非常细节的代码实现,因为我也没看完囧。
webpack
中的绝大部分功能都是通过注册事件监听实现的,如果把整个打包流程看成是一辆从起点到终点运行的火车,那么每个事件就相当于一个个途中站点,事件监听函数类似于需要在对应站点下车的行人,行人就是我们的一个个Plugin
,注册事件监听类似于在 A 站点上车并且想要在 B 站点下车,事件触发类似于火车到达了 B 站点,行人下车该干嘛干嘛,Plugin
也就在这时开始干活。webpack
中涉及的站点和行人都非常多,想要挨个弄清楚需要花费很多精力,我自己也没有怎么研究,不过了解了主流程后,就可以按兴趣针对性学习了,此时会省很多时间。
流程图
整理了一个主流程图:
- 蓝色的节点表示触发了一个事件
- 黑色的节点表示调用了一个方法
- 竖直的泳道表示节点所属的对象或文件名
- 水平的泳道表示打包流程所属的阶段,整个过程可以分为 3 个阶段
- 编译准备阶段,主要是准备好各种配置项,初始化
Compiler
和Compilation
对象 - 生成
modules
和chunks
,可能一些人对二者的关系不是很清楚,module
可以理解为一个打包产物的最小单元,比如一个js
文件、一张图片;而一个chunk
对应一个最终生成的文件,内部可能包含多个module
;chunk
与module
是一对多的关系 - 生成打包产物,也就是一个个文件
- 编译准备阶段,主要是准备好各种配置项,初始化
上述只画了一部分的节点,实际上涉及到的类和节点要多得多,不过在清楚了这些后,探索其他内容时就那么迷糊了~ 以下所有的代码为了保持注意力,均会省去类似错误处理这样的旁支代码。
编译准备阶段
此阶段准备好各种配置项,初始化Compiler
和Compilation
对象。
首先整个打包的起点位于webpack.js
中的webpack
函数,以下是精简的部分:
1 | const webpack = (options, callback) => { |
WebpackOptionsDefaulter
继承了OptionsDefaulter
类,它的set
方法用于设置每一项的默认值,比如this.set("entry", "./src")
就是另entry
的默认值为./src
.
前半部分代码比较好懂,另外我们暂时不关注watch
选项,所以接下来会调用compiler.run
。在继续之前先看看WebpackOptionsApply.process
:
1 | function process(options, compiler) { |
应用插件的代码比较无趣都略过了,比较关键的是EntryOptionPlugin
,它监听的事件恰好就是compiler.hooks.entryOption
,所以监听函数会立马得到执行:
1 | const itemToPlugin = (context, item, name) => { |
很明显,针对我们配置的entry
选项来应用不同的Plugin
,以单入口SingleEntryPlugin
为例,另外两个类似:
1 | class SingleEntryPlugin { |
监听了两个很重要的事件钩子compilation
和make
,在我们的流程图里也有这两个钩子,等后续钩子触发我们再看具体的实现。
之后就是调用compiler.run
:
1 | run(callback) { |
beforeRun
和run
两个钩子没有什么要特别注意的,最后调用了compile
:
1 | compile(callback) { |
newCompilationParams
会实例化两个工厂类NormalModuleFactory
、ContextModuleFactory
,它们的create
方法会创建NormalModule
、ContextModule
,通过这两个Module
类我们可以将每个模块结合对应的loader
转化为js
代码。
1 | newCompilationParams() { |
newCompilation
用于创建一个Compilation
对象,这个对象挂载了各种各样的构建产物,非常核心:
1 | newCompilation(params) { |
注意在这里触发了hooks.compilation
,还记得之前在SingleEntryPlugin
注册了这个钩子吗?
1 | compiler.hooks.compilation.tap('SingleEntryPlugin', (compilation, { normalModuleFactory }) => { |
dependencyFactories
是一个Map
,用于记录Dependency
与ModuleFactory
之间的映射,在后续hooks.make
钩子中会用到。
接下来就是hooks.make
钩子了,我们的编译准备工作也做完了。
生成modules
和chunks
make
是代码分析的核心流程,包括创建模块、构建模块的工作,而构建模块步骤又包含了:
loader
来处理资源- 处理后的
js
进行AST
转换并分析语句 - 语句中发现的依赖关系再处理拆分成
dep
,即依赖module
,形成依赖网 chunks
生成,找到chunk
所需要包含的modules
SingleEntryPlugin
也注册了make
钩子:
1 | compiler.hooks.make.tapAsync('SingleEntryPlugin', (compilation, callback) => { |
很简单,看看addEntry
:
1 | // 触发第一批 module 的解析,这些 module 就是 entry 中配置的模块。 |
基本上就是调用内部方法_addModuleChain
,另外就是触发了 2 个钩子。
1 | function _addModuleChain(context, dependency, onModule, callback) { |
this.dependencyFactories.get(Dep)
就是我们在compilation
钩子中注册的那个,对于SingleEntryDependency
我们拿到的是NormalModuleFactory
. 看看它的create
方法:
1 | // lib/NormalModuleFactory.js |
如果在beforeResolve
钩子中返回false
,则后续流程会被跳过,即此模块不会被打包。例如IgnorePlugin
处理moment
的打包问题就很典型:
测试发现import moment
时会将所有locale
都打包进了,追查发现在moment
源码中有个函数可以执行导入,虽然默认不会执行:
1 | function loadLocale(name) { |
在webpack
打包过程中,在解析完moment
后发现有locale
的依赖,就会去解析locale
。 在IgnorePlugin
中打断点发现会尝试解析 ./locale
(即result.request
的值):
1 | if ('resourceRegExp' in this.options && this.options.resourceRegExp && this.options.resourceRegExp.test(result.request)) { |
利用 BundleAnalyzerPlugin
可以很明显发现打包产物包含了所有 locale
文件。
解决办法,添加如下配置:
1 | new webpack.IgnorePlugin({ |
此时 IgnorePlugin
会返回 null
,这样我们就跳过了整个locale
的打包。 (此插件注册了 NormalModuleFactory
和 ContextModuleFactory
的 beforeResolve
钩子,locale
的解析是在 ContextModuleFactory
的).
参考资料
以上就是beforeResolve
的一个作用,接下来的factory
钩子的监听函数中会生成NormalModule
实例:
1 | this.hooks.factory.tap('NormalModuleFactory', () => (result, callback) => { |
传入new NormalModule
的参数对象类型示范,这些数据是在resolver
监听中生成的:
1 | result = { |
生成了NormalModule
后会回到Compilation.prototype._addModuleChain
,并在随后调用buildModule
方法并传入新创建的NormalModule
:
1 | buildModule(module, optional, origin, dependencies, thisCallback) { |
核心还是NormalModule.prototype.build
:
1 | build(options, compilation, resolver, fs, callback) { |
内部使用了runLoaders
这个库来转化module
,如何确定每个Module
对应的loaders
呢?在初始化NormalModuleFactory
时会解析options.rules
得到一个ruleSet
属性。 ruleSet
会去匹配文件类型并结合rules
找到需要的loaders
。
1 | // lib/NormalModuleFactory.js |
RuleSet
的解析过程比较复杂,主要是因为rules
配置很灵活,还要兼容一些过时的配置方式,具体过程大家自行了解。
runLoaders
在内部主要是读取module
内容,再迭代loaders
的处理方法,关键的代码有:
1 | // loader-runner/lib/LoaderRunner.js |
注释中也有写到,一个loader
其实就是一个函数:(source: string, inputSourceMap) => string
,比如babel-loader
:
1 | function (source, inputSourceMap) { |
doBuild
方法结束后会拿到module
转化后的js
代码,并在接下来使用Parser.prototype.parse
方法将js
转为AST
。
1 | parse(source, initialState) { |
Parser.parse
中是利用acorn
这个第三方库来生成AST
的,类似的还有esprima
。
一个Module
可能依赖其他Module
,这需要逐个解析AST
节点来确定,由于依赖的方式有很多种比如require
、require.ensure
、import
等,对于每一种依赖都有对应的类例如AMDRequireDependency
,依赖的形式如此之多以至于webpack
专门建了一个lib/dependencies
文件夹。
当
parser
解析完成之后,module
的解析过程就完成了。每个module
解析完成之后,都会触发Compilation
实例对象的任务点succeedModule
,我们可以在这个任务点获取到刚解析完的module
对象。正如前面所说,module
接下来还要继续递归解析它的依赖模块,最终我们会得到项目所依赖的所有modules
。此时任务点make
结束。注意require.ensure
在build
后被放入了module.blocks
而不是module.dependencies
。
接下来按照流程图我们会调用Compilation
对象的finish
和seal
方法。finish
很简单就触发了一个钩子,我们的重点放在seal
上:
1 | seal(callback) { |
到了seal
方法,我们已经处理了所有Module
并统一打平放到了compilation.modules
中,现在需要根据modules
生成chunks
,代码中放了一些大致流程的注释,内部的实现还是很复杂的。
moduleId
和chunkId
作用: 在filename
的变换时会用到[id]/[moduleid]
; chunhHash
会用到chunk id
; 生成的打包代码会将名称替换为id
;
createHash
的会生成hash、moduleHash、chunkHash
,hash
生成算法核心是crypto.createHash + ‘md4’
。在随后的createChunkAssets -> TemplatedPathPlugin
中替换filename
、chunkfileName
的[hash]
、[chunkhash]
.
modules
生成chunks
没有看完,这里记录掌握的东西。涉及到 3 个核心对象:
ChunkGroup
: 内部维护了chunks
、children
、parents
3 个数组,并添加了一系列方法来维护这 3 个数组。chunks
表示这个group
下面拥有多少chunk
;Chunk
:内部维护了groups
、modules
数组。groups
表示此chunk
存在于哪些chunkgroup
中;modules
表示此chunk
内部含有多少module
Module
:内部维护了chunks
数组。chunks
表示此module
存在于哪些chunks
当中。
assignDepth
方法:从 entry 出发,为每个 module 添加一个 depth 属性
entry
的depth
为 0- 依赖的静态模块
depth
+1 - 动态模块的
depth
也是 +1 - 层级遍历
一些参考文档:
生成了chunks
之后,第二阶段就完成了,接下来就是生成打包产物阶段了。
生成打包产物
根据 chunks
生成最终文件,主要有三个步骤:
- 模板
hash
- 更新模板渲染
chunk
- 生成文件
Compilation
在实例化的时候,就会同时实例化三个对象:MainTemplate
、ChunkTemplate
、ModuleTemplate
。这三个对象是用来渲染chunk
对象,得到最终代码的模板。第一个对应了在entry
配置的入口chunk
的渲染模板,第二个是动态引入的非入口chunk
的渲染模板,最后是chunk
中的module
的渲染模板。
上面seal
方法中调用的createHash
就是用于生成模板hash
的,hash
包含两种:
- 本次构建的整体
hash
,用于替换output
选项中的[hash]
,如[name].[hash].js
- 每个
chunk
也会生成一个基于内容的hash
,用于替换output
选项中的[chunkhash]
,如[name].[chunkhash].js
createHash
的主要代码如下:
1 | createHash() { |
hash
生成后接下来就会利用createChunkAssets
方法生成每个chunk
的代码。
首先判断是否为entry
来选择Template
:
1 | // createChunkAssets() |
然后生成文件名:
1 | // 根据配置中的 output.filename 来生成文件名称 |
最后根据模板来拼接文件内容:
1 | const manifest = template.getRenderManifest({ |
render
方法其实是在chunk
内容前后添加各种样板代码,例如MainTemplate
:
1 | render(hash, chunk, moduleTemplate, dependencyTemplates) { |
bootstrap
钩子在多个插件中有注册,例如JsonpMainTemplatePlugin
中的部分示例:
1 | mainTemplate.hooks.bootstrap.tap('JsonpMainTemplatePlugin', (source, chunk, hash) => { |
每个chunk
的代码生成后就会放到compilation.assets
数组中。
生成代码后只剩最后将其输出到文件中了,这一步是在Compiler.prototype.emitAssets
函数中:
1 | const targetPath = this.outputFileSystem.join(outputPath, targetFile); |
以上就是webpack
的主体流程了~
参考文章
- 玩转 webpack
- webpack 源码学习系列](webpack 各个击破)
- webpack 各个击破
- diving-into-webpack
- webpack 源码分析