写在前边
日益繁杂的前端工程化中,围绕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,但是不禁用 postLoaders
import 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
阶段。
让我们来看这样一个例子:
代码语言:javascript复制// 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',
},
],
},
};
代码语言:javascript复制// 入口文件中
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
代码语言:javascript复制// 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
得到的通俗易懂的版本了