多角度解析Webpack5之Loader核心原理
写在前边
日益繁杂的前端工程化中,围绕Webpack
的前端工程化在前端项目中显得格外重要, 谈到webpack
必不可少的就会提起Loader
机制。
这里我们会从应用-原理-实现一层一层来揭开loader
的面目。废话不多说,让我们快速开始吧。
文章会围绕以下三个方面循序渐进带你彻底掌握Webpack Loader
机制:
Loader
概念: 何谓Loader
, 从基础出发带你快速入门日常业务中Loader
的各种配置方式。Loader
原理: 从源码解读Loader
模块,手把手实现Webpack
核心loader-runner
库。Loader
实现: 复刻高频次出现的Babel-loader
,带你掌握企业级Loader
开发流程。
这里我们会告别枯燥的源码阅读方式,图文并茂的带大家掌握Loader
核心原理并且熟练应用于各种场景之下。
文章基于
webpack
最新5.64.1
版本loader-runner
4.2.0版本进行分析。
Ok! Let's Do It !
Loader概念
Loader
的作用
让我们先从最基础的开始说起,所谓Loader
本质上就是一个函数。
loader 用于对模块的源代码进行转换。loader 可以使你在
import
或 "load(加载)" 模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的得力方式。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中import
CSS 文件!
webpack
中通过compilation
对象进行模块编译时,会首先进行匹配loader
处理文件得到结果(string/buffer)
,之后才会输出给webpack
进行编译。
简单来说,loader
就是一个函数,通过它我们可以在webpack
处理我们的特定资源(文件)之前进行提前处理。
比方说,webpack
仅仅只能识别javascript
模块,而我们在使用TypeScript
编写代码时可以提前通过babel-loader
将.ts
后缀文件提前编译称为JavaScript
代码,之后再交给Webapack
处理。
Loader
配置相关API
常用基础配置参数
我们来看一段最简单的webpack
配置文件:
module.exports = {
module: {
rules: [
{ test: /.css$/, use: 'css-loader',enforce: 'post' },
{ test: /.ts$/, use: 'ts-loader' },
],
},
};
相信这段配置代码大家已经耳熟能详了,我们通过module
中的rules
属性来配置loader
。
其中:
test参数
test
是一个正则表达式,我们会对应的资源文件根据test
的规则去匹配。如果匹配到,那么该文件就会交给对应的loader
去处理。
use参数
use
表示匹配到test
中匹配对应的文件应该使用哪个loader
的规则去处理,use
可以为一个字符串,也可以为一个数组。
额外注意,如果
use
为一个数组时表示有多个loader
依次处理匹配的资源,按照 从右往左(从下往上) 的顺序去处理。
enforce参数
loader
中存在一个enforce
参数标志这loader
的顺序,比如这样一份配置文件:
module.exports = {
module: {
rules: [
{ test: /.css$/, use: 'sass-loader', enforce: 'pre' },
{ test: /.css$/, use: 'css-loader' },
{ test: /.css$/, use: 'style-loader', enforce: 'post' },
],
},
};
针对.css
结尾的资源文件,我们在打包过程中module.rules
分别有三条规则匹配到,也就是对于同一个.css
文件我们需要使用匹配到的三个loader
分别进行处理。
那么此时,如果我们希望三个loader
的顺序可以不根据书写时的顺序去处理,那么enforce
就会大显身手。
enforce
有两个值分别为pre
、post
。
- 当我们的
rules
中的规则没有配置enforce
参数时,默认为normal loader
(默认loader
)。 - 当我们的
rules
中的规则配置enforce:'pre'
参数时,我们称之它为pre loader
(前置loader
)。 - 当我们的
rules
中的规则配置enforce:'post'
参数时,我们称之它为post loader
(后置loader
)。
关于这三种loader
的执行顺序,我想大家根据名称也可以猜的出来一二,没错在 正常loader
的执行阶段这三种类型的loader
执行顺序为:
当然,那么什么是不正常
loader
呢?我们会在后续详细给大家讲到。
webpack
中配置loader
的三种方式
通常我们在配置时都是直接使用直接使用loader
名称的方式,比如:
// webpack.config.js
module.exports = {
...
module: {
rules: [
{
test:/\.js$/,
loader: 'babel-loader'
}
]
}
}
上边的配置文件中,相当于告诉webpack
关于js
结尾的文件使用babel-loader
去处理。可是这里我们明明只写了一个babel-loader
的字符串,它是如何去寻找到babel-loader
的真实内容呢?
带着这个疑问,接下来让我们一起来看看在webpack
中配置loader
的三种方式。
绝对路径
第一种方式在项目内部存在一些未发布的自定义loader
时比较常见,直接使用绝对路径地址的形式指向loader
文件所在的地址。 比如:
const path = require('path')
// webpack.config.js
module.exports = {
...
module: {
rules: [
{
test:/\.js$/,
// .js后缀其实可以省略,后续我们会为大家说明这里如何配置loader的模块查找规则
loader: path.resolve(__dirname,'../loaders/babel-loader.js')
}
]
}
}
这里我们在loader
参数中传入一个绝对路径的形式,直接去该路径查找对应的loader
所在的js
文件。
resolveLoader.alias
第二种方式我们可以通过webpack
中的resolveLoader
的别名alias
方式进行配置,比如:
const path = require('path')
// webpack.config.js
module.exports = {
...
resolveLoader: {
alias: {
'babel-loader': path.resolve(__dirname,'../loaders/babel-loader.js')
}
},
module: {
rules: [
{
test:/\.js$/,
loader: 'babel-loader'
}
]
}
}
此时,当webpack
在解析到loader
中使用babel-loader
时,查找到alias
中定义了babel-loader
的文件路径。就会按照这个路径查找到对应的loader
文件从而使用该文件进行处理。
当然在我们定义loader
时如果每一个loader
都需要定义一次resolveLoader.alias
的话无疑太过于冗余了,情况在真实业务场景下通常我们都很少自己定义resolveLoader
选项但是webpack
也可以自动的帮我们找到对应的loader
,这就要引出我们的另一个参数了。
resolveLoader.modules
我们可以通过resolveLoader.modules
定义webpack
在解析loader
时应该查找的目录,比如:
const path = require('path')
// webpack.config.js
module.exports = {
...
resolveLoader: {
modules: [ path.resolve(__dirname,'../loaders/') ]
},
module: {
rules: [
{
test:/\.js$/,
loader: 'babel-loader'
}
]
}
}
上述代码中我们将resolveLoader.modules
配置为 path.resolve(__dirname,'../loaders/')
,此时在webpack
解析loader
模块规则时就会去path.resolve(__dirname,'../loaders/')
目录下去寻找对应文件。
当然resolveLoader.modules
的默认值是['node_modules']
,自然在默认业务场景中我们通过npm install
按照的第三方loader
都是存在于node_modules
内所以配置mainFields
默认就可以找到对应的loader
入口文件。
关于
resolveLoader
有些同学可能户非常眼熟,它和resolve
正常模块解析的配置参数是一模一样的。只不过resolveLoader
是相对于loader
的模块加载规则的,具体更多的配置手册你可以在这里看到。
同时需要注意的是modules
字段中的相对路径查找规则是类似于 Node 查找 'node_modules' 的方式进行查找。比如说modules:['node_modules']
,即是在当前目录中通过查看当前目录以及祖先路径(即 ./node_modules
, ../node_modules
等等)进行规则查找。
loader
种类与执行顺序
Loader的种类
上边我们讲到了通过配置文件的enforce
参数可以将loader
分为三种类型:pre loader
、normal loader
、post noraml
,分别代表了三种不同的执行顺序。
当然在在 配置文件中根据loader
的执行顺序,我们可以将loader
分为三种类型,同时webpack
还支持一种内联的方式配置loader
, 比如我们在引用资源文件时:
import Styles from 'style-loader!css-loader?modules!./styles.css';
通过上述的方式,我们在引用./styles.css
时候,调用了css-loader
、style-loader
进行提前处理文件,同时给css-loader
传递了modules
的参数。
我们将引用资源时,通过!
分割使用loader
的方式称为行内loader
。
至此,我们清楚关于loader
的种类存在四种类型的loader
,分别是pre loader
、normal loader
、inline loader
、post loader
四种类型。
关于inline loader
还有一些特殊的前置参数需要大家清楚:
通过为内联 import
语句添加前缀,可以覆盖配置中的所有 normalLoader
, preLoader
和 postLoader
:
-
使用
!
前缀,将禁用所有已配置的 normal loader(普通 loader)import Styles from '!style-loader!css-loader?modules!./styles.css';
-
使用
!!
前缀,将禁用所有已配置的 loader(preLoader, loader, postLoader)import Styles from '!!style-loader!css-loader?modules!./styles.css';
-
使用
-!
前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoadersimport Styles from '-!style-loader!css-loader?modules!./styles.css';
这里大家没有死记硬背的必要,了解大致用法后具体可以通过
webpack
官方网站进行查阅即可。
Loader的执行顺序
在了解了我们将loader
分为了pre loader
、normal loader
、inline loader
、post loader
四种loader
。
其实这四种loader
通过命名我们也可以看出来他们的执行顺序,在默认的Loader
执行阶段这四种loader
会按照如下顺序执行:
在webpack
进行编译文件前,资源文件匹配到对应loader
:
-
执行
pre loader
前置处理文件。 -
将
pre loader
执行后的资源链式传递给normal loader
正常的loader
处理。 -
normal loader
处理结束后交给inline loader
处理。 -
最终通过
post loader
处理文件,将处理后的结果交给webpack
进行模块编译。
注意这里我们强调的是默认
loader
的执行阶段,那么什么是非默认呢?接下来让我们一起来看看所谓的pitch loader
阶段。
loader
的pitch
阶段
关于loader
的执行阶段其实分为两种阶段:
- 在处理资源文件之前,首先会经历
pitch
阶段。 pitch
结束后,读取资源文件内容。- 经过
pitch
处理后,读取到了资源文件,此时才会将读取到的资源文件内容交给正常阶段的loader
进行处理。
简单来说就是所谓的
loader
在处理文件资源时分为两个阶段:pitch
阶段和nomral
阶段。
让我们来看这样一个例子:
// webpack.config.js
module.exports = {
module: {
rules: [
// 普通loader
{
test: /\.js$/,
use: ['normal1-loader', 'normal2-loader'],
},
// 前置loader
{
test: /\.js$/,
use: ['pre1-loader', 'pre2-loader'],
enforce: 'pre',
},
// 后置loader
{
test: /\.js$/,
use: ['post1-loader', 'post2-loader'],
enforce: 'post',
},
],
},
};
// 入口文件中
import something from 'inline1-loader!inline2-loader!./title.js';
这里,我们在webpack
配置文件中对于js
文件配置了三种处理规则6个loader
。同时在入口文件引入./title.js
使用了我们之前讲到过的inline loader
。
让我们用一张图来描述下所谓loader
的执行顺序:
loader
的执行阶段实际上分为两个阶段,webpack
在使用loader
处理资源时首先会经过loader.pitch
阶段,pitch
阶段结束后才会读取文件而后进行normal
阶段处理。
-
Pitching 阶段: loader 上的 pitch 方法,按照
后置(post)、行内(inline)、普通(normal)、前置(pre)
的顺序调用。 -
Normal 阶段: loader 上的 常规方法,按照
前置(pre)、普通(normal)、行内(inline)、后置(post)
的顺序调用。
请各位同学牢牢记住上边的loader
执行流程图,之后我们也会详细使用这个流程去带大家实现loader-runner
的源码。
关于
pitch
阶段有什么作用,webpack
为何如此设计loader
。别着急,后边的内容慢慢为你解开这些答案。
pitch Loader
的熔断效果
上边我们通过一张图描述了webpack
中loader
的执行顺序。我们了解到除了正常的loader
执行阶段还额外存在一个loader.pitch
阶段。
pitch loader
本质上也是一个函数,比如:
function loader() {
// 正常的loader执行阶段...
}
loader.pitch = function () {
// pitch loader
}
关于pitch loader
的需要特别注意的就是Pitch Loader
带来的熔断效果。
假设我们在上边配置的8个loader
中,为inline1-loader
添加一个pitch
属性使它拥有pitch
函数,并且,我们让它的pitch
函数随便返回一个非undefined
的值。
// inline1-loader normal
function inline1Loader () {
// dosomething
}
// inline1-loader pitch
inline1Loader.pitch = function () {
// do something
return '19Qingfeng'
}
这里我们在inline1-loader pitch
阶段返回了一个字符串19Qingfeng
,我们上边说到过在loader
的执行阶段是会按照这张图进行执行(pitch
阶段全部返回undefined
情况下):
但是一旦在某一个loader
的pitch
阶函数中返回一个非undefined
的值就会发生熔断的效果:
我们可以看到当我们在inline1-loader
的pitch
函数中返回了一个字符串19Qingfeng
时,loader
的执行链条会被阻断--立马掉头执行,直接掉头执行上一个已经执行的loader
的normal
阶段并且将pitch
的返回值传递给下一个normal loader
,简而言之这就是loader
的熔断效果。
Loader
开发相关API
上边我们带大家入门了loader
的基础概念和配置用法,我们了解了loader
按照执行阶段分为4中类型且loader
执行时分为两个阶段:pitch
、normal
阶段。
接下来让我们来看一下常见开发loader
相关内容:
关于执行顺序对于loader
开发的影响
这里我特意想和大家强调一下,上边我们说过loader
本质上就是一个函数。
function loader() {
// ...
}
// pitch 属性是可有可无的
loader.pitch = function () {
// something
}
关于loader
的执行顺序是通过webpack
配置中决定的,换而言之一个loader
到底是pre
、normal
、inline
还是post
和loader
开发本身是没有任何关系的。
执行顺序仅仅取决于webpack
应用loader
时的配置(或者引入文件时候添加的前缀)。
同步 or 异步loader
同步Loader
上边我们罗列的loader
都是同步loader
,所谓同步loader
很简单。就是在loader
本身阶段同步处理对应逻辑从而返回对应的值:
// 同步loader
// 关于loader的source参数 我们会在后续详尽讲述到 这里你可以理解为需要处理的文件内容
function loader(source) {
// ...
// 一系列同步逻辑 最终函数返回处理后的结果交给下一个阶段
return source
}
// pitch阶段的同步同理
loader.pitch = function () {
// 一系列同步操作 函数执行完毕则pitch执行完毕
}
同步
loader
在normal
阶段返回值时可以通过函数内部的return
语句进行返回,同时如果需要返回多个值时也可以通过this.callback()
表示loader
结束传入多个值进行返回,比如this.callback(error,value2,...)
,需要注意this.callback
第一个参数一定是表示是否存在错误。具体你可以在这里进行查看更加详细的用法。
异步Loader
在开发loader
时绝大多数情况下我们是用同步loader
就可以满足我们的要求了,但是往往会存在一些特殊情况。比如我们需要在loader
内部调用一些远程接口或者定时器之类的操作。此时就需要loader
可以等待异步返回结束后才继续执行下一个阶段处理:
将loader
变为异步loader
有两种方式:
返回Promise
我们仅仅修改loader
的返回值为一个Promise
就可以将loader
变为异步loader
,后续步骤会等待返回的Promise
变成resolve
后才会继续执行。
funciton asyncLoader() {
// dosomething
return Promise((resolve) => {
setTimeout(() => {
// resolve的值相当于同步loader的返回值
resolve('19Qingfeng')
},3000)
})
}
通过this.async
同样还有另一种方式也是比较常用的异步loader
方式,我们通过在loader
内部调用this.async
函数将loader
变为异步,同时this.async
会返回一个callback
的方式。只有当我们调用callback
方法才会继续执行后续阶段处理。
function asyncLoader() {
const callback = this.async()
// dosomething
// 调用callback告诉loader-runner异步loader结束
callback('19Qingfeng')
}
同样
loader
的pitch
阶段也可以通过上述两个方案变成异步loader
。
normal loader & pitch loader
参数详解
Normal Loader
normal loader
默认接受一个参数,这个参数是需要处理的文件内容。在存在多个loader
时,它的参数会受上一个loader
的影响。
同时nomral loader
存在一个返回值,这个返回值会链式调用给下一个loader
作为入参,当最后一个loader
处理完成后,会讲这个返回值返回给webpack
进行编译。
// source为需要处理的源文件内容
function loader(source) {
// ...
// 同时返回本次处理后的内容
return source + 'hello !'
}
关于
normal loader
中其实有非常多的属性会挂载在函数中的this
上,比如通常我们在使用某个loader
时会在外部传递一些参数,此时就可以在函数内部通过this.getOptions()
方法获取。
关于
loader
中的this
被称作上下文对象,更多的属性你可以在这里看到
Pitch Loader
// normal loader
function loader(source) {
// ...
return source
}
// pitch loader
loader.pitch = function (remainingRequest,previousRequest,data) {
// ...
}
Loader
的Pitch
阶段也是一个函数,它接受3个参数,分别是:
- remainingRequest
- previousRequest
- data
remainingRequest
remainingRequest
表示剩余需要处理的loader
的绝对路径以!
分割组成的字符串。
同样我们在上边的loader
中为每个normal loader
分别添加一个pitch
属性,我们以loader2.pitch
来举例:
在loader.pitch
函数中remainingRequest
的值为xxx/loader3.js
的字符串。如果说后续还存在多个loader
,那么他们会以!
进行分割。
需要注意的是
remainingRequest
与剩余loader
有没有pitch
属性没有关系,无论是否存在pitch
属性remainingRequest
都会计算pitch
阶段还未处理剩余的loader
。
previousRequest
在理解了remainingRequest
的概念之后,那么pitch loader
的第二个参数就很好理解了。
它表示pitch
阶段已经迭代过的loader
按照!
分割组成的字符串。
注意同样
previousRequest
和有无pitch
属性没有任何关系。同时remainingRequest
和previousRequest
都是不包括自身的(也就是我们例子中都不包含loader2
自身的绝对路径)。
data
现在让我们来看看pitch loader
最后一个参数。这个参数默认是一个空对象{}
。
在normalLoader
与pitch Loader
进行交互正是利用了第三个data
参数。
同样我们以上图中的loader2
来举例:
-
当我们在
loader2.pith
函数中通过给data
对象上的属性赋值时,比如data.name="19Qingfeng"
。 -
此时在
loader2
函数中可以通过this.data.name
获取到自身pitch
方法中传递的19Qingfeng
。
loader
的raw
属性
值得一提的是日常我们在开发一些loader
时,normal Loader
的参数我们讲到过它会接受前置normal loader
or 对应资源文件(当它为第一个loader
还未经过任何loader
处理时) 的内容。这个内容默认是一个string
类型的字符串。
但是在我们开发一些特殊的loader
时,比如我们需要处理图片资源时,此时对于图片来说将图片变成字符串明显是不合理的。针对于图片的操作通常我们需要的是读取图片资源的Buffer
类型而非字符串类型。
此时我们可以通过loader.raw
标记normal loader
的参数是Buffer
还是String
:
-
当
loader.raw
为false
时,此时我们normal loader
的source
获取的是一个String
类型,这也是默认行为。 -
当
loader.raw
为true
时,此时这个loader
的normal
函数接受的source
参数就是一个Buffer
类型。
function loader2(source) {
// 此时source是一个Buffer类型 而非模型的string类型
}
loader2.raw = true
module.exports = loader2
Normal Loader & Pitch Loader 返回值
上边其实我们已经详细讲过了关于Normal Loader
和Pitch Loader
的返回值。
-
Normal
阶段,loader
函数的返回值会在loader chain
中进行一层一层传递直到最后一个loader
处理后传递将返回值给webpack
进行传递。 -
Pitch
阶段,任意一个loader
的pitch
函数如果返回了非undefined
的任何值,会发生熔断效果同时将pitch
的返回值传递给normal
阶段loader
的函数。
需要额外注意的是,在
normal
阶段的最后一个loader
一定需要返回一个js
代码(一个module
的代码,比如包含module.exports
语句)。
关于熔断效果我相信大家如果认真看到这里一定能够理解它,如果对于熔断还有疑问的小伙伴我强烈建议再去看看我们上边关于熔断的两张图。
Loader源码分析
在上边我们对于loader
的基础内容和概念进行了详细的讲解。掌握了上边的内容之后我相信在日常业务中对于绝大多数loader
的场景你都可以游刃有余。
可是作为一个合格的前端工程师,任何一款工具的使用如果仅仅停留在应用方便一定是不合格的。
接下来,让我们从源码出发一步一步去掌握webpack
中是如何实现loader
从而更深层次的理解loader
核心内容与loader
的设计哲学吧!
写在源码分析之前
webpack
中的loader
机制就独立出来成为了一个loader-runner.js
,所以相对于loader
处理的逻辑和webpack
没有过多的耦合比较清晰。
首先,源码分析对于大多数人来说都觉得枯燥无趣,这里我会尽量简化步骤手把手带大家实现一款loader-runner
库。
文章中我想给大家强调的是一个源码流程,而非和真实源码一模一样。这样做的好处是简化了很多边界条件的处理可以更加快速、方便的带大家去掌握loader
背后的设计哲学。
但是并不是说我们实现的loader-runner
并不是源码,我们会在源码的基础上进行分析省略它的冗余步骤并且提出对于源码中部分写法我自己的优化点。
(毕竟是我一下一下debugger
得到的通俗易懂的版本了😼)
前期准备
在进入loader-runner
分析之前,我们让我们先来看一看webpack
中是在哪里进入这个模块调用~
webpack
中通过compilation
对象进行模块编译时,会首先进行匹配loader处理文件得到结果,之后才会输出给webpack进行编译。
简单来说就是在每一个模块module
通过webpack
编译前都会首先根据对应文件后缀寻找匹配到对应的loader
,先调用loader
处理资源文件从而将处理后的结果交给webpack
进行编译。
在webpack
中的_doBuild
函数中调用了runLoaders
方法,而runLoaders
方法正是来自于loader-runner
库。
简单来说webpack
在进行模块编译时会调用_doBuild
,在doBuild
方法内部通过调用runLoaders
方法调用loader
处理模块。
runLoader
参数
在真正进入runLoader
方法前我们先来看一看runLoader
方法传入的四个参数。
这里为了大家更加单纯的理解loader
,我们单独将这四个参数拿出来并不会尽量做到和webpack
编译过程解耦。
-
resource
:resource
参数表示需要加载的资源路径。 -
loaders
: 表示需要处理的loader
绝对路径拼接成为的字符串,以!
分割。 -
context
:loader
上下文对象,webpack
会在进入loader
前创建一系列属性挂载在一个object
上,而后传递给loader
内部。
比如我们上边说到的this.getOptions()
方法获得loader
的配置options
参数就是在进入runLoader
函数前webpack
将getOptions
方法定义在了loaderContext
对象中传递给context
参数。
这里你可以理解这个参数就是loader
的上下文对象,简单来说就是我们使用loader
函数中的this
,为了让大家更好的理解loader-runner
,这里我们并不会涉及webpack
对于context
对象的处理。
processResource
: 读取资源文件的方法。
同样源码中的processResource
涉及了很多plugin hooks
相关逻辑,这里你可以简单理解为它是一个fs.readFile
方法就可以。本质上这个参数的核心就是通过processResource
方法按照路径来读取我们的资源文件。
构造场景
正所谓磨刀不费砍柴功,在了解了webpack
源码中调用loader
函数时传递了四个参数之后,接下来让我们来模拟一下这四个参数吧。
目录构建
首先让我们先创建一个名为loader-runner
的文件夹,其次在loader-runner
下我们分别创建一个loader-runner/loaders
文件夹和loader-runner/index.js
两个文件分别存放我们的loader
和对应的模拟入口文件:
同时让我们在loader-runner
下创建一个title.js
,它存放需要loader
编译的js
文件内容:
// title.js
require('inline1-loader!inline2-loader!./title.js');
此时我们应该拥有了这样的目录:
创建Loader
接下里让我们来给loader-runner/loaders
下补充一些loader
吧,这里我为loader-runner/loaders
创建了8个loader
:
他们的内容非常简单,而且都是类似的。比如inline1-loader
中:
// 每一个loader文件中都存在对应的 normal loader和 pitch loader
// normal loader中打印一句 文件名: normal 和 对应的接受参数
// pitch loader 中打印一句 文件名 pitch
function loader(source) {
console.log('inline1: normal', source);
return source + '//inline1';
}
loader.pitch = function () {
console.log('inline1 pitch');
};
module.exports = loader;
同理post1-loader
中:
function loader(source) {
console.log('post1: normal', source);
return source + '//post1';
}
loader.pitch = function () {
console.log('post2 pitch');
};
module.exports = loader;
剩下的文件具体内容都是一样的,请小伙伴们自己动手创建一下~
强烈小伙伴们建议跟着文章一步一步来敲一下。
此时我们就已经创建好了这8个我们需要使用到的loader
了。
创建入口文件
上边我们说到过在webpack
源码中是通过compilation
编译时调用_doBild
方法时创造参数并且调用runLoaders
方法。
这里我们为了和webpack
构建流程尽量解耦,所以我们先动手自己构建一下runLoaders
函数需要的参数。
// loader-runner/index.js
// 入口文件
const fs = require('fs');
const path = require('path');
const { runLoaders } = require('loader-runner');
// 模块路径
const filePath = path.resolve(__dirname, './title.js');
// 模拟模块内容和.title.js一模一样的内容
const request = 'inline1-loader!inline2-loader!./title.js';
// 模拟webpack配置
const rules = [
// 普通loader
{
test: /\.js$/,
use: ['normal1-loader', 'normal2-loader'],
},
// 前置loader
{
test: /\.js$/,
use: ['pre1-loader', 'pre2-loader'],
enforce: 'pre',
},
// 后置loader
{
test: /\.js$/,
use: ['post1-loader', 'post2-loader'],
},
];
// 从文件引入路径中提取inline loader 同时将文件路径中的 -!、!!、! 等标志inline-loader的规则删除掉
const parts = request.replace(/^-?!+/, '').split('!');
// 获取文件路径
const sourcePath = parts.pop();
// 获取inlineLoader
const inlineLoaders = parts;
// 处理rules中的loader规则
const preLoaders = [],
normalLoaders = [],
postLoaders = [];
rules.forEach((rule) => {
// 如果匹配情况下
if (rule.test.test(sourcePath)) {
switch (rule.enforce) {
case 'pre':
preLoaders.push(...rule.use);
break;
case 'post':
postLoaders.push(...rule.use);
break;
default:
normalLoaders.push(...rule.use);
break;
}
}
});
/**
* 根据inlineLoader的规则过滤需要的loader
* https://webpack.js.org/concepts/loaders/
* !: 单个!开头,排除所有normal-loader.
* !!: 两个!!开头 仅剩余 inline-loader 排除所有(pre,normal,post).
* -!: -!开头将会禁用所有pre、normal类型的loader,剩余post和normal类型的.
*/
let loaders = [];
if (request.startsWith('!!')) {
loaders.push(...inlineLoaders);
} else if (request.startsWith('-!')) {
loaders.push(...postLoaders, ...inlineLoaders);
} else if (request.startsWith('!')) {
loaders.push(...postLoaders, ...inlineLoaders, ...preLoaders);
} else {
loaders.push(
...[...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders]
);
}
// 将loader转化为loader所在文件路径
// webpack下默认是针对于配置中的resolveLoader的路径进行解析 这里为了模拟我们省略了webpack中的路径解析
const resolveLoader = (loader) => path.resolve(__dirname, './loaders', loader);
// 获得需要处理的loaders路径
loaders = loaders.map(resolveLoader);
runLoaders(
{
resource: filePath, // 加载的模块路径
loaders, // 需要处理的loader数组
context: { name: '19Qingfeng' }, // 传递的上下文对象
readResource: fs.readFile.bind(fs), // 读取文件的方法
// processResource 参数先忽略
},
(error, result) => {
console.log(error, '存在的错误');
console.log(result, '结果');
}
);
这里我们通过loader-runner/index.js
来模拟webpack
编译过程中传递给loader-runner
中runLoader
的参数。
这里有几点需要大家注意:
-
filePath
是title.js
的模块路径,换而言之我们就是通过loader
来处理这个title.js
文件。 -
request
是我们模拟title.js
中的内容,它其实和title.js
文件内容是一模一样的,这里我们为了方便模拟webpack
解析loader
的处理规则所以直接将title.js
的文件内容放在了request
字符串中。 -
这里我们给
runLoaders
中第一参数对应的属性分别是:-
resource
表示需要loader
编译的模块路径。 -
loaders
表示本次loader
处理,需要有哪些loader
进行处理。(它是一个所有需要处理的loader
文件路径组成的数组) -
context
表示loader
的上下文对象,真实源码中webpack
会在进入runLoaders
方法前对这个对象进行额外加工,这里我们不做过多处理,它就是loader
中的this
上下文。 -
readResource
这个参数表示runLoaders
方法中会以我们传入的这个参数去读取resource
需要加载的文件路径从而得到文件内容。
-
-
runLoaders
函数第二个参数传入的是一个callback
表示本次loader
处理完成的结果。
这里传入的
loaders
参数的顺序是我刻意而为之的。是按照pitch
阶段的执行顺序来处理的:post
->inline
->normal
->pre
。
这里我们在上边的代码中调用了原版的
loader-runner
处理,让我们一起先来看看原版的结果吧。
我们可以看到原版loader-runner
中runLoaders
我们传入的第二个参数callback
在处理完成后会得到两个参数。
-
error
: 如果runLoaders
函数执行过程中遇到错误那么这个参数将会变成错误内容,否则将为null
。 -
result
: 如果runLoaders
函数执行完毕并且没有存在任何错误,那么这个result
将会存在以下属性:result
:它是一个数组用来表示本次经过所有loaders
处理完毕后的文件内容。resourceBuffer
: 它是一个Buffer
内容,这个内容表示我们的资源文件原始内容转化为Buffer的结果。- 其他参数是关于
webpack
构建与缓存时的参数,这里我们可以先不用关系这些参数。
流程梳理
在了解到原版的runLoaders
方法接受的参数以及返回的结果之后,接下来让我们来实现自己的runLoaders
方法吧。
首先,让我们先在一步一步先从创建目录开始吧!
这里我们在loader-runner
目录下创建了一个core/index.js
作为我们将要实现的loader-runner
模块。
创建好目录之后让我们再来回顾一下这张图吧:
这里我想给大家强调的是整个runLoaders
函数的核心逻辑就是接受待处理的资源文件路径,根据传入的Loaders
首先经过pitch
阶段读取资源文件内容再经过normal
阶段处理资源文件内容最终得到返回结果。
把握这样一个核心执行过程,剩下的一些边界情况的处理我相信对于大家来说都是litter case
!
接下来让我们正式进入runLoaders
方法的实现。
进入源码
创建loaders
对象
// loader-runner
const fs = require('fs')
function runLoaders(options, callback) {
// 需要处理的资源绝对路径
const resource = options.resource || ''
// 需要处理的所有loaders 组成的绝对路径数组
let loaders = options.loaders || []
// loader执行上下文对象 每个loader中的this就会指向这个loaderContext
const context = options.context || {}
// 读取资源内容的方法
const readResource = options.readResource || fs.readFile.bind(fs);
// 根据loaders路径数组创建loaders对象
loaders = loaders.map(createLoaderObject);
}
首先第一步我们在runLoaders
函数中保存了外部传入的options
中的参数,细心的同学可能已经发现了我们通过createLoaderObject
方法对传入的loaders
进行map
处理。
createLoaderObject
方法做的事情很简单,其实就是将原本loaders
路径数组中每个路径修改成为了一个对象,让我们来实现一下这个方法吧:
/**
*
* 通过loader的绝对路径地址创建loader对象
* @param {*} loader loader的绝对路径地址
*/
function createLoaderObject(loader) {
const obj = {
normal: null, // loader normal 函数本身
pitch: null, // loader pitch 函数
raw: null, // 表示normal loader处理文件内容时 是否需要将内容转为buffer对象
// pitch阶段通过给data赋值 normal阶段通过this.data取值 用来保存传递的data
data: null,
pitchExecuted: false, // 标记这个loader的pitch函数时候已经执行过
normalExecuted: false, // 表示这个loader的normal阶段是否已经执行过
request: loader, // 保存当前loader资源绝对路径
};
// 按照路径加载loader模块 真实源码中通过loadLoader加载还支持ESM模块 咱们这里仅仅支持CJS语法
const normalLoader = require(obj.request);
// 赋值
obj.normal = normalLoader;
obj.pitch = normalLoader.pitch;
// 转化时需要buffer/string raw为true时为buffer false时为string
obj.raw = normalLoader.raw;
return obj;
}
这里我们通过createLoaderObject
将loader
的绝对路径地址转化成为了loader
对象,并且赋值了一些核心的属性normal
、pitch
、raw
、data
等等。
赋值loaderContext
,处理上下文对象
在通过createLoaderObject
函数将路径转化为loader
对象后,让我们回到runLoaders
函数中,让我们紧跟着来处理loader
中的上下文对象loaderContext
:
function runLoaders(options, callback) {
// 需要处理的资源绝对路径
const resource = options.resource || '';
// 需要处理的所有loaders 组成的绝对路径数组
let loaders = options.loaders || [];
// loader执行上下文对象 每个loader中的this就会指向这个loaderContext
const loaderContext = options.context || {};
// 读取资源内容的方法
const readResource = options.readResource || fs.readFile.bind(fs);
// 根据loaders路径数组创建loaders对象
loader = loader.map(createLoaderObject);
// 处理loaderContext 也就是loader中的this对象
loaderContext.resourcePath = resource; // 资源路径绝对地址
loaderContext.readResource = readResource; // 读取资源文件的方法
loaderContext.loaderIndex = 0; // 我们通过loaderIndex来执行对应的loader
loaderContext.loaders = loaders; // 所有的loader对象
loaderContext.data = null;
// 标志异步loader的对象属性
loaderContext.async = null;
loaderContext.callback = null;
// request 保存所有loader路径和资源路径
// 这里我们将它全部转化为inline-loader的形式(字符串拼接的"!"分割的形式)
// 注意同时在结尾拼接了资源路径哦~
Object.defineProperty(loaderContext, 'request', {
enumerable: true,
get: function () {
return loaderContext.loaders
.map((l) => l.request)
.concat(loaderContext.resourcePath || '')
.join('!');
},
});
// 保存剩下的请求 不包含自身(以LoaderIndex分界) 包含资源路径
Object.defineProperty(loaderContext, 'remainingRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(loaderContext + 1)
.map((i) => i.request)
.concat(loaderContext.resourcePath)
.join('!');
},
});
// 保存剩下的请求,包含自身也包含资源路径
Object.defineProperty(loaderContext, 'currentRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(loaderContext)
.map((l) => l.request)
.concat(loaderContext.resourcePath)
.join('!');
},
});
// 已经处理过的loader请求 不包含自身 不包含资源路径
Object.defineProperty(loaderContext, 'previousRequest', {
enumerable: true,
get: function () {
return loaderContext.loaders
.slice(0, loaderContext.index)
.map((l) => l.request)
.join('!');
},
});
// 通过代理保存pitch存储的值 pitch方法中的第三个参数可以修改 通过normal中的this.data可以获得对应loader的pitch方法操作的data
Object.defineProperty(loaderContext, 'data', {
enumerable: true,
get: function () {
return loaderContext.loaders[loaderContext.loaderIndex].data;
},
});
}
这里我们为loaderIndex
上下文对象上定义了一系列属性,比如其中我们通过loaderIndex
控制当前loaders
列表中,当前执行到第几个loader以及当前data
、async
、callback
等等属性。
其实这里我相信大家对这些参数属性是不是有一种似曾相识的感觉,之前我们说到过在loader
中我们通过this
上下文对象灵活的对于loader
进行配置,这里我们定义的loaderContext
恰恰正是this
对象。
关于上下文对象这些参数的含义,我们在上文的
loader
开发阶段已经大概讲过对应API的用法和含义, 如果忘记的同学可以回到上边再次温习温习。
这里我们仅仅对于这些属性进行了定义,同时我希望在看到目前定义属性时同学们可以联想到每个
API
相应的用法。
定义完成loaderContext
属性后,让我们再次回到runLoaders
方法中:
function runLoaders(options, callback) {
...
// 用来存储读取资源文件的二进制内容 (转化前的原始文件内容)
const processOptions = {
resourceBuffer: null,
};
// 处理完loaders对象和loaderContext上下文对象后
// 根据流程我们需要开始迭代loaders--从pitch阶段开始迭代
// 按照 post-inline-normal-pre 顺序迭代pitch
iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
callback(err, {
result,
resourceBuffer: processOptions.resourceBuffer,
});
});
}
这里我们定义的processOptions
中的resourceBuffer
正是result
中的resourceBuffer
: 原始(未经loader处理)的资源文件内容的Buffer对象。
iteratePitchingLoaders
在创建loader
对象、赋值loaderContext
属性后,按照之前的流程图。我们就要进入每一个loader
的pitch
执行阶段。
上边我们定义了iteratePitchingLoaders
函数,并且为他传入了三个参数:
-
processOptions
: 我们上述定义的对象,它存在一个resourceBuffer
属性用来保存未经过loader
处理前Buffer
类型的资源文件内容。 -
loaderContext
:loader
上下文对象。 -
callback
: 这个方法内部调用了runLoaders
方法外部传入的callback
,用来在回调函数中调用最终的runLoaders
方法的结果。
了解了传入的参数后,让我们一起来看看iteratePitchingLoaders
的实现吧。
/**
* 迭代pitch-loaders
* 核心思路: 执行第一个loader的pitch 依次迭代 如果到了最后一个结束 就开始读取文件
* @param {*} options processOptions对象
* @param {*} loaderContext loader中的this对象
* @param {*} callback runLoaders中的callback函数
*/
function iteratePitchingLoaders(options, loaderContext, callback) {
// 超出loader个数 表示所有pitch已经结束 那么此时需要开始读取资源文件内容
if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
return processResource(options, loaderContext, callback);
}
const currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// 当前loader的pitch已经执行过了 继续递归执行下一个
if (currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}
const pitchFunction = currentLoaderObject.pitch;
// 标记当前loader pitch已经执行过
currentLoaderObject.pitchExecuted = true;
// 如果当前loader不存在pitch阶段
if (!currentLoaderObject.pitch) {
return iteratePitchingLoaders(options, loaderContext, callback);
}
// 存在pitch阶段 并且当前pitch loader也未执行过 调用loader的pitch函数
runSyncOrAsync(
pitchFunction,
loaderContext,
[
currentLoaderObject.remainingRequest,
currentLoaderObject.previousRequest,
currentLoaderObject.data,
],
function (err, ...args) {
if (err) {
// 存在错误直接调用callback 表示runLoaders执行完毕
return callback(err);
}
// 根据返回值 判断是否需要熔断 or 继续往下执行下一个pitch
// pitch函数存在返回值 -> 进行熔断 掉头执行normal-loader
// pitch函数不存在返回值 -> 继续迭代下一个 iteratePitchLoader
const hasArg = args.some((i) => i !== undefined);
if (hasArg) {
loaderContext.loaderIndex--;
// 熔断 直接返回调用normal-loader
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
// 这个pitch-loader执行完毕后 继续调用下一个loader
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
}
我带大家稍微来看看iteratePitchingLoaders
这个方法,它做的事情非常简单,** 本质上就是通过loaderContext.loaderIndex
来递归迭代每一个loader
对象的pitch
方法。
这里有几点需要给大家提示下:
processResource
方法是读取资源文件内容的方法,按照上文流程图中的步骤当所有pitch
执行完毕后我们需要读取资源文件内容了。runSyncOrAsync
方法是执行调用loader
函数的方法,loader
的执行有两种方式同步/异步,这里正是通过这个方法进行的统一处理。iterateNormalLoaders
方法是迭代normal loader
的方法。
上述这三个方法的实现细节这里你可以不用关心,只需要清楚
iteratePitchingLoaders
的流程就可以。
- 需要额外注意的是,我们在模拟入口文件中传入的
loader
顺序是按照[...post,...inline,...normal,...pre]
的传入的。所以内部我们通过loaderContext.loaderIndex
从下标0
开始迭代正好符合pitch
阶段。
runSyncOrAsync
了解了iteratePitchingLoaders
是如何迭代pitch loader
,我们一起来看看运行loader
的方法runSyncOrAsync
:
/**
*
* 执行loader 同步/异步
* @param {*} fn 需要被执行的函数
* @param {*} context loader的上下文对象
* @param {*} args [remainingRequest,previousRequest,currentLoaderObj.data = {}]
* @param {*} callback 外部传入的callback (runLoaders方法的形参)
*/
function runSyncOrAsync(fn, context, args, callback) {
// 是否同步 默认同步loader 表示当前loader执行完自动依次迭代执行
let isSync = true;
// 表示传入的fn是否已经执行过了 用来标记重复执行
let isDone = false;
// 定义 this.callback
// 同时this.async 通过闭包访问调用innerCallback 表示异步loader执行完毕
const innerCallback = (context.callback = function () {
isDone = true;
// 当调用this.callback时 标记不走loader函数的return了
isSync = false;
callback(null, ...arguments);
});
// 定义异步 this.async
// 每次loader调用都会执行runSyncOrAsync都会重新定义一个context.async方法
context.async = function () {
isSync = false; // 将本次同步变更成为异步
return innerCallback;
};
// 调用pitch-loader执行 将this传递成为loaderContext 同时传递三个参数
// 返回pitch函数的返回值 甄别是否进行熔断
const result = fn.apply(context, args);
if (isSync) {
isDone = true;
if (result === undefined) {
return callback();
}
// 如果 loader返回的是一个Promise 异步loader
if (
result &&
typeof result === 'object' &&
typeof result.then === 'function'
) {
// 同样等待Promise结束后直接熔断 否则Reject 直接callback错误
return result.then((r) => callback(null, r), callback);
}
// 非Promise 切存在执行结果 进行熔断
return callback(null, result);
}
}
runSyncOrAsync
接受四个参数,分别是
fn
需要被调用的函数context
被调用的fn
函数内部的this
指针args
被调用函数的fn
传入的参数callback
用来表示loader(fn)
执行完毕后调用的回调函数。
它的实现很简单,内容通过闭包结合isSync
变量实现异步this.async/this.callback
这两个loader API
的实现。
最终,loader
执行完毕runSyncOrAsync
方法会将loader
执行完毕的返回值传递给callback
函数的第二个参数。
实现了runSyncOrAsync
,了解如何执行loader
之后让我们回过头分析iteratePitchingLoaders
中的runSyncOrAsync
方法。
在iteratePitchingLoaders
函数中我们通过runSyncOrAsync
去执行对应pitch loader
,分别传入了这四个参数:
-
pitchFunction
作为需要执行的fn
。 -
loaderContext
表示pitch loader
函数中的this
上下文对象。 -
[currentLoaderObject.remainingRequest,currentLoaderObject.previousRequest,currentLoaderObject.data]
。上文我们说到过pitch loader
函数会接受三个参数分别是剩下的laoder
请求, 已经处理过的loader
请求以及作为传递给normal
阶段的data
。 -
第四个参数是一个回调函数,它表示
pitch loader
函数执行完毕后这个callback
会被调用,如果pitch loader
存在返回值那么它的第二个参数则会接受到pitch loader
执行后的返回值。
这里我想和大家强调一下iteratePitchingLoaders
中调用runSyncOrAsync
执行loader
时候传入的第四个callback
函数:
runSyncOrAsync(
pitchFunction,
loaderContext,
[
currentLoaderObject.remainingRequest,
currentLoaderObject.previousRequest,
currentLoaderObject.data,
],
function (err, ...args) {
if (err) {
// 存在错误直接调用callback 表示runLoaders执行完毕
return callback(err);
}
// 根据返回值 判断是否需要熔断 or 继续往下执行下一个pitch
// pitch函数存在返回值 -> 进行熔断 掉头执行normal-loader
// pitch函数不存在返回值 -> 继续迭代下一个 iteratePitchLoader
const hasArg = args.some((i) => i !== undefined);
if (hasArg) {
loaderContext.loaderIndex--;
// 熔断 直接返回调用normal-loader
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
// 这个pitch-loader执行完毕后 继续调用下一个loader
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
上边我们提到过第四个参数会在pitch loader
函数执行完毕后 or 报错后callback
会被调用。
如果存在错误,那么直接调用runLoaders
传入的callback(err)
。
如果不存在错误,这里我们对于除开第一个表示错误的参数剩余参数做了判断,我们知道这个参数表示loader
执行完毕的返回值,让我们再来回顾一下pitch
阶段的流程图:
任何一个loader
的pitch
阶段如何返回了非undefined
的任何值,那么此时loader
将会发生熔断的效果:立即掉头执行normal loader
并且将pitch
阶段的返回值传递给normal loader
。
所以这里我们通过callback
中判断,如果args
中存在任何一个非undefined
的返回值。那么此时将loaderContext.loaderIndex
递减,从而开始迭代normal loader
。
如果pitch loader
运行结束后不存在返回值或者说返回的是undefeind
,那么此时继续递归调用下一个pitch loader
。
针对于
pitch loader
的大致流程我们在这里就告一段落了,如果同学们还有疑问可以在重新翻看一些或者在评论区来一起讨论。其实在把握loader
执行过程之后,单独代码逻辑来说我相信对于大家理解起来都不是很难,这也就是为什么前边我会花很多篇幅去讲诉loader
的基础用法。
processResource
结束了iteratePitchingLoaders
之后,迭代完成所有的pitch loader
之后。下一步是什么呢?
如果忘记的同学建议一定要去翻看一下loader
的执行流程图,此时应该是读取资源文件内容了,也就是我们上边没有完成的processResource
方法。
这个方法做的事情非常简单:
- 按照传入的方法读取文件内容,同时将得到的文件
Buffer
类型的内容保存进入processOptions.resourceBuffer
中去。 - 拿到文件内容后,将文件内容传入
normal loader
之后执行iterateNormalLoaders
迭代执行normal loader
。
/**
*
* 读取文件方法
* @param {*} options
* @param {*} loaderContext
* @param {*} callback
*/
function processResource(options, loaderContext, callback) {
// 重置越界的 loaderContext.loaderIndex
// 达到倒叙执行 pre -> normal -> inline -> post
loaderContext.loaderIndex = loaderContext.loaders.length - 1;
const resource = loaderContext.resourcePath;
// 读取文件内容
loaderContext.readResource(resource, (err, buffer) => {
if (err) {
return callback(err);
}
// 保存原始文件内容的buffer 相当于processOptions.resourceBuffer = buffer
options.resourceBuffer = buffer;
// 同时将读取到的文件内容传入iterateNormalLoaders 进行迭代`normal loader`
iterateNormalLoaders(options, loaderContext, [buffer], callback);
});
}
它的代码其实比较容易理解,这里需要注意的有三点:
-
loaderIndex
在迭代pitch loader
中越界了(也就是等于loaderContext.loaders.length
)时才会进入processResource
方法所以此时我们将loaderContext.loaderIndex
重置为loaderContext.loaders.lenth -1
。 -
iterateNormalLoaders
额外传入了一个表示资源文件内容[buffer]
的数组,这是刻意而为之,这里我先买个关子,后续你会发现我为什么这么做。 -
还记得我们在
loaderContext.loaders
中保存的loaders
顺序吗,它是按照post -> inline -> normal -> pre
的顺序保存的的,所以此时只要我们按照loaderIndex
逆序去迭代,就可以得到normal loader
的顺序。
iterateNormalLoaders
完成了processResource
读取文件内容之后,在processResource
以及iteratePitchingLoaders
方法中我们都用到的iterateNormalLoaders
--迭代normal loader
的函数还未实现。
接下来让我们先来看看这个函数的实现吧:
/**
* 迭代normal-loaders 根据loaderIndex的值进行迭代
* 核心思路: 迭代完成pitch-loader之后 读取文件 迭代执行normal-loader
* 或者在pitch-loader中存在返回值 熔断执行normal-loader
* @param {*} options processOptions对象
* @param {*} loaderContext loader中的this对象
* @param {*} args [buffer/any]
* 当pitch阶段不存在返回值时 此时为即将处理的资源文件
* 当pitch阶段存在返回值时 此时为pitch阶段的返回值
* @param {*} callback runLoaders中的callback函数
*/
function iterateNormalLoaders(options, loaderContext, args, callback) {
// 越界元素判断 越界表示所有normal loader处理完毕 直接调用callback返回
if (loaderContext.loaderIndex < 0) {
return callback(null, args);
}
const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];
if (currentLoader.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
const normalFunction = currentLoader.normal;
// 标记为执行过
currentLoader.normalExecuted = true;
// 检查是否执行过
if (!normalFunction) {
return iterateNormalLoaders(options, loaderContext, args, callback);
}
// 根据loader中raw的值 格式化source
convertArgs(args, currentLoader.raw);
// 执行loader
runSyncOrAsync(normalFunction, loaderContext, args, (err, ...args) => {
if (err) {
return callback(err);
}
// 继续迭代 注意这里的args是处理过后的args
iterateNormalLoaders(options, loaderContext, args, callback);
});
}
其实仔细阅读iterateNormalLoaders
的代码它和iteratePitchingLoaders
存在异曲同工之妙,它们的核心都是基于loaderContext.loaderIndex
下标进行迭代loaders
对象分别运行对应的pitch
或者normal
函数。
只不过不同的是iteratePitchingLoaders
仅仅接受三个参数,iterateNormalLoaders
额外接受一个args
参数表示资源文件对象的[Buffer]
(或者发生熔断时pitch loader
的返回值)。这是由于pitch loader
被调用时的参数可以通过loaderContext
来获取(remainingRequest
属性等),而normal loader
的参数需要一层一层将处理后的内容传递下去。
同时为什么我们在上边将文件内容处理成数组
[Buffer]
,正是因为传递给runSyncOrAsync
方法时第三个参数是一个数组(表示调用loader
函数时传递给loader
函数的参数),因为通过apply
进行调用所以统一处理为数组会让代码更加方便简洁。
细心的同学会发现在iterateNormalLoaders
中有一个convertArgs
函数的调用,我们先来看一看这个函数的内容:
/**
*
* 转化资源source的格式
* @param {*} args [资源]
* @param {*} raw Boolean 是否需要Buffer
* raw为true 表示需要一个Buffer
* raw为false表示不需要Buffer
*/
function convertArgs(args, raw) {
if (!raw && Buffer.isBuffer(args[0])) {
// 我不需要buffer
args[0] = args[0].toString();
} else if (raw && typeof args[0] === 'string') {
// 需要Buffer 资源文件是string类型 转化称为Buffer
args[0] = Buffer.from(args[0], 'utf8');
}
}
convertArgs
方法根据loader
的raw
属性判断这个loader
在normal
阶段需要接受资源文件内容是Buffer
还是String
。
上边我们讲到过每个loader
都存在一个raw
属性,通过loader.raw
标记normal loader
的参数是Buffer
还是String
。这个方法正是在normal loader
执行前对于参数进行转化处理的。
大功告成
此时针对于loader-runner
中runLoaders
方法的核心逻辑我们已经全部实现了。
验证执行结果
让我们现在loader-runner/core/index.js
中导出这个方法,同时在模拟入口文件loader-runner/index.js
中替换为我们自己的模块:
// loader-runner/core/index.js
...
module.exports = {
runLoaders
}
// loader-runner/index.js
// 入口文件
const fs = require('fs');
const path = require('path');
const { runLoaders } = require('./core/index');
...
runLoaders(
{
resource: filePath, // 加载的模块路径
loaders, // 需要处理的loader数组
context: { name: '19Qingfeng' }, // 传递的上下文对象
readResource: fs.readFile.bind(fs), // 读取文件的方法
// processResource 参数先忽略
},
(error, result) => {
console.log(error, '存在的错误');
console.log(result, '结果');
}
);
接来下让我们执行我们的loader-runner/index.js
:
我们打印出来的callback
中的result
形参对象中的:
-
result
属性的值是一个数组,它的第一个元素是经过我们所有loader
处理后的文件内容。 -
resourceBuffer
属性的值是一个Buffer
,它的内容是原始未经转化后的资源文件内容,有兴趣的朋友可以自己toString()
一下看看。
至此我们正常阶段的执行结果和原始的runLoaders
方法基本已经一模一样了。
熔断效果验证
此时,让我们再来验证一下熔断效果。
首先让我们修改loader-runner/loaders/inline2-loader
,我们在inline2-loader
的pitch
函数中返回一个字符串:
function loader(source) {
console.log('inline2: normal', source);
return source + '//inline2';
}
loader.pitch = function () {
console.log('inline2 pitch');
return '19Qingfeng';
};
module.exports = loader;
再次执行我们的loader-runner/core/index.js
:
正如我们期望的那样~一切正常!
至此,runLoaders
的核心源码我已经带大家全部实现了。
我相信代码本身并不是很难理解,源码阅读本身就需要一定的耐心,如果大家对于代码有任何疑问或者对于原始源码有任何疑问欢迎大家在评论区骚扰我~
真心感谢每一位看到这里的朋友,对于loader
源码的学习我希望大家可以以此为起点可以在后续更加深入的探索并着手于优化整个流程体系。(毕竟说实话runLoaders
方法内部一些地方写的还是比较糙的嘛🐶)。
文章中的完整代码地址你可以在这里看到。
企业级Loader应用
在精进了源码部分的知识体系之后,让我们来聊一些比较轻松的话题吧。
接下来让我们手把手来实现一款企业级loader
应用,带领大家真正精通并掌握开源工具中loader
开发者的思想。
babel-loader
流程
老样子~首先让我们来梳理一下关于需要开发babel-loader
的流程吧。
让我们一起先来根据这张图来梳理一下。
所谓babel-loader
实现的功能特别简单,本质上就是通过babel-loader
函数以及对应配置loader
时的参数将我们的js
文件进行转化, 简单来说这就是babel-loader
需要实现的功能。
当然在经过babel-loader
处理后的内容后续还需要交给webapck
进行编译。
babel-loader
实现
const core = require('@babel/core');
/**
*
* @param {*} source 源代码内容
*/
function babelLoader(source) {
// 获取loader参数
const options = this.getOptions() || {};
// 通过transform方法进行转化
const { code, map, ast } = core.transform(source, options);
// 调用this.callback表示loader执行完毕
// 同时传递多个参数给下一个loader
this.callback(null, code, map, ast);
}
module.exports = babelLoader;
看起来很简单吧,这里有一些注意点需要和大家阐述下:
这里我们通过core.transform
将源js
代码进行ast
转化同时通过外部传递的options
选项处理ast
节点的转化,从而按照外部传入规则将js
代码转化为转化后的代码。
- 这里我们通过
this.getOptions
方法获得外部loader
传递的参数。
在webpack5
中获取loader
的方法在调用runLoaders
方法时webpack
已经在loaderContext
中添加了这个getOptions
的实现,从而调用runLoaders
方法时传入了处理好的loaderContext
参数。
在webpack5
之前并不存在this.getOptions
方法,需要额外通过loader-utils
这个包实现获取外部loader
配置参数。
这个方法的实现非常简单,在源码中的webpack/lib/NormalModule.js
中,有兴趣的朋友可以自行翻看。
接下来就让我们稍微来验证一下吧。
验证babel-loader
首先让我们重新搭建一个开发目录webpack-babel
文件夹:
-
loaders/babel-loaders
目录下存放我们自己定义的babel-loader
。 -
src/index.js
存放入口文件。 -
webpack.config.js
为webpack
配置文件。
// src/index.js
// 这里我们使用ES6的语法
const arrowFunction = () => {
console.log('hello');
};
console.log(arrowFunction);
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
},
devtool: 'eval-source-map',
resolveLoader: {
modules: [path.resolve(__dirname, './loaders')],
},
module: {
rules: [
{
test: /\.js/,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
],
},
plugins: [new HtmlWebpackPlugin()],
};
// package.json
{
...
// 这里我们定义两个脚本 一个为开发环境下的dev
// 一个为build打包命令
"scripts": {
"dev": "webpack serve --mode developmen",
"build": "webpack --config ./webpack.config.js"
},
}
不要忘记安装我们需要用到的包。
yarn add webpack webpack-cli @babel/core @babel/preset-env html-webpack-plugin webpack-dev-server
接下来让我们来一起运行yarn build
来看一看打包后输出的js
内容吧:
可以看到在yarn build
之后,生产环境打包后的代码原本的箭头函数已经转化成为了普通函数。也就是说我们自定义的babel-loader
功能已经生效了。
sourcemap
完善babel-loader
生产环境下的确是没有任何问题了,可是开发环境呢。这里我们尝试在src/index.js
下debugger
看一下:
// src/index.js
const arrowFunction = () => {
console.log('hello');
};
debugger;
console.log(arrowFunction);
此时我们执行yarn dev
,打开生成的html
页面进入debugger
:
在debugger
中的代码此时并不是我们真正的源码,是已经被babel
转译后的代码,这对于日常开发来说无疑是一种灾难,这里我们的src/index.js
的文件内容很简单只有一个箭头函数。可是当项目中代码越来越复杂,这种情况无疑对于我们进行debugger
代码时是一种噩梦。
其实导致这个问题的原因很简单,在babel-loader
编译阶段我们并没有携带任何sourceMap
映射。而在webpack
编译阶段即使开启了sourceMap
映射此时也仅仅只能将webpack
编译后的代码在debugger
中映射到webpack
处理前,也就是已经经历过babel-loader
处理了。
此时,我们需要做的仅仅是需要在babel-loader
转化过程中添加对应的sourcemap
返回交给webpack
编译阶段时候携带babel-loader
生成的sourcemap
就可以达到我们期望的效果。
让我们来动手实现一下:
const core = require('@babel/core');
/**
*
* @param {*} source 源代码内容
*/
function babelLoader(source, sourceMap, meta) {
// 获取loader参数
const options = this.getOptions() || {};
// 生成babel转译阶段的sourcemap
options.sourceMaps = true;
// 保存之前loader传递进入的sourceMap
options.inputSourceMap = sourceMap;
// 获得处理的资源文件名 babel生成sourcemap时候需要配置filename
options.filename = this.request.split('!').pop().split('/').pop();
// 通过transform方法进行转化
const { code, map, ast } = core.transform(source, options);
console.log(map, 'map');
// 调用this.callback表示loader执行完毕
// 同时传递多个参数给下一个loader
// 将transform API生成的sourceMap 返回给下一个loader(或者webpack编译阶段)进行处理
this.callback(null, code, map, ast);
}
module.exports = babelLoader;
这里我们在babel
的tranform
方法上接受到了上一次loader
传递过来的soureMap
结果(如果有的话)。
同时调用options.sourceMaps
告诉babel
在转译时生成sourceMap
。
最终将生成的source
在this.callback
中返回。
此时我们就在我们的babel
转译阶段也生成了sourcemap
同时最终会经过loader-chain
将生成的sourcemap
传递给webpack
。
同时额外注意
webpack
中devtool
的配置,如果关闭了sourceMap
的话就看不到任何源代码信息~
接下来让我们再次运行yarn build
打开浏览器看一下:
大功告成!至此我们的babel-loader
开发就告一断落了。这里我希望通过这个小例子可以带领大家真正进入loader
开发者的世界。
回到我们最开始的内容,loader
本质上就是一个函数而已。只不过是我们通过loader chain
将多个loader
链接在一起按照一定顺序和规则去执行而已。
我相信阅读到这里的你,在了解了loader
的基础和原理部分这样的小例子对于大家每个人来说都是信手拈来,其实真实开源loader
中相比这里无非也就是多了一些参数校验和边界处理而已。
babel-loader
的实现代码你可以在这里看到。
写在结尾
感谢每一位看到这里的小伙伴,关于webpack loader
的分享文章到这里就要结束了。
希望大家以此为起点,在探索loader
的路上越走越远!
其实源码并不是那么晦涩难懂,我相信runLoaders
源码中的设计理念一定会对大家有所帮助,这也是为什么我会花很大篇幅去在源码分析的章节中的原因。我希望带给大家的不仅仅是关于如何实现webpack loader
,更多的是我希望带领大家去掌握一种如何阅读源码的方式。
其实认真看到这里的小伙伴会发现runLoaders
方法中有部分内容的确写的也是不过如此,还有很多优化的点嘛哈哈~
大家如果关于loader
有任何疑问也可以在评论区留下你的问题,我们可以一起探讨~
转载自:https://juejin.cn/post/7036379350710616078