侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

简易的webpack5配置方法(二):产物分包和loader和plugin

lumozx
2022-08-12 / 0 评论 / 0 点赞 / 112 阅读 / 100195 字

注:本文webpack版本为5.74.0

说到webpack,我们的印象一般是构建、打包以及配置,通过各种loader、plugin将各种各样的资源转化成js,然后把js放到一个、或者若干个文件中,或者通过各种各样的配置,让webpack识别、构建资源的时候附加额外的功能,比如图片的压缩、代码的混淆、不可达代码的优化。

产物分包

在vite那篇文章中,我们使用了手动分包,来达到业务代码、第三份依赖分割的场景,同时即使是官方的分包插件,也是基于手动进行构建的,也就是说,vite的分包功能,或者Rollup的分包功能相比webpack来说,是比较薄弱的。

那现在,我们来了解一下,webpack的构建、打包相关的配置和流程。

chunk

现在,我们先来了解一下webpackchunkchunkwebpack一个非常重要的底层设计,用于组织、管理、优化最终产物,那么,chunk是怎么产生的呢?

  1. 首先,webpack会根据entry配置,创建若干个chunk对象。

  2. 然后,在构建阶段,遍历所有能找到的模块依赖,同一个entry下的模块,分配到的entrychunk中。

  3. 之后,webpack会使用启发式算法,多这些chunk进行裁剪、拆分、合并、优化,并最终尽可能调整成性能最优的形态。

  4. 最后,这些chunk会被输出为文件,也就是我们说的打包产物。

所以我们提到的分包、合包,大多数是针对chunk的修改,chunk的结果也影响最终产物的性能。webpack使用了一些列的分包策略来优化chunk,在默认情况下,webpack会将三种模块进行分包处理。

  • initial chunkentry模块和子模块打包成此chunk,也就是整个项目代码(业务代码和第三方依赖)。

  • async chunk,异步模块以及相应的子模块会打包成此模块。

  • runtime chunk,代码运行时所抽离的chunk,我们在上一篇提到的entry.runtime即可实现此chunk

但如果根据此规则进行打包,会出现三个问题:

  1. 重复打包:比如多个chunk共同依赖一个模块,那么这个模块会被重复打包进不同chunk中。

  2. 冗余资源:如果将整个项目代码打进一个包中,那么之后浏览器需要将整个包加载完成才会进行启动,但实际上用户并不需要整个项目的内容。

  3. 缓存失效,如果所有代码都在一个包中,那么即使改动了一个字符,那么整个缓存都会失效,缓存形同虚设。

这三个问题需要更加科学的分包,将多个chunk依赖的包打包成单独的chunk,让他们来引用这个共同的chunk,以及将变动较少的资源,比如第三方包,抽离出来,这样提高缓存的命中率。

分包

SplitChunksPluginwebpack4之后内置的分包方案,不过使用方式并非使用插件的方式,而是通过webpackoptimization.splitChunks进行配置,通过配置不同的规则,将模块放进不同的chunk,并构建出较为合理的产物。

不过此配置项较为抽象,能力包括支持模块路径、模块被引用此时、chunk大小、chunk请求进行分包。

splitChunks的配置分为两部分:

  • minChunksminSizemaxInitialRequest 等分包条件,满足这些条件的模块都会被执行分包

  • cacheGroups 用于为特定资源声明特定分包条件,例如可以为 node_modules 包设定更宽松的分包条件。

在优先级方面cacheGroups大于其他分包逻辑,类似vite中的manualChunks,将匹配到进行分包,而没有匹配到的走默认逻辑。

chunks

在默认情况下,分包配置只对async chunk,也就是异步chunk有效,因此,我们需要设置splitChunks.chunks的作用范围,该配置支持一下值:

  • all,对initial chunkasync chunk都生效。

  • initial,只对initial chunk生效。

  • async ,只对async chunk生效。

  • 函数,接受chunk返回boolean,为true的时候生效。

为了配置达到最佳覆盖,建议使用all,这样,几乎所有chunk都会被配置所控制,来达到最佳优化。

minChunks

这个配置项决定了模块最小引用次数,某些模块被chunk引用次数超过限定次数,而有可能进行分包,注意,这里是有可能。

这里需要注意的是,这里的被引用次数,并非是被import的次数,而是取决于调用者是Initial Chunk,还是Async Chunk

举个例子:

entryA依赖common,也依赖other,也依赖async-moduleentryB依赖common。从import上看,common被引入了4次,但如果从minChunks的角度来讲,entryAother是一个Initial Chunk,而async-module是被entryA异步引入,因此是Async ChunkentryB是另一个Initial Chunk,所以实际上,common的引用次数为3。

所以,如果设置splitChunks.minChunks = 2,那么common可能会被分包。这里又提到一次可能,因为minChunks并非唯一条件,还有其他限制。

最大并行请求数

如果一味着进行分包,那么就会产生大量的HTTP请求,webpack并没有vite那样优化产物依赖的加载,因此过多的产物碎片反而降低了应用性能。因此webpack提供了以下配置项,用于限制分包数量。

  • maxInitialRequest,用于设置 Initial Chunk 最大并行请求数

  • maxAsyncRequests,用于设置 Async Chunk 最大并行请求数。

首先,这个请求数,是基于【如果这样打包,那么访问这个模块的时候会有几个请求】这样的概念。为了让大家理解这个概念,我们接着举个例子:

如果有一个chunkentryA,此时依赖5个被用各种方式分离出依赖chunk1chunk2chunk3chunk4chunk5

同时由于不是异步模块,因此加载entryA的时候,我需要一起加载这5个依赖,因此此时的并行请求等于5个依赖加一个entryA,也就是5 + 1 = 6,并行请求次数为6。

那么并行依赖和minChunks的优先级如何呢,如果他们进行冲突,他们的结果是如何呢?

我们来画一个图。

这里有entryAentryB依赖commonAentryBentryC依赖commonB,如果按照minChunks = 2规则进行分包,那么 commonAcommonB会被分别打包,如果同时设置了maxInitialRequest = 2,那么在访问entryB的时候,需要同时加载commonAcommonB,此时并行请求变成了3,超过配置项中的2,那么此时webpack会放弃commonAcommonB中体积较小的包。

maxAsyncRequests的逻辑类似。

那么我们可以总结一下,目前webpack的分包优先级和逻辑:

  • 入口所产生的 Initial Chunk 算作一个并行请求

  • async chunk并不算并行请求。

  • runtime chunk也不算并行请求,因为这个是必要的运行时文件。

  • 如果存在minChunks分出了超过maxInitialRequestmaxAsyncRequests的的值,那么将会放弃体积较小的chunk,优先抽离体积较大的包。

分包体积

除了以上被引用之外,webpack还提供了,分包大小有关的规则,因此,我们不止可以通过模块、chunk之间的关系来进行分包,还可以通过更加宏观的方面,比如产物的体积上,来进行分包合包。当产物过碎的时候,我们可以进行合包,当产物过大的时候,我们还可以再次拆解。

与此相关配置有:

  • minSize: 超过这个尺寸的 Chunk 才会正式被分包,默认2000字节。

  • maxSize: 超过这个尺寸的 Chunk 会尝试进一步拆分出更小的 Chunk

  • maxAsyncSize: 与 maxSize 功能类似,但只对异步引入的模块生效

  • maxInitialSize: 与 maxSize 类似,但只对 entry 配置的入口模块生效

  • enforceSizeThreshold: 超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 Size 限制

在这里,需要我们进行注意的是,分包配置只见是相互制约,并非达到某个参数之后,就一定分包,而是通过一个主体流程,来判断是否能够进行分包。

  1. 首先会根据minChunks进行分包,所以符合此配置的包都会单独抽离为一个独立的chunk对象。

  2. 然后需要判断chunk是否满足maxInitialRequests的配置项,如果满足则进行下一步。如果不满足将舍弃较小的chunk,然后继续。

  3. 如果此chunk小于minSize,就取消分包,对应的模块合入一开始拆分出来的chunk。如果体积大于minSize就判断是否超过maxSize、maxAsyncSize、maxInitialSize,如果超过,就分割尝试分割更小的部分。

错略来讲,这些配置的优先级是maxInitialRequest/maxAsyncRequests < maxSize < minSize,但如果配置enforceSizeThreshold,那么就不会可能分包,而是一定分包,直接跳过这些条件,强制分包。

cacheGroups

在前文所说,cacheGroups更接近与vite或者rollupmanualChunks,通过test匹配符合的文件,并在idHint中设置一个共同的id,因此符合这个test都会被打包进入对应的chunk

cacheGroups的配置项中的key是分组名称,value才是配置内容。他接受以下参数:

  • test: 正则表达式、函数、字符串,所有符合test的都会被分到这个组。

  • type: 正则、函数、字符串,与test类似,但筛选的并非文件名和路径,而是文件类型。

  • idHint:字符串,用于设置ChunkID,最终会传递到产物的占位符中。

  • priority:数字型,用于设置分组优先级,因为一个模块可能匹配到多个缓存组,因此,这个模块优先被分配到priority更大的组。

除了以上的几个配置,cacheGroups还支持 minSize、minChunks、maxInitialRequest 等条件配置,在这里,他们的优先级大于外层。

同时,webpack提供了两个开箱即用的组,分别是defaultdefaultVendors,默认配置如下:

这两个配置可以帮助我们将将所有 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是相当用的。

但是,这里需要注意一点:异步模块变化会引起主 Chunkcontenthash 同步发生变化。

归根结底是因为异步模块的hash变化,导致了异步模块的名称发生了变化,而主Chunk使用了异步模块的地址,所以主Chunkcontenthash也发生了变化。

而修改非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.usedExportstrue,标记模块的导入导出列表

  • 启用代码优化比如

  • mode = production

  • optimization.minimize = true

  • 提供optimization.minimizer 数组,注意需要optimization.minimize = true才会使用minimizer

除了树摇,我们还可以通过Scope Hoisting,来减少runtime代码的体积,从而优化性能。

而开启Scope Hoistingwebpack提供了三个方法:

  • mode = production

  • 使用 optimization.concatenateModules 配置项

  • 直接使用 ModuleConcatenationPlugin 插件

这三个方法最终都会调用ModuleConcatenationPlugin 完成模块分析与合并操作,与树摇类似,Scope Hoisting也基于ESM的静态特性,推断模块之间的依赖关系,从而进一步判断模块与模块能否合并,但由于都基于ESM,因此也存在失效的情况。

  1. 当模块是非ESM模块的时候,比如AMD、CMD,由于有着导入导出内容的动态性,比如ESM导入导出,在AST中所使用的方法都是固定的,并不需要运行时分析,而对于非ESM,webpack并不能确定树摇、模块合并后会有什么副作用,因此会对上述功能进行关闭,这种问题多发生在NPM包身上,许多NPM包都会自行打包上传到NPM,因此使用了兼容性更佳的CommonJS,因此导致无法启用这些功能,解决方法是使用对应的ESM版本。

  2. 当一个模块被多个chunk同时引入,因为为了防止重复打包,Scope Hoisting同样失效。

Loader

loader和plugin是扩展webpack经常涉及到的两个方式,其中,loader负责将资源翻译成webpack能够理解、处理的js代码,而plugin,则是深入整个webpack的构建过程,从而实现全新的逻辑。

其中的loader职责较为单一,因此较容易理解。

什么是loader

如果了解过vite,可能就产生一个疑问了:如果识别新的文件资源,为什么不用plugin而是用loader?换句话说,webpack为什么设计loader

实际上这个问题比较好回答,loader其实是webpack的一个“抽离”思路的体现,世界上的文件格式是相当多的,不可能一一枚举,即使是一些常见的格式资源,也因为解析的方法不同,而得出不同的结果,因此,webacpk将这个任务抽离出来,交给第三方实现,所以loader正是为了将文件的读取和处理逻辑进行解耦,从而实现特定的资源以及特定的加载。

同时webpackplugin的逻辑是相当复杂的,并没有vite那只有几个简单的生命周期,因此开发plugin的成本是相当高的,所以webpack产生了loaderplugin这两种处理方式。

从逻辑上来讲,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中了解到,可以使用上下文来帮助我们实现一些插件的功能,实际上,webpackloader也提供了相当多的上下文接口,有限制地影响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的回调函数之后,才会继续执行接下来的逻辑以及接下来的loaderasync的回调函数参数同callback相同。

addDependency

webpack的依赖解析是依赖于js解析实现的,但非js,比如css,那么就需要指定那些依赖的文件,告诉webpack如果这些文件变动的话,就得重新构建了,比如说,sass中的@importwebpack并不知道@import中的字符串是依赖文件,因此如果那些文件变动的话,webpack并不会重新构建,因此为了解决webpack无法识别意想不到的以来的情况,提供了addDependency上下文,让loader开发者来自己指定依赖,不光addDependencywebpack还提供了一下三种依赖相关的上下文。

  • 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的调用

当提到loaderplugin的执行顺序的时候,我们一般会讲:loader是从后往前调用,plugin是从前往后调用,其实这个说法并非完全正确,因为loader往复式调用,从右往左并非loader的完整调用链,不过loader确实是存在从右往左调用的过程的,但是为什么呢?

这里就要说到关于loader的调用模型了,loader的实现是基于函数组合的链式调用,通俗点讲就是一堆函数组合在一起使用,将外部函数一次通过内部函数的加工,最后输出结果。

这样有两个好处,一是保持单个loader的职责单一,一定程度降低代码的复杂度,二是可以将大模块的处理逻辑拆分为更小的处理单元,提高了单个loader的复用性。

但依然存在两个问题:链条调用一旦启动,就没有中断的机会,除非抛出异常,第二点是有的loader并不关心文件内容,但依然需要等待前一个loader调用完毕才能执行。

于是webpack增加了pitch的概念。

pitch本身是一个函数,挂载在loader函数上面,而pitch函数的执行远远早于loader函数,同时,当调用pitch的时候,是从左往右的!因此,当处理到有关pitch的逻辑的时候,我们需要以从左往右的思路来看。

我们来看pitch需要的参数:

  • remainingRequest,当前loader之后的资源请求字符串,这个字符串是一串包括了内联数据的loader引用语句

  • previousRequest,进入当前loaderpitch之前经历过的loader信息

  • data,用于传递需要在 Loader 传播的信息

这里可能比较难理解,不过如果记住pitch是从左往右进行执行的,然后配合以下例子,就可以比较好的理解pitch的逻辑。现在有三个loader[index,index2,index3,index4],那么每个loaderpitch的参数如下。

那么,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-loaderpitch实际的运行后,之后的css-loader、less-loader是无法运行的,但是,由于webpack是不断解析,不断构建的,因此当解析到刚刚pitch 生成的js结果,由于require中使用了remainingRequest,也就是内联loader语句,那么又可以从剩下的loader进行pitch阶段以及资源读取,然后loader执行阶段。

webpack的角度来看,是对同一个文件调用了两次loader链,第一次是只执行了style-loaderpitch,第二次是执行了除了style-loader以外的完整往复调用。

这也是为什么,我们在上一篇文章提到的并行构建,新增的并行loader需要放在最左边,而非最右边,原因就是新增并行loaderpitch函数,会拦截之后的内联loader请求,并分配到合理的线程池中运行。

vue-loader也有类似的处理逻辑。但更加复杂,因为vue并非只处理js,还处理css,同时使用了plugin,动态地修改了webpack的配置。在执行方面,vue-loader首先给原始文件增加不同的参数,后续可以分开处理这些内容,同时复用用户的loader配置。

plugin

loader较为简单易懂,但plugin却有些复杂,不过,复杂的同时则带来了强大的功能,webpackplugin可以改写webpack几乎所有的特性,但也伴随着相当陡峭的学习曲线。

什么是plugin

从结构来说,plugin是带着一个apply函数的类。webpack在启动时,就调用插件对象的apply函数,然后将compiler这个核心对象,传递到apply之中,同时核心对象有着独特的钩子以及子钩子。通过这些钩子或者子钩子的处理,我们来完成webpack的插件逻辑。

这个类似于事件订阅模式,但与一般的事件订阅模式不同的是,webpack的插件机制是一种与事件发布源强耦合的订阅模式。

在这里面,最重要的就是compiler,他是webpack的全局管理构建器,在启动的时候,webpack会首先创建compiler,负责管理配置信息、loaderplugincompilerwebpack的构建生命周期中,在不同的阶段,会传递不同的上下文。

  • 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 FunctionHookCodeFactorycreate的实现的,在实现过程中,不光内部进行了逻辑判断、实现,同时调用了方法content拼接固定的代码,而content同样是子类进行实现的。

在每个钩子实现继承Hook父类的地方,还继承了HookCodeFactory,自行实现了contentcompile就是调用继承了HookCodeFactory子类的create闭包执行后的动态函数的函数体。

经过上面的逻辑整理,我们可以得出一个结论,call方法大体上可以看做是调用了new Function 语句动态输出函数,然后来执行。

那么问题就来了,我们都知道,在前端环境中,尽可能地少用new Functioneval等功能,因为存在性能、安全性的问题,那么这样使用是没有顾虑的吗?

俗话说,没有坏代码只有坏设计,因此在当前场景下,tapable需要实现串行、异步、前一个回调的返回值传入下一个回调,那么就要在代码中使用递归、循环的手段,但实际代码都是高度相似的,因此是动态编译出的函数逻辑比递归更加清晰、更容易理解。

Intercept

Intercepttapable的高阶特性,这个是一个中间件接口,通过对目标钩子注册一个中间件,那么就可以在合适的时机,调用中间件内的函数,可以说是一种钩子的钩子,在webpack中,此特性主要用作进度条的展示。

HookMap

HookMaptapable的另一个高阶特性,它提供了一种集合操作能力,能够降低创建与使用的复杂度,用法比较简单

HookMap 能够用于实现的动态获取钩子功能,例如在 Webpacklib/parser.js 文件中,parser 文件主要完成将资源内容解析为 AST 集合,之后遍历 AST 并以 HookMap 方式对外通知遍历到的内容,如果以独立钩子实现,代码量会急剧膨胀,因此需要使用集合管理钩子。

HookMap的使用方法与普通钩子类似,唯一不同的需要增加for函数,过滤出实际监听的钩子来使用call等方法执行。

小结

我们在这里了解到了产物和产物的优化,稍微了解到了loaderplugin,以及他们背后实现的,但实际上,我们只是接触到webpack的皮毛而已,webpack的核心编译流程以及他的runtime等,我们还一无所知,因此,我们需要基于现在学习到的知识,更上一层台阶,这并非我们的终点,而是我们新的起点。

0
博主关闭了所有页面的评论