导语|若你对webpack仅仅是处于使用阶段,觉得webpack原理太杂太乱太多,但是觉得大概了解下webpack的大致原理也不错。亦或是想要了解分包优化如何进行配置呢?以及为什么webpack官方分包配置会从 CommmonsChunkPlugin演变成SplitChunksPlugin呢?我按照自己的方式,通过查阅、整理相关文档,梳理一些比较容易让大家纠结的点,让大家通过本篇文章,大概了解webpack是干了什么?
一、webpack前生今世
(一)前端石器时代—–>工业化时代
前端变迁转折点
2008年9月2号,当Chrome第一次出现的时候(V8与Chrome同一天宣布开源),它对网页的加载速度让所有人惊叹,是V8引擎把JavaScript的运行速度提上来了,让前端从蒸汽机机时代正式步入内燃机时代。
2009年诞生的Node.js和2010年诞生的npm,迅速将JavaScript变成全球最受欢迎的生态系统之一。前端正式从石器时代进入到了工业化时代。
(二)前端为什么需要模块化痛点
变量和方法不容易维护,容易污染全局作用域。
加载资源的方式通过script标签从上到下。
依赖的环境主观逻辑偏重,代码较多就会比较复杂。
大型项目资源难以维护,特别是多人合作的情况下,资源的引入会让人奔溃。
作用
模块化的开发方式可以提供代码复用率,方便进行代码的管理。通常来说,一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。
模块规范
但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到Javascript模块现在还没有官方规范,这一点就更重要了。目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系统。
CommonJS
NodeJS诞生之后,它使用CommonJS的模块化规范。从此,js模块化开始快速发展。它有四个重要的环境变量为模块化的实现提供支持:module、exports、require、global。
AMD
CommonJS对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于“假死”状态。因此,浏览器端的模块,不能采用“同步加载”(synchronous),只能采用“异步加载”(asynchronous)。这就是AMD规范诞生的背景。
AMD是“Asynchronous Module Definition”的缩写,意思就是“异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。主要有两个Javascript库实现了AMD规范:require.js和curl.js。
CMD
CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。
ES6
在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
模块化总结
(三)为什么需要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对象,该对象一直存活直到结束退出。
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而言,通过它来完成数据的处理。
(一)初始化阶段
初始化参数:从配置文件、 配置对象、Shell参数中读取,与默认配置结合得出最终的参数
创建编译器对象:用上一步得到的参数
创建Compiler对象初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化RuleSet集合、加载配置的插件等
开始编译:执行compiler对象的run方法
确定入口:根据配置中的entry找出所有的入口文件,调用compilition.addEntry将入口文件转换为dependence对象。
这个过程需要在webpack初始化的时候预埋下各种插件,经历4个文件,7次跳转才开始进入主题。
(二)构建阶段
编译模块(make):根据entry对应的dependence创建module对象,调用loader将模块转译为标准JS内容,调用JS解释器将内容转换为AST对象,从中找出该模块依赖的模块,再递归。本步骤直到所有入口依赖的文件都经过了本步骤的处理。
完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间依赖关系图。
构建阶段从entry开始递归解析资源与资源的依赖,在compilation对象内逐步构建出module集合以及module之间的依赖关系。
这个过程中数据流module=>ast=>dependences=>module,先转AST再从AST找依赖。compilation按这个流程递归处理,逐步解析出每个模块的内容以及module依赖关系,后续就可以根据这些内容打包输出。
(三)生成阶段
输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
seal的关键逻辑是将module按规则组织成chunks,webpack内置的chunk封装规则比较简单:entry及entry触达到的模块,组合成一个 chunk 使用动态引入语句引入的模块,各自组合成一个chunk。
chunk是输出的基本单位,默认情况下这些chunks与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个entry会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源。
四、chunk概念及分包基本规则
(一)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搭建主流程。
(二)chunk概念
从上面的webpack资源形态流转图以及解析中,我们不难发现chunk的大概概念。
chunk:webpack实现中,原始的资源模块以Module对象形式存在、流转、解析处理。
而Chunk则是输出产物的基本组织单位,在生成阶段webpack按规则将entry及其它Module插入Chunk中,之后再由SplitChunksPlugin插件根据优化规则与ChunkGraph对Chunk做一系列的变化、拆解、合并操作,重新组织成一批性能(可能)更高的Chunks 。运行完毕之后webpack继续将 chunk一一写入物理文件中,完成编译工作。代码块,是webpack根据功能拆分出来的(chunk是无法在打包结果中看到的,打包结果中看到的是bundle)
(三)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分析业务模块后,动态注入的用于支撑各项特性的运行时代码。
(四)bundle vs chunk
bundle: bundle是webpack打包之后的各个文件,一般就是和chunk是一对一的关系,但有时候也不完全是一对一的关系。bundle就是对chunk进行编译压缩打包等处理之后的产出。
Chunk是过程中的代码块,Bundle是结果的代码块。
五、SplitChunksPlugin的前世今生
默认情况下,Webpack会将所有代码构建成一个单独的包,这在小型项目通常不会有明显的性能问题,但伴随着项目的推进,包体积逐步增长可能会导致应用的响应耗时越来越长。归根结底这种将所有资源打包成一个文件的方式存在两个弊端:
「资源冗余」:客户端必须等待整个应用的代码包都加载完毕才能启动运行,但可能用户当下访问的内容只需要使用其中一部分代码。
「缓存失效」:将所有资源达成一个包后,所有改动——即使只是修改了一个字符,客户端都需要重新下载整个代码包,缓存命中率极低。
一个多页面应用,所有页面都依赖于相同的基础库,那么这些所有页面对应的entry都会包含有基础库代码,这岂不浪费?这些问题都可以通过对产物做适当的分解拆包解决 ,诞生了CommonsChunkPlugin。
(一)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 componentschunkB: vue axios someComponents chunkC: axios someComponents
带来的问题是:如果asyncB在entryA中动态引入,则会引入多余的module。
总的来说,CommonsChunkPlugin有以下三个问题:
产出的chunk在引入的时候,会包含重复的代码。
无法优化异步chunk。
高优的chunk产出需要的minchunks配置比较复杂。
综上,CommonsChunkPlugin有很多问题:
它可能会导致许多不必要的代码代码被加载。
它会影响异步加载的chunk。
它很难使用。
它使用起来很难理解。
(二)SplitChunksPlugin的诞生
针对以上种种问题,webpack4集成了新的插件:SplitChunksPlugin
其中,可以发现SplitChunksPlugin产出的vendor-chunk有多个,对于入口A来说,引入的代码只有chunkA、vendor-chunkA-B、vendor-chunkA-C、vendor-chunkA-B-C;这时候chunkA、vendor-chunkA-B、vendor-chunkA-C、vendor-chunkA-B-C形成了一个chunkGroup。SplitChunksPlugin它会使用模块重复计数和模块种类(node_modules)自动识别哪些chunk需要被分离。可以类比一下两个插件。
CommonsChunkPlugin就类似于:创建这个chunk并且把所有与minChunks字段匹配的模块移到新的chunk中。而SplitChunksPlugin就类似于:这是启发式的,确保你需要他们(命令式vs声明式)
(三)SplitChunksPlugin的概念和优点
SplitChunksPlugin是Webpack 4之后引入的分包方案(此前为 CommonsChunkPlugin),它能够基于一些启发式的规则将Module编排进不同的Chunk序列,并最终将应用代码分门别类打包出多份产物,从而实现分包功能。
SplitChunksPlugin有一些很赞的属性:
不会下载不必要的模块(只要你强制使用name属性合并chunk)
对异步加载的chunk也有效
默认情况下,只对异步加载的chunk有效
处理从多个vendor chunks分离出来的vendor
更容易使用
不需要依赖chunk graph的hack手法
更加自动化
命名规则大致举例如下:
六、SplitChunksPlugin使用
使用上,SplitChunksPlugin的配置规则比较抽象,算得上Webpack的一个难点,仔细拆解后关键逻辑在于:
SplitChunksPlugin通过module被引用频率、chunk大小、包请求数三个维度决定是否执行分包操作,这些决策都可以通过 optimization.splitChunks配置项调整定制,基于这些维度我们可以实现:
单独打包某些特定路径的内容,例如node_modules打包为 vendors;
单独打包使用频率较高的文件。
SplitChunksPlugin还提供配置组概念optimization.splitChunks.cacheGroup,用于为不同类型的资源设置更有针对性的配置信息
SplitChunksPlugin还内置了default与defaultVendors两个配置组,提供一些开箱即用的特性:
node_modules资源会命中defaultVendors规则,并被单独打包。
只有包体超过20kb的Chunk才会被单独打包。
加载 Async Chunk 所需请求数不得超过30。
加载 Initial Chunk 所需请求数不得超过30。
(一)分包范围(chunks)
SplitChunksPlugin默认只对Async Chunk生效,开发者也可以通过optimization.splitChunks.chunks调整作用范围,该配置项支持如下值:
字符串’all’ :对Initial Chunk与Async Chunk都生效,建议优先使用该值。
字符串’initial’:只对Initial Chunk生效。
字符串’async’:只对Async Chunk生效。
函数 (chunk)=>boolean:该函数返回true时生效。
module.exports = { //… optimization: { splitChunks: { chunks: ‘all’, }, },}
(二)根据Module使用频率分包(minChunks)
module.exports = { //… optimization: { splitChunks: { // 设定引用次数超过 3 的模块才进行分包 minChunks: 3 }, },}
SplitChunksPlugin支持按Module被Chunk引用的次数决定是否进行分包,开发者可通过optimization.splitChunks.minChunks设定最小引用次数。
示例中,entry-a、entry-b分别被视作Initial Chunk处理;async-module被entry-a以异步方式引入,因此被视作Async Chunk处理。那么对于common模块来说,分别被三个不同的Chunk引入,此时引用次数为 3,命中optimization.splitChunks.minChunks=2规则,因此该模块「可能」会被单独分包,最终产物:entry-a.js entry-b.js async-module.js common.js
(三)限制分包数量(maxInitialRequest/maxAsyncRequests)
在满足minChunks基础上,还可以通过maxInitialRequest/maxAsyncRequests配置项限定分包数量,配置项语义。
maxInitialRequest:用于设置Initial Chunk最大并行请求数。
maxAsyncRequests:用于设置 Async Chunk 最大并行请求数。
注意:这里所说的“请求数”,是指加载一个Chunk时所需同步加载的分包数。例如对于一个Chunk A,如果根据分包规则(如模块引用次数、第三方包)分离出了若干子Chunk A??,那么请求A时,浏览器需要同时请求所有的A??,此时并行请求数等于??个分包加A主包,即?? 1。
若minChunks=2,则common-1 、common-2同时命中minChunks规则被分别打包,浏览器请求entry-b时需要同时请求common-1、common-2两个分包,并行数为2 1=3,此时若maxInitialRequest= 2,则分包数超过阈值,SplitChunksPlugin会放弃common-1、common-2中体积较小的分包。maxAsyncRequest逻辑与此类似,不在赘述。
并行请求数关键逻辑总结如下:
Initial Chunk本身算一个请求Async Chunk不算并行请求通过 runtimeChunk拆分出的runtime不算并行请求如果同时有两个Chunk满足拆分规则,但是maxInitialRequests(或maxAsyncRequest) 的值只能允许再拆分一个模块,那么体积更大的模块会被优先拆解
(四)限制分包体积(minSize、maxSize………)
在满足minChunks与maxInitialRequests的基础上,SplitChunksPlugin还会进一步判断Chunk包大小决定是否分包,这一规则相关的配置项非常多:
minSize:超过这个尺寸的Chunk才会正式被分包。
maxSize:超过这个尺寸的Chunk会尝试继续做分包。
maxAsyncSize:与maxSize功能类似,但只对异步引入的模块生效。
maxInitialSize:与maxSize类似,但只对entry配置的入口模块生效。
enforceSizeThreshold:超过这个尺寸的Chunk会被强制分包,忽略上述其它size限制。
那么,结合前面介绍的两种规则,SplitChunksPlugin的主体流程如下:
SplitChunksPlugin尝试将命中minChunks规则的Module统一抽到一个额外的Chunk对象。
判断该Chunk是否满足maxInitialRequests阈值,若满足则进行下一步。
判断该 Chunk 资源的体积是否大于上述配置项minSize声明的下限阈值。
如果体积「小于」 minSize则取消这次分包,对应的Module依然会被合并入原来的Chunk。
如果Chunk体积「大于」minSize则判断是否超过maxSize、maxAsyncSize、maxInitialSize声明的上限阈值,如果超过则尝试将该Chunk继续分割成更小的部分。
注意,这些属性的优先级顺序为:maxInitialRequest/maxAsyncRequests < maxSize < minSize而命中 enforceSizeThreshold 阈值的 Chunk 会直接跳过这些属性判断,强制进行分包。
(五)cacheGroups
cacheGroups配置项用于为不同文件组设置不同的规则,例如:
基本规则
module.exports = { //… optimization: { splitChunks: { minChunks: 2, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, minChunks: 1, minSize: 0 }, }, }, },};
示例通过cacheGroups属性设置vendors缓存组,所有命中vendors.test规则的模块都会被视作vendors分组,优先应用该组下的minChunks、minSize等分包配置。
除了minChunks等分包基础配置项之外,cacheGroups还支持一些与分组逻辑强相关的属性,包括:
test:接受正则表达式、函数及字符串,所有符合test判断的Module或Chunk都会被分到该组。
type:接受正则表达式、函数及字符串,与test类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如type=’json’会命中所有JSON文件。
name:chunk命名
priority:数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到priority更大的组。
缓存组的作用在于能为不同类型的资源设置更具适用性的分包规则,一个典型场景是将所有node_modules下的模块统一打包到vendors产物,从而实现第三方库与业务代码的分离。
默认分组
module.exports = { //… optimization: { splitChunks: { chunks: ‘async’, minSize: 30000, maxSize: 0, minChunks: 1, maxAsyncRequests: 5, maxInitialRequests: 3, automaticNameDelimiter: ‘~’, name: true, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true } } } }};
这两个配置组能帮助我们:
将所有node_modules中的资源单独打包到vendors.js命名的产物。
对引用次数大于等于2的模块,也就是被多个Chunk引用的模块,单独打包。
七、SplitChunksPlugin最佳实战
那么,如何设置最适合项目情况的分包规则呢?这个问题并没有放诸四海皆准的通用答案,因为软件系统与现实世界的复杂性,决定了很多计算机问题并没有银弹,不过还是有几条可供参考的最佳实践:
「尽量将第三方库拆为独立分包」
「保持按路由分包,减少首屏资源负载」
「尽量保持」 chunks=’all’
八、构建打包工具比较
参考资料:1.Webpack前世今生-说故事的五公子-博客园
2.为什么选择webpack | webpack中文文档
3.GitHub-morrain/webpack-learning: 配套的PPT
4.前端模块化——彻底搞懂AMD、CMD、ESM和CommonJS-奔跑的瓜牛-博客园
5.Javascript模块化编程(一):模块的写法-阮一峰的网络日志
6.Javascript模块化编程(二):AMD规范-阮一峰的网络日志
7.Javascript模块化编程(三):require.js的用法-阮一峰的网络日志
8.[万字总结] 一文吃透Webpack核心原理9.Webpack系列第五篇:彻底理解Webpack运行时10.webpack chunk相关分享
李倩倩
腾讯前端开发工程师
腾讯前端开发工程师,毕业于北京邮电大学。目前负责腾讯新闻业务的前端开发工作。
推荐阅读
C 反射:反射信息的自动生成!
3大问题!Redis缓存异常及处理方案总结
码住!Golang并发安全与引用传递总结
MongoDB全方位知识图谱!