likes
comments
collection
share

webpack原理解析【长文万字】

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

~ 本文以特别的角度分析webpack原理解析,请耐心坚持看完。

开场白

大家好,我是Webpack,AKA打包老炮,我的slogan是:"打天下的包,让Rollup无包可打"。

今天我要带来的才艺是:剖析打包的艺术

 故事还要从一次npm包工头拉我进项目开始...

  那是一个阳光明媚的凌晨,我的眼前是一个正在脱头发的精神程序小哥。他写了一个很简单的webpack+vue项目,企图通过这个项目来了解我的全部。

  他的要求很过分,但...我答应了,因为从我出生那天开始,我就发四要让每一个前端靓仔深深的爱上我,或许这就是该死的爱情。

webpack原理解析【长文万字】

在办事之前我想先给家人们介绍一下我们穷士康的哥哥姐姐们。

  • 我大表哥【npm】: 给我拉活的
  • 我【webpack】: 穷士康工厂,打工是不可能打工的,这辈子不可能打工的,专门接点打包的单子做做,维持下生活这样子。我们工厂的宗旨是:帮助客户把一堆文件整合成另一堆好一点的文件
  • 厂长【Compiler】: 厂长,老伙计了,每次有单子下来都帮我协调的很好
  • 高级打工人【Compilation】:优秀打工人,很听厂长话,叫他干嘛就干嘛
  • 机动组工人【Plugin】:随传随到,很敬业。在干活的过程中,大家有事就打电话【hooks】叫他们来。他们有些是属于我们厂内部的【有内置插件】,也有第三方请来的【引用第三方插件】
  • 电话【hooks】:有很多个电话号码,能够打给不同的机动组工人【通过触发钩子,来调用插件执行】

他们是我最爱的员工,个个都是人才!!! webpack原理解析【长文万字】

那开始吧,用力敲下npm run build。我便奋不顾身裸露在你面前。

随着 npm run build回车执行,正题开始:

如何叫我【webpack】开始干活 ?

通常下面的代码可能手脚架搭建出来的项目都已经写好了,执行npm run build命令就执行了;但是呢,也有个别的靓仔喜欢特立独行,年轻人嘛!

var webpack = require('webpack') 
var config = require('./webpack.config')  // 自定义配置
webpack(config, (err, stats) => {}) // 调用我我就干活

我会从下面三个地方结合生成最终的配置清单。【优先级也从高到低】

  • 命令行中【process.args读取 - Node的命令行参数解析】
  • 自定义配置
  • 默认配置

接下来就进入了工厂的准备阶段【webpack()函数执行】

工厂准备阶段【初始化阶段】

合并配置

  • validateSchema大姐是我们厂的质检员,先帮我检查下客户的配置有没有出错。
  • new WebpackOptionsDefaulter().process(options)合并配置 webpack原理解析【长文万字】 tips:options可以是数组类型的,这里只按对象【即单个配置】的情况展开讨论

叫厂长出来干活了【创建Compiler实例】

我是老板,我只看不干!

webpack原理解析【长文万字】

于是我把这次的整合好的任务清单【options】给到厂长【Compiler】

compiler = new Compiler(options.context);

叫机动组的人来登记一下【注册插件】

叫上工厂内部机动组人员【内置插件】以及外部机动组人员 【自定义配置引入的外部插件】来登记一下【注册插件】,以便干活的时候联系。

例如: 机动组人1号【SingleEntryPlugin插件】留了个电话make【钩子:make】。到时候我们打给make的时候,机动组人1号【SingleEntryPlugin插件】就能够接到电话然后干专属自己的活。当然可以多个人留同一个电话,到时候也是都能接听到。

毕竟科技改变生活嘛,联想该学学了!

// 注册配置文件的插件
if (options.plugins && Array.isArray(options.plugins)) {
	for (const plugin of options.plugins) {
		if (typeof plugin === "function") {
			plugin.call(compiler, compiler);
		} else {
			plugin.apply(compiler);
		}
	}
}
// 注册内置插件
// 内部插件之多,令人发指。这也进一步说明了我的设计真的很强,功能解耦,拓展性...令人敬佩。不愧是我
// 此外还处理了devtool相关逻辑

new WebpackOptionsApply().process(options, compiler);

厂长下达命令开始打包【Compiler.run()/watch()】

厂长这个时候出来喊话了:

"comeon bady,everybody move!"

【compiler.run()/compiler.watch() ,且都会再调用compiler.compile()】

此话一出,各位打工人便开始忙碌起来!

  • 执行run的话,在打包结束后,厂长就回老家养老了,等我下次再有活他再开三轮车过来。
  • 但如果是watch(),厂长就要一直留在厂里【进程一直在进行】,客户一有需求马上再安排打包工作。【例如:热更新模式再次触发打包】
  • 但是打工人【Compilation】就不一样,到点就一定下班。这次的打包活干完铁定回家,从不996【只负责一次打包任务, 下一次打包再创建新的Compilation实例】。
webpack原理解析【长文万字】

接着上图的代码 webpack原理解析【长文万字】

最强打工人回来干活【实例化Compilation】

贴一下compiler.js中的compile()函数 webpack原理解析【长文万字】

该函数做了两个重要的事:

  • 叫打工人【Compilation】开始干活【实例化Compilation】
  • 拨打号码make【触发钩子make】

至此,我们称上面的工作为工厂的准备阶段【初始化阶段】,

下面我们来总结下工厂准备阶段做了些什么。

初始化阶段总结

webpack原理解析【长文万字】 用厂里的话总结:

  • 老板我【webpack】整合好这次的任务清单【options】,
  • 然后叫厂长回来上班并且把任务清单给他【实例化Compiler】,
  • 厂长叫员工开始干活【compiler.run()/watch()】
  • 打工人回来干活【compiler.compile() => 实例化Compilation】
  • 厂长拨打电话“make”【触发make钩子】

tips:过程中有钩子触发environment,afterEnvironment等,只说对我们重要的钩子

tips:webpack中的钩子,有点类似于发布订阅模式,但更加的强耦合,因为这些订阅者能够影响打包的流程,发布者在通知订阅者时还把compiler、Compilation这些重要的构建上下文作为参数传递过去。这个事件机制是基于Tapable实现,非常值得学习的一种设计。

到这里厂里的准备工作已经告一段落,厂长【Compiler】以及打工人【Compilation】已经就位【实例化】

打工人干活阶段【构建阶段】

还记得我们的宗旨吗?

“帮助客户把一堆文件整合成另一堆好一点的文件”

打工人从入口开始【Compilation.addEntry()】

在拨打“make”号码后,机动组SingleEntryPlugin员工接到电话,此时他不慌不忙的通知打工人【Compilation】从客户给的一堆文件中,根据任务清单【options】中找到入口,从这里开始整理客户提供的一大堆文件【Comilation.addEntry()】。

所以说机动组员工登记要趁早【注册插件】,不然打电话找不到人就麻烦了。

所以说员工按部就班,规规矩矩也是老板的福报。

webpack原理解析【长文万字】

贴一下SingleEntryPlugin插件的代码: webpack原理解析【长文万字】

总结一下:初始化阶段注册了SingleEntryPlugin插件,监听make钩子,监听到则触发compilation.addEntry()从入口文件开始打包。 这个过程不简单,函数与函数之间基本都是通过callback回调,并且还有很多监听钩子的插件触发回调,所以这里需要很耐心很耐心才能够理解。

厂言厂语总结:厂长【Compiler】打电话【hooks:make】,机动组SingleEntryPlugin接到电话,通知打工人【Compilation】从入口开始整理文件【Compilation.addEntry()】

  • tips:addEntry():该函数会接收一个Denpendency对象。后面然后根据Dependency创建Module。
  • tips:Dependency与Module的关系:先根据文件创建的Dependency,再创建Module里面又会解析文件内容创建新的Denpendency,新的Dependency又会创建Module...现有这么大概一个了解,接着往下看

创建盒子【Compilation.handleModuleCreation()】

新介绍一下厂里的材料:

  • 盒子【Module】:只装着一个文件的智能文件盒,最小单位了【这个盒子除了装着文件外,还会记录这个盒子依赖其他盒子的信息【盒子标签】】
  • 盒子标签【Dependency】:记录着一个文件地址,打工人【Compilation】会根据这个盒子标签【Dependency】创建智能文件盒【Module】,【盒子之间的关系也由盒子标签记录着】

打工人【Compilation】在拿到入口的盒子标签后【Compilation.addEntry(dependency)】,会创建一个对应的盒子【Compilation.handleModuleCreation(dependency)】,紧接着Compilation会对这个盒子装着的文件进行解析,分析盒子有没有依赖其他的盒子,有的话就在这个盒子上面贴上这些盒子标签。

想了很久,觉得还是有必要给你看看当时的情况。 webpack原理解析【长文万字】

这个盒子分析完之后呢,打工人会看看盒子上面有没有贴着盒子标签,有的话就又创建对应新的盒子了。

接下来盒子是如何分析以及打上盒子标签的...

解析文件【Loader + Parser】

到这里我需要再给大家介绍下厂里的其他成员:

  • 加载机器【Loader】:厂里的机器设备,将客户的文件变成JavaScript
  • 解析员【Parser.js】:浸过咸水的高材生,将JavaScript通过acorn解析成AST,分析AST发现线索就打电话通知其他人【触发钩子】

趁这次机会想给各位员工再培训一下,知己知彼,百战不殆!

“各位员工好,大家都知道我们工厂服务对象是前端靓仔,而且我们的工厂是把前端靓仔提供的文件变成另一堆更合理的文件。”

“那各位知道为什么我们是怎么做到的吗?"

”你们说得对,首先客户文件先转成JavaScript资源,我们配置了很多加载机器【Loader】,能够处理各种类型文件,变成Javascript“

”变成JavaScript之后呢?我们需要分析文件内容,这样我们才知道客户提供的文件哪些是要拆开的,哪些是要合并的,哪些是没用要去掉的,文件之前是怎么关联的。那怎么分析文件内容呢?“

这个时候解析员走了出来说到: ”是AST,我用了AST! 我将JavaScript变成AST之后,去分析里面的(import/require)关键字,如果发现了就会打电话号码”exportImportSpecifier“【触发hooks:exportImportSpecifier】去通知机动人员HarmonyExportDependencyParserPlugin“,

”据我所知,这个机动组人员就是在盒子上打上盒子标签【Dependency】的那个人“。 话音刚落,海归解析员嘴角止不住微微扬起,内心止不住想:还有谁?

webpack原理解析【长文万字】

md,厂长【Compiler】忍不了别人装b,手里拿着两张工作流程图走了出来,里面有各个角色的分工以及流程。看完图纸我笑了,不愧是厂长,稳如老狗。还是我最信任的那个男人!

webpack原理解析【长文万字】
这两个图纸要细看,厂里最值钱的东西了。

简易版: webpack原理解析【长文万字】 详细版:【这张图凝聚了我一个寂寞夜深的心血】 一定要看这个图,细品 webpack原理解析【长文万字】 所以提个问题?

顺藤摸瓜【Dependency -> Module】

”入口文件index.js内导入了a.js,那我们工厂是怎么知道他们是有这样的引用关系的呢?打工人【Compilatin】,你站起来说一下“

打工人【Compilatin】也站了起来,忍不了高材生装b的看来不止厂长一个,说道: “

  • 首先我【Compilation】会读取配置的入口信息,找到入口文件index.js装在一个文件盒【module】。
  • 标记1紧接着把这个文件盒【module】放到加载机器【Loader】转化成的Javascript,Javascript也同样放在文件盒内。
  • 这个时候解析员【Parser.js】接收了文件盒【module】,把盒子里的Javascript通过acorn转化成AST对象
  • 解析原【Parser.js】对这个AST进行分析,也可以说是对这个文件的内容进行分析,如果发现【require/import】这样的关键字,就说明这个文件跟其他文件存在关联,准确来说是这个文件依赖其他文件。
  • 紧接着解析员拨打电话号码”exportImportSpecifier“要通知机动人员【触发hooks:exportImportSpecifier】,当然解析员也不知道接电话的是谁,总之打电话,说明文件关联的事情就完事。真正的高效就是每个人职责分明,分工明确。
  • 这个时候机动组工人HarmonyExportDependencyParserPlugin【HarmonyExportDependencyParserPlugin插件】接到电话”exportImportSpecifier“,然后给这个文件盒【module】加上依赖信息dependencies,说明需要依赖哪个文件【调用module.addDependency()】
  • 这个时候我检查一下这个文件盒【module】的dependencies依赖信息,发现依赖信息记录着a.js,此时利用依赖创建一个新的文件盒,把文件装进去【创建module对象】, 又从标记1开始处理这个文件盒,直到客户递归完所有的文件盒子。
  • 好了,最后你们自己好好想想这个文件盒里面除了文件还有什么信息。 "

说完,全场掌声雷动,精彩的演讲!堪称前端特冷普。

webpack原理解析【长文万字】

那么你如何理解module顺藤摸瓜的过程?

  • 1、make钩子发布,SingleEntryPlugin监听到调用Compilation.addEntry()。从第一个入口module开始
  • 2、module通过loader转JavaScript再由acorn转AST后
  • 3、分析语句import/require等依赖语句,生成dependency对象,通过依赖对象生成新的module
  • 4、新的module再从2开始循环,直到不能找到其他依赖

构建过程总结

从对象的角度

compilation

  • addEntry()找到入口文件开始,
  • 不同文件类型创建不同的module子类
  • 调用module.build()开始构建
  • 遍历module的依赖列表,【遍历依赖列表再重复构建过程】

module

  • 从module.build()开始
  • 调用runLoader将文件转成Javascript资源
  • Javascript资源再由Parser对象使用acorn转成AST【parser.parse()】
  • 解析AST的过程触发解析语句相关钩子
  • HarmonyExportDependencyParserPlugin插件监听到AST解析钩子exportImportSpecifier则会回调module.addDependency()将依赖对象添加到module的依赖列表中
  • handleParseResult()执行后执行回到compilation执行【compilation遍历module的依赖列表】

Parser

  • 从module内调用parser.parse()开始
  • 利用acorn将Javascript转成AST对象
  • 分析AST【prewalkStatements()、walkStatements()】,触发钩子exportImportSpecifier
  • HarmonyExportDependencyParserPlugin监听到钩子,则调用module.addDependency()
  • 回到module执行

从文件的角度

例如以下文件结构,index.js作为入口

webpack原理解析【长文万字】

此时通过Compilation.addEntry()找到index.js,导入成module对象,同时分析得出module的依赖列表有left.js以及right.js

webpack原理解析【长文万字】

此时遍历依赖列表[dependency-left.js, dependency-right.js],创建对应的module-left.js、module-right.js

webpack原理解析【长文万字】

再遍历下一批依赖

webpack原理解析【长文万字】

再看下完整的文件构建流程

webpack原理解析【长文万字】

至此构建阶段告一段落。在文件盒都准备好之后,则进入下一步

装箱交货阶段【生成阶段】

先思考一个问题:我们为什么要装箱?

如果将我们的开发文件1比1的打包到生产环境上的话,数量上是非常大的,这会导致浏览器发起的http请求资源次数则需要非常频繁,可能一个module就一两行代码,我们依然要发起一个http去请求它回来。而浏览器对同时能发起的http数量是有限制的,例如chrome就是最多同时6个请求,所以最后放到的生产的文件不能太多,不能太小,也不能太大。我们有时候看到的各种打包优化策略,也是朝着这个目标尽量实现。所以我们需要将他们合并组成一个一个块,以达到目的。

这里我们再介绍一个我们的厂的成员:

  • 箱子【chunk】:我们厂拿来装文件盒【module】用的,就是你们平常见的纸皮箱,没什么特别。但是,那么多文件盒,把他们合理的分配在不同箱子就是门技术活了。

在上面阶段整理出来那么多的文件盒【module】,我们会先把他们装箱【chunk】再交付客户【输出文件】。

装箱【compilation.seal()】

这些箱子里面装的都是module

webpack原理解析【长文万字】

在递归生成所有的文件盒【module】之后,打工人【Compilation】就开始装箱操作了【compilation.seal()】【module分配到chunk】。

其实我也一直疑惑,打工人【Compilation】是怎么装箱的,例如要使用到多少个箱子?哪些文件盒放到哪些箱子?这其中必然有功夫。

于是我以老板的身份要求打工人说出自己的秘籍!

打工人【Compilation】开始巴拉巴拉:“除非升职加薪,否则不说!”

我:“最近公司准备优...”,话还没说完,

打工人准备全盘托出:

webpack原理解析【长文万字】

“首先我自己有一套默认装箱的规则”

  • 一个入口能关联到的所有文件盒【module】放到一个箱子【chunk】
  • 动态依赖的文件盒【例如import()这样的语法】,放到一个箱子【chunk】

动态依赖的分包规则补充一个点: 例如:webpack原理解析【长文万字】

那么一个箱子装【index.js+right.js】,另一个箱子装【left.js + left-1.js + left-2.js】.而不是另一个箱子只装【left.js】喔!

装箱逻辑的实现在不同的webpack版本中差异还是比较大的:

〇 webpack4的时候还是相对简单一点点,装箱逻辑直接利用module以及dependency之间相互引用的信息,

〇 webpack5则做了升级【将module与dependency之间的依赖关系拆分的更加细致】:

增加了下面这些对象

  • ChunkGraph:所有关于模块如何与块连接的信息现在都存储在 ChunkGraph 类中

  • ModuleGraph:所有关于模块如何在模块图中连接的信息现在都存储在 ModuleGraph 类中。

  • ModuleGraphConnection:记录模块间的引用以及被引用的关系;originModule字段记录被引用的module,module字段表示自己

  • ModuleGraphModule:incomingConnections字段记录自己的ModuleGraphConnection,outgoingConnections字段记录依赖的module的ModuleGraphConnection

    webpack5正是将module与depenency的关系拆分的如此细致,加上在seal()阶段做了更多的优化,webpack5在打包性能上面又进步了不少。 例如:

    • 优化TreeShaking
    • 增加基于文件系统的的持久化缓存,对于多次构建提升非常明显。而以前只有基于内存的持久化缓存,在增量构建的时候略显鸡肋,性能与时间不能同时兼顾。

这里再介绍两位帮助优化装箱的工厂成员:

  • 机动组工人罗老师【SplitChunksPlugin】:webpack>=4时用,大家叫他装箱【分包】管理大师,很会管理,所以大家叫他罗老师,由于太出色,招进来当我们webpack工厂内部人员了【内置插件】
  • 机动组工人王宝强【SplitChunksPlugin】:webpack<4时用,老实憨厚的老一辈【分包】大师【第三方插件】,没啥好说的,一杯敬乃亮,一杯敬宝强
webpack原理解析【长文万字】

打工人【Compilation】把老底都说出来时候,眼泪忍不住从眼角流出,向生活低下了高贵的头颅。

打工人【Compilatin】继续说道:

“除了内置的装箱规则,客户也可以点名叫机动组的人帮他们分包【插件-上面提到的两位大师】”

语气里多少都带点小脾气。

“还有就是机动组员【SplitChunksPlugin】会在我装箱之后,再偷偷摸摸再搞一遍装箱,客户自定义的分包规则就是在这里起作用的,当然客户不自定义,罗老师也有自己的一套默认规则”

罗老师的默认配置

module.exports = {
  
  
  optimization: {
    //罗老师的默认规则,webpack4跟webpack5默认好像不一样
    splitChunks: {
      chunks: 'async', // 2. 处理的 chunk 类型
      minSize: 20000, // 4. 允许新拆出 chunk 的最小体积
      minRemainingSize: 0,
      minChunks: 1, // 5. 拆分前被 chunk 公用的最小次数
      maxAsyncRequests: 30, // 7. 每个异步加载模块最多能被拆分的数量
      maxInitialRequests: 30, // 6. 每个入口和它的同步依赖最多能被拆分的数量
      enforceSizeThreshold: 50000, // 8. 强制执行拆分的体积阈值并忽略其他限制
      cacheGroups: { // 1. 缓存组
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/, // 1.1 模块路径/文件名匹配正则
          priority: -10, // 1.2 缓存组权重
          reuseExistingChunk: true, // 1.3 复用已被拆出的依赖模块,而不是继续包含在该组一起生成
        },
        default: {
          minChunks: 2, // 5. default 组的模块必须至少被 2 个 chunk 共用 (本次分割前) 
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

交付客户【输出文件】

这里没啥好说的,打工人【Compilation】装箱之后【compilation.seal()完成】,厂长【Compiler】就把箱子给到客户了【compiler.emiAssets()将chunk生成一个个文件】

结尾

就这样日复一日,年复一年,我们厂为所有一线奋斗的客户解决模块化打包的问题。渐渐的有些客户想了解我们工厂内部的运作原理,为此我作为工厂老板,势必要为我们穷士康写下这么一篇文章,让更多的人能够了解我们。但苦于才疏学浅,文笔功力有限;且厂里部门繁多,各种人情世故暂时也只能写下这么一篇水平有限的文章。

不到之处望海涵~

看到文章的人今年必定升值加薪!

点个赞吧👍【肮脏手段:不点赞不是中国人】