若你对webpack仅仅是处于使用阶段,觉得webpack原理太杂太乱太多,但是觉得大概了解下webpack的大致原理也不错。亦或是想要了解分包优化如何进行配置呢?以及为什么webpack官方分包配置会从 CommmonsChunkPlugin演变成SplitChunksPlugin呢?我按照自己的方式,通过查阅、整理相关文档,梳理一些比较容易让大家纠结的点,让大家通过本篇文章,大概了解webpack是干了什么?
一、webpack前生今世
1.1、前端石器时代----->工业化时代
前端变迁转折点
- 2008年9月2号,当Chrome第一次出现的时候(V8与Chrome同一天宣布开源),它对网页的加载速度让所有人惊叹,是V8引擎把JavaScript的运行速度提上来了,让前端从蒸汽机机时代正式步入内燃机时代。
- 2009年诞生的Node.js和2010年诞生的npm,迅速将JavaScript变成全球最受欢迎的生态系统之一。前端正式从石器时代进入到了工业化时代。
1.2、前端为什么需要模块化
痛点
- 变量和方法不容易维护,容易污染全局作用域。
- 加载资源的方式通过script标签从上到下。
- 依赖的环境主观逻辑偏重,代码较多就会比较复杂。
- 大型项目资源难以维护,特别是多人合作的情况下,资源的引入会让人崩溃。
作用
模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常来说,一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。
模块规范
但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到Javascript模块现在还没有官方规范,这一点就更重要了。目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系统。
(1)CommonJS
NodeJS诞生之后,它使用CommonJS的模块化规范。从此,js模块化开始快速发展。它有四个重要的环境变量为模块化的实现提供支持:module、exports、require、global。
(2)AMD
CommonJS对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于“假死”状态。因此,浏览器端的模块,不能采用“同步加载”(synchronous),只能采用“异步加载”(asynchronous)。这就是AMD规范诞生的背景。
AMD是“Asynchronous Module Definition”的缩写,意思就是“异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。主要有两个Javascript库实现了AMD规范:require.js和curl.js。
(3)CMD
CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。
(4)ES6
在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
模块化总结
1.3、为什么需要webpack呢?
前端页面效果越来越酷炫、功能越来越复杂。而前端工程师们为了更方便的开发提高开发效率进行了一系列der探索,模块化思想的提出啊,将复杂的程序分割成更小的文件。这些年优秀的框架层出不穷react、vue、angular、es6这种在javascript基础上拓展的新的语法规范和less、sass、css处理器等等等。所有的事物都是具有双面性的、有利有弊。大大提高开发效率的同时,又为后期维护造成了困扰。因为利用这些工具的文件往往不能直接被浏览器识别,需要手动处理,很影响开发进度。
是否可以有一种方式,不仅可以让我们编写模块,而且还支持任何模块格式(至少在我们到达ESM之前),并且可以同时处理资源和资产?所以webpack应运而生~这就是webpack存在的原因。它是一个工具,可以打包你的JavaScript应用程序(支持ESM和CommonJS),可以扩展为支持许多不同的静态资源,例如:images, fonts和stylesheets。
webpack关心性能和加载时间;它始终在改进或添加新功能,例如:异步地加载chunk和预取,以便为你的项目和用户提供最佳体验。
二、webpack概念
webpack是一个用于现代JavaScript应用程序的静态模块打包工具。当 webpack处理应用程序时,它会在内部从一个或多个入口点构建一个依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个bundles,它们均为静态资源,用于展示你的内容。
三、webpack核心流程解析
总体流程架构图
上述提及的各类技术名词不太熟悉的同学,可以先看看简介:
- Entry:编译入口,webpack编译的起点。
- Compiler:编译管理器,webpack启动后会创建compiler对象,该对象一直存活直到结束退出。
- Compilation:单次编辑过程的管理器,比如watch=true时,运行过程中只有一个compiler但每次文件变更触发重新编译时,都会创建一个新的 compilation对象。
- Dependence:依赖对象,webpack基于该类型记录模块间依赖关系。
- Module:webpack内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以“module”为基本单位进行的。
- Chunk:编译完成准备输出时,webpack会将module按特定的规则组织成一个一个的chunk,这些chunk某种程度上跟最终输出一一对应。
- Loader:资源内容转换器,其实就是实现从内容A转换B的转换器。
- Plugin:webpack构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程。
扩展
Compiler是plugin的apply接口传进来的参数,它代表了完整的 webpack环境配置。这个对象在启动webpack时被一次性建立,并配置好所有可操作的设置,包括options,loader和plugin。当在webpack环境中应用一个插件时,插件将收到此compiler对象的引用,可以使用它来访问webpack的主环境。对于plugin而言,通过它来注册事件钩子。
Compilation对象代表了一次资源版本构建。当运行webpack开发环境中间件时,每当检测到一个文件变化,就会创建一个新的compilation,从而生成一组新的编译资源。一个compilation对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。对于plugin而言,通过它来完成数据的处理。
3.1、初始化阶段
- 初始化参数:从配置文件、 配置对象、Shell参数中读取,与默认配置结合得出最终的参数。
- 创建编译器对象:用上一步得到的参数。
- 创建Compiler对象初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化RuleSet集合、加载配置的插件等。
- 开始编译:执行compiler对象的run方法。
- 确定入口:根据配置中的entry找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象。
这个过程需要在webpack初始化的时候预埋下各种插件,经历4个文件,7次跳转才开始进入主题。
3.2、构建阶段
- 编译模块(make):根据entry对应的dependence创建module对象,调用loader将模块转译为标准JS内容,调用JS解释器将内容转换为AST对象,从中找出该模块依赖的模块,再递归。本步骤直到所有入口依赖的文件都经过了本步骤的处理。
- 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间依赖关系图。
构建阶段从entry开始递归解析资源与资源的依赖,在compilation对象内逐步构建出module集合以及module之间的依赖关系。
这个过程中数据流module=>ast=>dependences=>module,先转AST再从AST找依赖。compilation按这个流程递归处理,逐步解析出每个模块的内容以及module依赖关系,后续就可以根据这些内容打包输出。
3.3、生成阶段
输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
seal的关键逻辑是将module按规则组织成chunks,webpack内置的chunk封装规则比较简单:entry及entry触达到的模块,组合成一个 chunk 使用动态引入语句引入的模块,各自组合成一个chunk。
chunk是输出的基本单位,默认情况下这些chunks与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个entry会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源。
四、chunk概念及分包基本规则
4.1、webpack资源形态流转
webpack资源形态流转
从资源流转的层面,我们来看下webpack的打包流程:
- compiler.make阶段:
entry文件以dependence对象形式加入compilation的依赖列表,dependence对象记录有entry的类型、路径等信息。
根据dependence调用对应的工厂函数创建module对象,之后读入module对应的文件内容,调用loader-runner对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为module。
- compilation.seal阶段:
遍历module集合,根据entry配置及引入资源的方式,将module分配到不同的chunk。
遍历chunk集合,调用compilation.emitAsset方法标记chunk的输出规则,即转化为assets集合。
- compiler.emitAssets阶段:
将assets写入文件系统
综上,Module主要作用在webpack编译过程的前半段,解决原始资源“如何读”的问题;而Chunk对象则主要作用在编译的后半段,解决编译产物“如何写”的问题,两者合作搭建起webpack搭建主流程。
4.2、chunk概念
从上面的webpack资源形态流转图以及解析中,我们不难发现chunk的大概概念。
chunk:webpack实现中,原始的资源模块以Module对象形式存在、流转、解析处理。
而Chunk则是输出产物的基本组织单位,在生成阶段webpack按规则将entry及其它Module插入Chunk中,之后再由SplitChunksPlugin插件根据优化规则与ChunkGraph对Chunk做一系列的变化、拆解、合并操作,重新组织成一批性能(可能)更高的Chunks 。运行完毕之后webpack继续将 chunk一一写入物理文件中,完成编译工作。代码块,是webpack根据功能拆分出来的(chunk是无法在打包结果中看到的,打包结果中看到的是bundle)。
4.3、chunk的基本分包规则
chunk可以分为三类:
- 每个entry项都会对应生成一个chunk对象,称之为initial chunk。
- 每个异步模块都会对应生成一个chunk对象,称之为async chunk。
- Webpack 5之后,如果entry配置中包含runtime值,则在entry之外再增加一个专门容纳runtime的chunk对象,此时可以称之为runtime chunk。
默认情况下initial chunk通常包含运行该entry所需要的所有runtime代码,但webpack 5之后出现的第三条规则打破了这一限制,允许开发者将runtime从initial chunk中剥离出来独立为一个多entry间可共享的 runtime chunk。
注意:
- 「业务模块」是指开发者所编写的项目代码。
- 「runtime 模块」是指Webpack分析业务模块后,动态注入的用于支撑各项特性的运行时代码。
4.4、bundle vs chunk
bundle: bundle是webpack打包之后的各个文件,一般就是和chunk是一对一的关系,但有时候也不完全是一对一的关系。bundle就是对chunk进行编译压缩打包等处理之后的产出。
Chunk是过程中的代码块,Bundle是结果的代码块。
五、SplitChunksPlugin的前世今生
默认情况下,Webpack会将所有代码构建成一个单独的包,这在小型项目通常不会有明显的性能问题,但伴随着项目的推进,包体积逐步增长可能会导致应用的响应耗时越来越长。归根结底这种将所有资源打包成一个文件的方式存在两个弊端:
- 「资源冗余」:客户端必须等待整个应用的代码包都加载完毕才能启动运行,但可能用户当下访问的内容只需要使用其中一部分代码。
- 「缓存失效」:将所有资源达成一个包后,所有改动——即使只是修改了一个字符,客户端都需要重新下载整个代码包,缓存命中率极低。
一个多页面应用,所有页面都依赖于相同的基础库,那么这些所有页面对应的entry都会包含有基础库代码,这岂不浪费?这些问题都可以通过对产物做适当的分解拆包解决 ,诞生了CommonsChunkPlugin。
5.1、CommmonsChunkPlugin的弊端
CommmonsChunkPlugin的思路是Create this chunk and move all modules matching minChunks into the new chunk,即将满足minChunks配置想所设置的条件的模块移到一个新的chunk文件中去,这个思路是基于父子关系的,也就是这个新产出的new chunk是所有chunk的父亲,在加载孩子chunk的时候,父亲chunk是必须要提前加载的。举例:
- 同步模块加载
example:
entryA: vue vuex someComponents
entryB: vue axios someComponents
entryC: vue vux axios someComponents minchunks: 2
产出后的chunk:
vendor-chunk:vue vuex axios
chunkA~chunkC: only the components
对entryA和entryB来说,vendor-chunk都包含了多余的module。
- 异步的模块
example:
entryA: vue vuex someComponents
asyncB:vue axios someComponents
entryC: vue vux axios someComponents
minchunks: 2
产出后的chunk:
vendor-chunk:vue vuex
chunkA: only the components
chunkB: vue axios someComponents
chunkC: axios someComponents<