对于webpack loader
相信大家都知道它是用于将一个模块转为js
代码的,但估计不是每个人都知道webpack
对于loader
的内部处理流程。从大体上来说是遵循流水线机制的,即挨个处理每个loader
,前一个loader
的结果会传递给下一个loader
。
loader
有一些主要的特性:
- 同步、异步
- pitch、normal execution
- context
本文会从源码角度解释webpack
是如何处理这些特性的,并在最后举一些实际的例子帮助大家理解如何写一个loader
,所用的webpack
版本是4.28.2
.
入口
webpack
在处理Module
时就会先用loader
,每个module
可以配置多个loader
,然后再将js
代码转为AST
,这部分的逻辑在webpack
源码的lib/NormalModule.js
:
1 | doBuild(options, compilation, resolver, fs, callback) { |
runLoaders
是专门抽取出去的库loader-runner
,所有逻辑都在这个库中,接下来我们重点放在这里。
loader-runner
先看看入口函数:
1 | exports.runLoaders = function runLoaders(options, callback) { |
入口函数其实做的事情比较简单,除了初始化外就是调用iteratePitchingLoaders
了,这个函数执行完就触发webpack
传递的回调函数。接下来看看这个函数。
iteratePitchingLoaders
1 | function iteratePitchingLoaders(options, loaderContext, callback) { |
初看很容易懵逼,到处都有递归,但仔细配合注释看下来会发现其实就是递归执行每个loader
的pitch
函数,并在所有pitch
执行完后调用processResource
。那么问题来了,pitch
是个什么鬼?
参照官网的api
解释,每个loader
除了默认的处理函数外(我们可以称之为normal
函数),还可以配置一个pitch
函数,这两个函数的关系类似于浏览器的dom
事件处理流程:先从前往后执行pitch
,接着处理module
自身一些逻辑,再从后往前执行normal
,类似于先触发dom
事件的捕获阶段,接着执行事件回调,再触发冒泡阶段。
如果我们给一个module
配置了 3 个loader
,这三个loader
都配置了pitch
函数:
1 | module.exports = { |
那么处理这个module
的流程如下:
1 | |- a-loader `pitch` |
顺序执行normal
函数的代码位于iterateNormalLoaders
,稍后会描述。
loadLoader
函数用于加载一个loader
的实现,会尝试使用System.import
或require
来加载,我不怎么熟悉System.import
就不细讲了。loader
默认导出函数会赋值给currentLoaderObject
的normal
属性,pitch
函数会赋值给pitch
属性。
runSyncOrAsync
用于执行一个同步或异步的fn
,执行完后触发传入的回调函数。这个函数比较有意思,仔细看看:
1 | // fn可能是同步也可能是异步的 |
往context
上添加了async
和callback
函数,它俩是给异步loader
使用的,前者告诉context
自己是异步的,后者告诉context
自己处理完成了。所以在loader
内部可以调用this.async
以及this.callback
. 同步的loader
不需要用到这俩,执行完直接return
即可。后面我们会分别举一个例子。
细心点会发现,loader
内部的this
是context
,也就是最外层的loaderContext
,如果想知道context
上有哪些成员,可以直接看runLoaders
内部的初始化逻辑,或者直接去webpack 官网 api查阅即可。
注意:执行完一个pitch
后,会判断pitch
是否有返回值,如果没有则继续递归执行下一个pitch
;如果有返回值,那么pitch
的递归就此结束,开始从当前位置从后往前执行normal
:
1 | var args = Array.prototype.slice.call(arguments, 1); |
这个逻辑在官网也有描述,继续用我们上面的例子,如果b-loader
的pitch
有返回值,那么处理这个module
的流程如下:
1 | |- a-loader `pitch` |
以上就是pitch
的递归过程,下面看看processResource
函数,它用于将目标module
当做loaderContext
的一个依赖。这个函数的逻辑还是比较简单的:
1 | // 处理模块自身的资源,主要是读取及添加为context的依赖 |
1 | var fileDependencies = []; |
iterateNormalLoaders
递归迭代normal
函数,和pitch
的流程大同小异,需要注意的是顺序是反过来的,从后往前。
1 | // 与iteratePitchingLoaders类似,只不过是从后往前执行每个loader的normal函数 |
convertArgs
用于根据raw
来转换args
,raw
属性在官网有专门描述:
By default, the resource file is converted to a UTF-8 string and passed to the loader.By setting the raw flag, the loader will receive the raw Buffer.Every loader is allowed to deliver its result as String or as Buffer.
1 | function convertArgs(args, raw) { |
例如file-loader
就会将raw
设置为true
,具体原因参考这里
以上就是整个loader-runner
库的核心逻辑了,接下来举几个例子。
同步的 style-loader
它的逻辑从整体上看比较简单,就是做了一些同步的处理并在最后return
了一个js
字符串。注意他只有pitch
函数而没有normal
函数。
1 | module.exports = function() {}; |
为啥style-loader
要有pitch
呢? 参考这篇博客
的说法,是为了避免受到css-loader
的影响:
因为我们要把 CSS 文件的内容插入 DOM,所以我们要获取 CSS 文件的样式。如果按照默认的从右往左的顺序,我们使用 css-loader ,它返回的结果是一段 JS 字符串,这样我们就取不到 CSS 样式了。为了获取 CSS 样式,我们会在 style-loader 中直接通过 require 来获取,这样返回的 JS 就不是字符串而是一段代码了。也就是我们是先执行 style-loader,在它里面再执行 css-loader。
从代码层面来看,
Style-loader
需要的是一个css
文件路径, 而css-loader
返回的是一个数组,这样就无法处理。 先用style-loader
的pitch
处理,其中会再次require
css
文件,这时才会用到css-loader
,此时css-loader
的返回值会传递给addStyle.js
,就可以处理了- 路径前面的感叹号作用, 参考 1、参考 2:
- 1 个
!
用于禁用preloader
,preloader
是一个过时的东西,参考这里 - 2 个
!
用于禁用所有loader
- 1 个
异步的 less-loader
1 | // 调用less第三方库来处理less代码,返回值为promise |
less-loader
的核心是利用less
这个库来解析less
代码,less
会返回一个Promise
,所以less-loader
是异步的。
我们可以看到在开头就调用了this.async()
方法,正好符合我们的预期,接下来如果猜的没错会在_processResult2
里调用this.callback
:
1 | function processResult(loaderContext, resultPromise) { |
bingo!!
实际上官网也推荐将loader
变成异步的:
since expensive synchronous computations are a bad idea in a single-threaded environment like Node.js, we advise to make your loader asynchronously if possible. Synchronous loaders are ok if the amount of computation is trivial.
bundle-loader
最后再看这个使用pitch
的例子bundle-loader
,也是官网推荐的loader
。它用于分离代码和延迟加载生成的bundle
。
原理: 正常情况下假如我们在entry
中require
了一个普通js
文件,这个目标文件是和entry
一起打包到主chunk
了,那么在执行时就是同步加载。 使用bundle-loader
我们的代码不用做任何修改,就可以让目标js
文件分离到独立chunk
中,执行时通过模拟jsonp
的方式异步加载这个js
。
看看loader
的源码实现:
1 | module.exports = function() {}; |
可以看到只有pitch
函数,为保证目标module
分离到独立chunk
,使用了require.ensure
这种动态导入。另外将整个module
替换成了自己的实现,module
真正的加载时机在require.ensure
的回调中。
为了加深理解,我参照官网利用一个小demo
测试:
1 | // webpack entry: index.js |
1 | // util.bundle.js |
打包后会生成两个文件,一个主chunk
文件main.xxx.js
,另一个是分离出去的bundle chunk
文件0.xxx.js
。在分析打包代码前,如果对webpack
打包产物不熟悉的,可以参考我之前的博客,这里我只分析关键的部分。
index.js
这个module
对于util.bundle
的引入方式没有什么值得注意的,精简如下:
1 | var _util_bundle_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./util.bundle.js'); |
变化的是util.bundle.js
这个module
,它被替换成了bundle-loader
的返回值:
1 | var cbs = [], |
可以看到真正的util.bundle.js
被替换为使用__webpack_require__.e
加载,也就是模拟的jsonp
。我们在index.js
中传入的回调被塞到cbs
数组,直到真正的bundle
被加载完才能执行__webpack_require__
,之后会将bundle
的导出内容依次传给cbs
每个元素,整个逻辑还是比较清晰的。
自定义 loader
看了上面这些,我们也可以自己尝试写一个最简单的loader
试一试,作为一个探索我们就针对后缀名为ttt
(随便想的 😄)的文件吧。
我们的自定义loader
什么事也不做,就只在文件内容前加上一串标记,loader
代码如下:
1 | // my-loader.js |
注意loader
的返回值需要是合法的js
代码。然后修改webpack config
,使得ttt
后缀的文件使用my-loader
, 使用resolveLoader
配置来修改loader
的指向:
1 | module.exports = { |
接下来自定义一个strange.ttt
的文件,然后在入口文件index.js
中导入它:
1 | // strange.ttt |
细心的人会发现strange.ttt
的内容并不是字符串~~
1 | // index.js |
运行webpack
可以看到打包产物的关键代码如下:
1 | // main.js |
很明显strange.ttt
的内容被转化为了js
代码并包含了my-loader
添加的前缀,如果我们将main.js
跑起来,控制台就会打印出Convert by my custom loader: this is strange ttt
。