对于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处理,其中会再次requirecss文件,这时才会用到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。