注:本文webpack版本为5.74.0
说到webpack,我们的印象一般是构建、打包以及配置,通过各种loader、plugin将各种各样的资源转化成js,然后把js放到一个、或者若干个文件中,或者通过各种各样的配置,让webpack识别、构建资源的时候附加额外的功能,比如图片的压缩、代码的混淆、不可达代码的优化。
产物分包
在vite那篇文章中,我们使用了手动分包,来达到业务代码、第三份依赖分割的场景,同时即使是官方的分包插件,也是基于手动进行构建的,也就是说,vite的分包功能,或者Rollup的分包功能相比webpack来说,是比较薄弱的。
那现在,我们来了解一下,webpack的构建、打包相关的配置和流程。
chunk
现在,我们先来了解一下webpack的chunk,chunk是webpack一个非常重要的底层设计,用于组织、管理、优化最终产物,那么,chunk是怎么产生的呢?
首先,webpack会根据entry配置,创建若干个chunk对象。
然后,在构建阶段,遍历所有能找到的模块依赖,同一个entry下的模块,分配到的entry的chunk中。
之后,webpack会使用启发式算法,多这些chunk进行裁剪、拆分、合并、优化,并最终尽可能调整成性能最优的形态。
最后,这些chunk会被输出为文件,也就是我们说的打包产物。
所以我们提到的分包、合包,大多数是针对chunk的修改,chunk的结果也影响最终产物的性能。webpack使用了一些列的分包策略来优化chunk,在默认情况下,webpack会将三种模块进行分包处理。
initial chunk,entry模块和子模块打包成此chunk,也就是整个项目代码(业务代码和第三方依赖)。
async chunk,异步模块以及相应的子模块会打包成此模块。
runtime chunk,代码运行时所抽离的chunk,我们在上一篇提到的entry.runtime即可实现此chunk。
但如果根据此规则进行打包,会出现三个问题:
重复打包:比如多个chunk共同依赖一个模块,那么这个模块会被重复打包进不同chunk中。
冗余资源:如果将整个项目代码打进一个包中,那么之后浏览器需要将整个包加载完成才会进行启动,但实际上用户并不需要整个项目的内容。
缓存失效,如果所有代码都在一个包中,那么即使改动了一个字符,那么整个缓存都会失效,缓存形同虚设。
这三个问题需要更加科学的分包,将多个chunk依赖的包打包成单独的chunk,让他们来引用这个共同的chunk,以及将变动较少的资源,比如第三方包,抽离出来,这样提高缓存的命中率。
分包
SplitChunksPlugin是webpack4之后内置的分包方案,不过使用方式并非使用插件的方式,而是通过webpack的optimization.splitChunks进行配置,通过配置不同的规则,将模块放进不同的chunk,并构建出较为合理的产物。
不过此配置项较为抽象,能力包括支持模块路径、模块被引用此时、chunk大小、chunk请求进行分包。
splitChunks的配置分为两部分:
minChunks、minSize、maxInitialRequest 等分包条件,满足这些条件的模块都会被执行分包
cacheGroups 用于为特定资源声明特定分包条件,例如可以为 node_modules 包设定更宽松的分包条件。
在优先级方面cacheGroups大于其他分包逻辑,类似vite中的manualChunks,将匹配到进行分包,而没有匹配到的走默认逻辑。
chunks
在默认情况下,分包配置只对async chunk,也就是异步chunk有效,因此,我们需要设置splitChunks.chunks的作用范围,该配置支持一下值:
all,对initial chunk和async chunk都生效。
initial,只对initial chunk生效。
async ,只对async chunk生效。
函数,接受chunk返回boolean,为true的时候生效。
为了配置达到最佳覆盖,建议使用all,这样,几乎所有chunk都会被配置所控制,来达到最佳优化。
minChunks
这个配置项决定了模块最小引用次数,某些模块被chunk引用次数超过限定次数,而有可能进行分包,注意,这里是有可能。
这里需要注意的是,这里的被引用次数,并非是被import的次数,而是取决于调用者是Initial Chunk,还是Async Chunk。
举个例子:
entryA依赖common,也依赖other,也依赖async-module,entryB依赖common。从import上看,common被引入了4次,但如果从minChunks的角度来讲,entryA和other是一个Initial Chunk,而async-module是被entryA异步引入,因此是Async Chunk,entryB是另一个Initial Chunk,所以实际上,common的引用次数为3。
所以,如果设置splitChunks.minChunks = 2,那么common很可能会被分包。这里又提到一次可能,因为minChunks并非唯一条件,还有其他限制。
最大并行请求数
如果一味着进行分包,那么就会产生大量的HTTP请求,webpack并没有vite那样优化产物依赖的加载,因此过多的产物碎片反而降低了应用性能。因此webpack提供了以下配置项,用于限制分包数量。
maxInitialRequest,用于设置 Initial Chunk 最大并行请求数
maxAsyncRequests,用于设置 Async Chunk 最大并行请求数。
首先,这个请求数,是基于【如果这样打包,那么访问这个模块的时候会有几个请求】这样的概念。为了让大家理解这个概念,我们接着举个例子:
如果有一个chunk包entryA,此时依赖5个被用各种方式分离出依赖chunk1、chunk2、chunk3、chunk4、chunk5。
同时由于不是异步模块,因此加载entryA的时候,我需要一起加载这5个依赖,因此此时的并行请求等于5个依赖加一个entryA,也就是5 + 1 = 6,并行请求次数为6。
那么并行依赖和minChunks的优先级如何呢,如果他们进行冲突,他们的结果是如何呢?
我们来画一个图。
这里有entryA、entryB依赖commonA,entryB、entryC依赖commonB,如果按照minChunks = 2规则进行分包,那么 commonA和commonB会被分别打包,如果同时设置了maxInitialRequest = 2,那么在访问entryB的时候,需要同时加载commonA和commonB,此时并行请求变成了3,超过配置项中的2,那么此时webpack会放弃commonA和commonB中体积较小的包。
maxAsyncRequests的逻辑类似。
那么我们可以总结一下,目前webpack的分包优先级和逻辑:
入口所产生的 Initial Chunk 算作一个并行请求
async chunk并不算并行请求。
runtime chunk也不算并行请求,因为这个是必要的运行时文件。
如果存在minChunks分出了超过maxInitialRequest、maxAsyncRequests的的值,那么将会放弃体积较小的chunk,优先抽离体积较大的包。
分包体积
除了以上被引用之外,webpack还提供了,分包大小有关的规则,因此,我们不止可以通过模块、chunk之间的关系来进行分包,还可以通过更加宏观的方面,比如产物的体积上,来进行分包合包。当产物过碎的时候,我们可以进行合包,当产物过大的时候,我们还可以再次拆解。
与此相关配置有:
minSize: 超过这个尺寸的 Chunk 才会正式被分包,默认2000字节。
maxSize: 超过这个尺寸的 Chunk 会尝试进一步拆分出更小的 Chunk
maxAsyncSize: 与 maxSize 功能类似,但只对异步引入的模块生效
maxInitialSize: 与 maxSize 类似,但只对 entry 配置的入口模块生效
enforceSizeThreshold: 超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 Size 限制
在这里,需要我们进行注意的是,分包配置只见是相互制约,并非达到某个参数之后,就一定分包,而是通过一个主体流程,来判断是否能够进行分包。
首先会根据minChunks进行分包,所以符合此配置的包都会单独抽离为一个独立的chunk对象。
然后需要判断chunk是否满足maxInitialRequests的配置项,如果满足则进行下一步。如果不满足将舍弃较小的chunk,然后继续。
如果此chunk小于minSize,就取消分包,对应的模块合入一开始拆分出来的chunk。如果体积大于minSize就判断是否超过maxSize、maxAsyncSize、maxInitialSize,如果超过,就分割尝试分割更小的部分。
错略来讲,这些配置的优先级是maxInitialRequest/maxAsyncRequests < maxSize < minSize,但如果配置enforceSizeThreshold,那么就不会可能分包,而是一定分包,直接跳过这些条件,强制分包。
cacheGroups
在前文所说,cacheGroups更接近与vite或者rollup的manualChunks,通过test匹配符合的文件,并在idHint中设置一个共同的id,因此符合这个test都会被打包进入对应的chunk。
cacheGroups的配置项中的key是分组名称,value才是配置内容。他接受以下参数:
test: 正则表达式、函数、字符串,所有符合test的都会被分到这个组。
type: 正则、函数、字符串,与test类似,但筛选的并非文件名和路径,而是文件类型。
idHint:字符串,用于设置ChunkID,最终会传递到产物的占位符中。
priority:数字型,用于设置分组优先级,因为一个模块可能匹配到多个缓存组,因此,这个模块优先被分配到priority更大的组。
除了以上的几个配置,cacheGroups还支持 minSize、minChunks、maxInitialRequest 等条件配置,在这里,他们的优先级大于外层。
同时,webpack提供了两个开箱即用的组,分别是default和defaultVendors,默认配置如下:
这两个配置可以帮助我们将将所有 node_modules 中的资源单独打包到 vendors-xxx-xx.js 命名的产物,对引用次数大于等于 2 的模块,也就是被多个 Chunk 引用的模块,单独打包。
不过,也可以将default或者defaultVendors设置为 false,关闭他们。
产物
在webpack5之后,默认使用terser作为代码压缩器,而我们想要启用他,只需要配置optimization.minimize = true即可开启压缩功能,当然,这个也被集成到mode = production中。
如果我们对压缩选项进行详细配置的话,我们需要在minimizer的数组中进行配置,需要主要的,minimizer是一个数组,即使配置项只有一个。
在webpack4中,使用了uglifyjs来压缩代码,不过门可以通过minimizer进行更换为terser。
这里有一个约定,如果配置minimizer的时候保留默认的配置,那么在数组中增加"..."字变量,来保留默认配置。
terser-webpack-plugin
这个插件是我们在webpack详细自定义压缩的插件,我们可以通过对这个插件的配置,来实现高度自由的压缩配置。
test,是一个正则,只有匹配的产物路径才会执行压缩功能
include,在范围内的产物才会进行压缩
exclude,与include相反,不在该范围的产物才会压缩
parallel,是否自动压缩,默认为true,同时会启动若干进程进行压缩,进程数量比当前cpu数量少1
minify,配置压缩器,也支持传入自定义压缩函数,也支持 swc/esbuild/uglifyjs 等值
terserOptions,压缩器函数的配置参数,针对不同压缩器,可以传递不同的参数
extractComments,是否将代码备注抽离为单独文件
这个插件并非只对terser进行了简单的封装,他实现了一个代码压缩的框架,让底层支持多个压缩器,比如SWC、esbuild,使用的时候只需通过minify来匹配值需要的压缩器即可,当然,还需要添加对应的压缩器依赖。
内置压缩器如下:
名称 | 依赖库 | 是否安装依赖 |
TerserPlugin.terserMinify | 依赖于 terser 库 | 否 |
TerserPlugin.uglifyJsMinify | 依赖于 uglify-js | 需安装 uglify-js |
TerserPlugin.swcMinify | 依赖于 @swc/core | 需安装 @swc/core |
TerserPlugin.esbuildMinify | 依赖于 esbuild | 需安装 esbuild |
cssMinimizerWebpackPlugin
css也可以进行压缩,同样的,他的配置项与terser-webpack-plugin类似,也有test、include、exclude、minify、minimizerOptions配置项,其中minify的内置压缩器如下
名称 | 依赖库 | 是否安装依赖 |
CssMinimizerPlugin.cssnanoMinify | 依赖于 cssnano 库 | 否 |
CssMinimizerPlugin.cssoMinify | 依赖于 csso | 需安装 csso |
CssMinimizerPlugin.cleanCssMinify | 依赖于 clean-css | 需安装 clean-css |
CssMinimizerPlugin.esbuildMinify | 依赖于 esbuild | 需安装 esbuild |
CssMinimizerPlugin.parcelCssMinify | 依赖于 parcel-css | 需安装 @parcel/css |
通过基准测试
我们发现其中 parcel-css 与 ESBuild 压缩性能相对较佳,但两者兼容性较弱,因此多数情况下使用cssnano
产物优化
我们之前提到过async chunk,是指的是异步模块都会被打包进的chunk,同时由于是异步的,因此并不需要我们担心他的首屏加载时间。
但异步代码并非总是好的,由于拆分了一些产物,会导致产物碎片化,发起的http通讯次数也会变多,在网络较差的环境下的体验不好,同时webpack会因为需要动态加载,往入口注入一大段支持动态加载的runtime。
这段runtime的大小在2.5k左右,如果动态加载的代码少于2.5k,那么将会是一个负优化。因此我们没有必要为小模块使用动态加载的能力。
对于产物的缓存,我们并没有办法使用webpack决定产物在网络分发的时候的缓存规则,但我们可以使用自定义文件的名称,来适配HTTP的缓存。在这里,webpack提供了一些列占位符。
[fullhash],整个项目的内容 Hash 值,项目中任意模块变化都会产生新的 fullhash
[chunkhash],产物对应 Chunk 的 Hash,Chunk 中任意模块变化都会产生新的 chunkhash
[contenthash],产物内容 Hash 值,仅当产物内容发生变化时才会产生新的 contenthash
因此,我们通过设置hash,可配合HTTP的缓存,让产物文件不会被重复下载,知直到文件内容产生变化,导致名称的hash变化,生成不同了URL路径,才需要请求新的资源文件,因此生产环境下使用contenthash是相当用的。
但是,这里需要注意一点:异步模块变化会引起主 Chunk的 contenthash 同步发生变化。
归根结底是因为异步模块的hash变化,导致了异步模块的名称发生了变化,而主Chunk使用了异步模块的地址,所以主Chunk的contenthash也发生了变化。
而修改非async chunk的子chunk并不会发生这样的问题,因为这些都是同步chunk,子chunk会在头部,将内容推入一个特殊的数据结构webpackChunkwebpack,可以把这个数据结构看做是一个对象数组,key为模块相对地址的字符串,value是内容。主chunk使用模块相对地址的字符串,并不会使用contenthash,由于是同步,因此可以保证主chunk可以读取到key对应的value。
但async chunk并非同步的,因此不能保证webpackChunkwebpack里面有主chunk需要的依赖数据,所以需要实际的async chunk的名称。因此内容产生了变化。
解决方法是抽离runtime chunk,因为内容变化是在runtime chunk里面的,因此我们抽离后,只会影响runtime chunk,而不会影响主Chunk。
我们之前提到过树摇,也就是tree shaking ,最早是在rollup中实现,实际是基于ESM规范的Dead Code Elimination,他会分析模块的之间的导入和导出,如果发现没有用到的模块,会将其删除。在webpack中,需要两个条件来开启树摇功能。
optimization.usedExports为true,标记模块的导入导出列表
启用代码优化比如
mode = production
optimization.minimize = true
提供optimization.minimizer 数组,注意需要optimization.minimize = true才会使用minimizer
除了树摇,我们还可以通过Scope Hoisting,来减少runtime代码的体积,从而优化性能。
而开启Scope Hoisting,webpack提供了三个方法:
mode = production
使用 optimization.concatenateModules 配置项
直接使用 ModuleConcatenationPlugin 插件
这三个方法最终都会调用ModuleConcatenationPlugin 完成模块分析与合并操作,与树摇类似,Scope Hoisting也基于ESM的静态特性,推断模块之间的依赖关系,从而进一步判断模块与模块能否合并,但由于都基于ESM,因此也存在失效的情况。
当模块是非ESM模块的时候,比如AMD、CMD,由于有着导入导出内容的动态性,比如ESM导入导出,在AST中所使用的方法都是固定的,并不需要运行时分析,而对于非ESM,webpack并不能确定树摇、模块合并后会有什么副作用,因此会对上述功能进行关闭,这种问题多发生在NPM包身上,许多NPM包都会自行打包上传到NPM,因此使用了兼容性更佳的CommonJS,因此导致无法启用这些功能,解决方法是使用对应的ESM版本。
当一个模块被多个chunk同时引入,因为为了防止重复打包,Scope Hoisting同样失效。
Loader
loader和plugin是扩展webpack经常涉及到的两个方式,其中,loader负责将资源翻译成webpack能够理解、处理的js代码,而plugin,则是深入整个webpack的构建过程,从而实现全新的逻辑。
其中的loader职责较为单一,因此较容易理解。
什么是loader
如果了解过vite,可能就产生一个疑问了:如果识别新的文件资源,为什么不用plugin而是用loader?换句话说,webpack为什么设计loader。
实际上这个问题比较好回答,loader其实是webpack的一个“抽离”思路的体现,世界上的文件格式是相当多的,不可能一一枚举,即使是一些常见的格式资源,也因为解析的方法不同,而得出不同的结果,因此,webacpk将这个任务抽离出来,交给第三方实现,所以loader正是为了将文件的读取和处理逻辑进行解耦,从而实现特定的资源以及特定的加载。
同时webpack的plugin的逻辑是相当复杂的,并没有vite那只有几个简单的生命周期,因此开发plugin的成本是相当高的,所以webpack产生了loader和plugin这两种处理方式。
从逻辑上来讲,webpack在进入构建阶段后,会通过IO读取文件内容,然后将文件以字符串的形式,通过source的参数传递给loader数组,当然,如同vite的串行钩子一样,source可能是上一个loader处理后的结果。
loader函数接收三个参数。
source:资源输入,第一个执行的loader为资源文件内容,后续的是前一个loader执行之后的结果,一般情况下,loader都需要返回值,当然也有特殊情况。
sourceMap: 可选参数,代码的sourcemap结构。
data:可选参数,需要在其他loader传递信息,比如额外AST对象等。
从这里可以看出来,loader的逻辑与vite插件的transform是类似的,最重要的是其中source参数以及对应的返回值,这个可以将source转译成另一种格式,可以被后续loader识别,或者被webpack识别,从而实现资源的加载。
这里有个需要注意的地方,由于loader是对资源的转移,除了IO密集以外,CPU也是需要大量操作的,因此在js的单线程下具有性能问题,如果使用异步操作——loader支持异步,但增加了异步loader,他的回调也会导致整个loader时间加长。为此,webpack会默认缓存的结果,虽然对用户是友好的,但对loader开发者却并不方便调试,因此可以使用上下文中的cacheable来关闭当前loader的缓存。
上下文
我们在vite中了解到,可以使用上下文来帮助我们实现一些插件的功能,实际上,webpack的loader也提供了相当多的上下文接口,有限制地影响webapck的编译过程,以及产生转换内容之外的产物。
其中比较常用的接口如下:
fs,也就是Compilation 对象的 inputFileSystem 属性,我们可以通过这个对象获取更多资源文件的内容
resource,当前处理文件路径
resourceQuery,文件请求参数,在loader中处理文件,实际是带有一定的参数,来标识需要哪个loader来进行处理
callback,用来返回多个结果
getOptions,用于获取当前 Loader 的配置对象
async,用于声明这是一个异步 Loader,通过 async 接口返回的 callback 函数传递处理结果
emitWarning,添加警告,不会中断构建流程
emitError,抛出错误,不会中断构建流程
emitFile,写出产物,常见于文件的压缩等loader
addDependency,将其他文件增加为构建依赖,如果这些文件发生变化,会触发当前文件的重新构建,针对webpack本身不识别的的依赖有着巨大作用,比如sass中的@import
callback
一般来说,loader直接返回一个处理结果是最简单的,但遇到复杂的场景,就可能需要callback接口返回更多的信息,供下游 Loader 或者 Webpack 本身使用。
我们之前提到过,loader返回转译之后的值,也就是下一个loader使用source,但如果使用了callback,那么下一个loader将以callback的内容为准
callback接受以下参数:
err,抛出异常,一般为null
content,转译结果,也就是return中的值
sourceMap,下一个loader接受的sourcemap
data,下一个loader接受的data
async
我们之前提到过,为了规避cpu密集操作,涉及到的性能问题,loader还可以使用异步返回处理结果。如果使用了async,那么webpack就会将此loader标记为异步加载器,然后,此时整个loader队列会被挂起,等触发async的回调函数之后,才会继续执行接下来的逻辑以及接下来的loader。async的回调函数参数同callback相同。
addDependency
webpack的依赖解析是依赖于js解析实现的,但非js,比如css,那么就需要指定那些依赖的文件,告诉webpack如果这些文件变动的话,就得重新构建了,比如说,sass中的@import,webpack并不知道@import中的字符串是依赖文件,因此如果那些文件变动的话,webpack并不会重新构建,因此为了解决webpack无法识别意想不到的以来的情况,提供了addDependency上下文,让loader开发者来自己指定依赖,不光addDependency,webpack还提供了一下三种依赖相关的上下文。
addContextDependency,目录依赖,整个目录下的文件内容变更都会触发构建流程。
addMissingDependency,与addDependency类似,添加文件依赖。
clearDependencies,清理所有文件依赖。
二进制
有时候我们期望以二进制方式读入资源文件,例如在 file-loader、image-loader 等场景中,此时只需要添加 export const raw = true 语句即可,之后,loader 函数中获取到的第一个参数 source 将会是 Buffer 对象形式的二进制内容。
错误与日志
Webpack 内置了一套 infrastructureLogging 接口,专门用于处理 Webpack 内部及各种第三方组件的日志需求,infrastructureLogging 提供了根据日志分级筛选展示功能,从而将日志的写逻辑与输出逻辑解耦。
因此,在使用getLogger上下文的时候,将对应日志类别传入进去,并在返回的回调对象中,调用对应的日志级别,然后传入日志信息即可。日志级别存在verbose/log/info/warn/error五种可选,同时我们可以在配置文件中,使用infrastructureLogging.level配置需要筛选的日志级别。
但如果是error的话,跟emitError以及emitWarning一样,并不会打断编译流程。
如果使用throw进行抛出错误,亦或者通过async的回调函数或者上下文callback的第一个参数,会导致当前模块的编译失败,虽然依然不会中断编译进行,但会将错误当做当前模块内容,打包进产物文件。
Loader的调用
当提到loader和plugin的执行顺序的时候,我们一般会讲:loader是从后往前调用,plugin是从前往后调用,其实这个说法并非完全正确,因为loader往复式调用,从右往左并非loader的完整调用链,不过loader确实是存在从右往左调用的过程的,但是为什么呢?
这里就要说到关于loader的调用模型了,loader的实现是基于函数组合的链式调用,通俗点讲就是一堆函数组合在一起使用,将外部函数一次通过内部函数的加工,最后输出结果。
这样有两个好处,一是保持单个loader的职责单一,一定程度降低代码的复杂度,二是可以将大模块的处理逻辑拆分为更小的处理单元,提高了单个loader的复用性。
但依然存在两个问题:链条调用一旦启动,就没有中断的机会,除非抛出异常,第二点是有的loader并不关心文件内容,但依然需要等待前一个loader调用完毕才能执行。
于是webpack增加了pitch的概念。
pitch本身是一个函数,挂载在loader函数上面,而pitch函数的执行远远早于loader函数,同时,当调用pitch的时候,是从左往右的!因此,当处理到有关pitch的逻辑的时候,我们需要以从左往右的思路来看。
我们来看pitch需要的参数:
remainingRequest,当前loader之后的资源请求字符串,这个字符串是一串包括了内联数据的loader引用语句
previousRequest,进入当前loader的pitch之前经历过的loader信息
data,用于传递需要在 Loader 传播的信息
这里可能比较难理解,不过如果记住pitch是从左往右进行执行的,然后配合以下例子,就可以比较好的理解pitch的逻辑。现在有三个loader[index,index2,index3,index4],那么每个loader的pitch的参数如下。
那么,pitch有什么用呢?
我们从总体来看一下loader的执行阶段。
loader是分为三个阶段的,pitch阶段、资源读取、loader阶段,在pitch阶段是从左往右执行,执行阶段,是从右往左进行执行。
同时,pitch还具有一个特性,那就是如果在某一个pitch return一个任意值,可以从当前loader中断链条调用,然后将返回值作为资源读取的值,进入前一个loader的执行阶段。
还是拿上图的例子来说,假如在index3 pitch return了一个任意值,那么index4 pitch并不会执行,而是 直接跳到执行阶段的index2 loader。
这一场景在社区中不少知名库都在使用。
比如style-loader、css-loader、less-loader:
在style-loader中,loader本身是一个空函数,这本身是不合规的,因为一般情况下loader是需要有返回值的,但loader的存在是为了挂载pitch,在pitch中根据remainingRequest,获取之后的loader内联数据字符串,拼接到require中,顺带拼接上运行时js,作为资源读取的文件字符串放在return返回。
由于style-loader是在第一位,同时pitch是有返回值的,因此在style-loader的pitch实际的运行后,之后的css-loader、less-loader是无法运行的,但是,由于webpack是不断解析,不断构建的,因此当解析到刚刚pitch 生成的js结果,由于require中使用了remainingRequest,也就是内联loader语句,那么又可以从剩下的loader进行pitch阶段以及资源读取,然后loader执行阶段。
从webpack的角度来看,是对同一个文件调用了两次loader链,第一次是只执行了style-loader的pitch,第二次是执行了除了style-loader以外的完整往复调用。
这也是为什么,我们在上一篇文章提到的并行构建,新增的并行loader需要放在最左边,而非最右边,原因就是新增并行loader的pitch函数,会拦截之后的内联loader请求,并分配到合理的线程池中运行。
vue-loader也有类似的处理逻辑。但更加复杂,因为vue并非只处理js,还处理css,同时使用了plugin,动态地修改了webpack的配置。在执行方面,vue-loader首先给原始文件增加不同的参数,后续可以分开处理这些内容,同时复用用户的loader配置。
plugin
loader较为简单易懂,但plugin却有些复杂,不过,复杂的同时则带来了强大的功能,webpack的plugin可以改写webpack几乎所有的特性,但也伴随着相当陡峭的学习曲线。
什么是plugin
从结构来说,plugin是带着一个apply函数的类。webpack在启动时,就调用插件对象的apply函数,然后将compiler这个核心对象,传递到apply之中,同时核心对象有着独特的钩子以及子钩子。通过这些钩子或者子钩子的处理,我们来完成webpack的插件逻辑。
这个类似于事件订阅模式,但与一般的事件订阅模式不同的是,webpack的插件机制是一种与事件发布源强耦合的订阅模式。
在这里面,最重要的就是compiler,他是webpack的全局管理构建器,在启动的时候,webpack会首先创建compiler,负责管理配置信息、loader、plugin,compiler在webpack的构建生命周期中,在不同的阶段,会传递不同的上下文。
compiler.hooks.compilation,在webpack刚启动完,创建出compilation对象后出发,此时的上下文是当前编译的compilation对象
compiler.hooks.make,在正式构建开始的时候触发,上下文也是当前编译的compilation对象
compilation.hooks.optimizeChunks,seal函数中,chunk集合构建完毕后出发,上下文是chunks集合与chunkGroups集合
compiler.hooks.done,编译完成后触发,stats对象,包含编译构成中的各类统计数据。
compiler钩子相当多,建议进入官网,来详细了解各个的作用,以上钩子仅举例不同的上下文类型。
但实际上,以上只是compiler的钩子,而正常情况下,我们还需要用到子钩子,其中最重要的是Compilation,单词构建过程的管理器,负责遍历模块,执行变异操作,当每次文件触发变更的时候,都会重新触发构建,创建一个新的compilation对象,他的生命周期,依然存在相当多的钩子,可以进去官方文档,来了解对应的钩子的作用。
除此之外,还有 Module、Resolver、Parser、Generator 等关键类型,也都相应暴露了许多钩子。
上下文
钩子是从上下文中进行注册的,同时每个钩子传递的上下文是不同的,因此可以在不同的主钩子注册不同的子钩子,但主要包含一下集中类型。
complation 对象:构建管理器,使用率非常高,主要提供了一系列与单次构建相关的接口,包括:
addModule:用于添加模块,例如 Module 遍历出依赖之后,就会调用该接口将新模块添加到构建需
addEntry:添加新的入口模块,效果与直接定义 entry 配置相似
emitAsset:用于添加产物文件,效果与Loader Context 的 emitAsset 相同
getDependencyReference:从给定模块返回对依赖项的引用,常用于计算模块引用关系
compiler 对象:全局构建管理器,提供如下接口:
createChildCompiler:创建子 compiler 对象,子对象将继承原始 Compiler 对象的所有配置数据
createCompilation:创建 compilation 对象,可以借此实现并行编译
close:结束编译
getCache:获取缓存接口,可借此复用 Webpack5 的缓存功能
getInfrastructureLogger:获取日志对象
module 对象:资源模块,有诸如 NormalModule/RawModule/ContextModule 等子类型,其中 NormalModule 使用频率较高,提供如下接口:
identifier:读取模块的唯一标识符
getCurrentLoader:获取当前正在执行的 Loader 对象
originalSource:读取模块原始内容
serialize/deserialize:模块序列化与反序列化函数,用于实现持久化缓存,一般不需要调用
issuer:模块的引用者
isEntryModule:用于判断该模块是否为入口文件
chunk 对象:模块封装容器,提供如下接口:
addModule:添加模块,之后该模块会与 Chunk 中其它模块一起打包,生成最终产物
removeModule:删除模块
containsModule:判断是否包含某个特定模块
size:推断最终构建出的产物大小
hasRuntime:判断 Chunk 中是否包含运行时代码
updateHash:计算 Hash 值。
stats 对象:构建过程收集到的统计信息,包括模块构建耗时、模块依赖关系、产物文件列表等。
架构
这里的插件和架构,是一种较为抽象的东西,他们通过定义了一套接口,让开发者可以将自己的代码插入到特定环节,以力求变更原始逻辑,然后通过参数或者上下问的方式,将内部信息传到给插件,最后将插件的结果,以各种各样的方式,影响到运行的逻辑。
从上述总结,我们对比webpack,可以发现,webpack通过tapable实现了以钩子的像是插入代码,以回调或者参数的形式传递信息,最后以上下文的输出来影响运行的逻辑。
不同的钩子传递不同的上下文,不同的上下文上面依然存在着自己的钩子。
这一整套的逻辑就在于,框架只需要负责最核心的流程,其他功能都交给插件来实现,因此webpack官方内置相当多的插件,换句话说,webpack的生态就是插件的生态。而插件的生态就是钩子的生态。
我们前文说到,webpack通过tapable来实现事件订阅与发布,那么让我们来看看tapable具体的使用方式:
那么我们来总结一下,Tapable整个逻辑需要有三个步骤:
创建钩子实例
调用订阅接口,注册回调
调用发布接口,触发回调
webpack内部钩子大腿都遵循上面三个步骤,只是有着异步、同步的区别。
同时Tapable提供了相当多类型的钩子,但在这里,webpack并没有全部使用,而是使用了其中的几个,因此我们就着重介绍那些被使用的类型的钩子。
不过,在此之前,我们需要了解一下Tapable的命名规则,说白了,命名规则分为两类
回调逻辑
基本类型,名称不带 Waterfall/Bail/Loop 关键字:与通常 订阅/回调 模式相似,按钩子注册顺序,逐次调用回调
waterfall 类型:前一个回调的返回值会被带入下一个回调,类似vite中的串行
bail 类型:逐次调用回调,若有任何一个回调返回非 undefined 值,则终止后续调用 类似vite中的one类型
loop 类型:逐次、循环调用,直到所有回调函数都返回 undefined
执行回调
sync :同步执行,启动后会按次序逐个执行回调,支持 call/tap 调用语句
async :异步执行,支持传入 callback 或 promise 风格的异步回调函数,支持 callAsync/tapAsync 、promise/tapPromise 两种调用语句
所有钩子都可以按名称套进这两条规则里面,可以注意到,这里面有着Rollup的生命周期的影子
SyncHook
SyncHook是最基本的钩子,触发后会按照注册顺序逐个调用,并且与返回值无关,同时触发回调的时候,也可以选择异步风格的callAsync,当然,逐个并不影响回调的执行逻辑,依然是按照顺序一次执行,触发回调的唯一不同是callAsync函数需要callback,用于处理队列可能出现的错误,在webpack中,代表使用环境是compiler.hooks.compilation
SyncBailHook
这里带有bail关键词,是一个如果任意回调返回了非undefined的值,就中断后续的处理,直接返回该值。而时间的调用顺序与syncHook类似,会以注册顺序逐个调用,如果因为有返回值提前中断回调的执行,那么此返回值会被触发事件的call返回。在webpack中,主要利用这个特性,来确定是否执行后续的操作。代表使用环境是compiler.hooks.shouldEmit。
SyncWaterfallHook
这里带有waterfall关键词,说明是一个串行钩子,上一个函数的结果会被带入到下一个函数,最后一个回调的结果会作为call的调用结果返回。但这也意味着,初始化必须提供参数,而参数则是call触发的时候传入的,在webpack中,代表使用环境是compilation.hooks.assetPath
AsyncParallelHook
可以注意到,这个是一个异步钩子,因此关于事件是并行处理,并不需要做任何等待,类似于vite中的并行钩子,可以在回调函数中写 callback 或 promise 风格的异步操作,同时,此异步钩子与返回值无关,因此,由于并行特殊性,在webpack中的应用也极少,代表使用环境是compilation.hooks.make
AsyncSeriesHook
与类似AsyncParallelHook,这个也是异步钩子,也对返回值无关,但也有一关键点不同,那就是这个钩子是串行执行,也就是说,前一个执行结束,才会执行下一个回调,因此,多用于等待前后顺序重要的等待异步操作完成,在webpack中,代表使用环境是compiler.hooks.done
AsyncSeriesWaterfallHook
这是一个较为复杂的钩子,可以从名字里面来看到,这个是异步钩子,同时是一个串行钩子,前一个回调返回值会传入下一个回调,因此实现起来,tapable使用了动态编译来实现,在webpack中,此钩子使用也较少,代表使用环境是normalModuleFactory.hooks.beforeResolve
动态编译
前面我们讲到了webpack使用的钩子,在AsyncSeriesWaterfallHook的时候,我们提到了动态编译,那么什么是动态编译呢?动态编译是一个非常大胆的设计,他直接使用new Function 语句动态输出一段控制执行流程的 JavaScript 代码实现控制的。
从源码中,我们可以注意到,当函数调用call的时候,实际上是调用CALL_DELEGATE,而CALL_DELEGATE是对_createCall的一层封装,通过在Hook类收集上下文信息,调用_createCall,而_createCall实际是compile函数,而compile是子类进行传入的——每个钩子都单独继承Hook父类,然后实现compile方法,因此,在钩子被使用的时候,是单独new出一个对象。然后在对象上挂载compile,每个钩子函数的compile相同的。
我们先把目光回到new Function 的实现方式上,new Function 在HookCodeFactory中create的实现的,在实现过程中,不光内部进行了逻辑判断、实现,同时调用了方法content拼接固定的代码,而content同样是子类进行实现的。
在每个钩子实现继承Hook父类的地方,还继承了HookCodeFactory,自行实现了content,compile就是调用继承了HookCodeFactory子类的create闭包执行后的动态函数的函数体。
经过上面的逻辑整理,我们可以得出一个结论,call方法大体上可以看做是调用了new Function 语句动态输出函数,然后来执行。
那么问题就来了,我们都知道,在前端环境中,尽可能地少用new Function、eval等功能,因为存在性能、安全性的问题,那么这样使用是没有顾虑的吗?
俗话说,没有坏代码只有坏设计,因此在当前场景下,tapable需要实现串行、异步、前一个回调的返回值传入下一个回调,那么就要在代码中使用递归、循环的手段,但实际代码都是高度相似的,因此是动态编译出的函数逻辑比递归更加清晰、更容易理解。
Intercept
Intercept是tapable的高阶特性,这个是一个中间件接口,通过对目标钩子注册一个中间件,那么就可以在合适的时机,调用中间件内的函数,可以说是一种钩子的钩子,在webpack中,此特性主要用作进度条的展示。
HookMap
HookMap是tapable的另一个高阶特性,它提供了一种集合操作能力,能够降低创建与使用的复杂度,用法比较简单
HookMap 能够用于实现的动态获取钩子功能,例如在 Webpack 的 lib/parser.js 文件中,parser 文件主要完成将资源内容解析为 AST 集合,之后遍历 AST 并以 HookMap 方式对外通知遍历到的内容,如果以独立钩子实现,代码量会急剧膨胀,因此需要使用集合管理钩子。
HookMap的使用方法与普通钩子类似,唯一不同的需要增加for函数,过滤出实际监听的钩子来使用call等方法执行。
小结
我们在这里了解到了产物和产物的优化,稍微了解到了loader和plugin,以及他们背后实现的,但实际上,我们只是接触到webpack的皮毛而已,webpack的核心编译流程以及他的runtime等,我们还一无所知,因此,我们需要基于现在学习到的知识,更上一层台阶,这并非我们的终点,而是我们新的起点。