前言
Webpack是如今前端离不开的话题和工具,相信不少同学都曾经感受过被webpack支配的恐惧,但是去深入理解它,可能是脱离苦海的第一步。
在webpack的体系里,loader和plugin无疑是最核心的组成部分,本文会结合webpack的运行机制,介绍loader和plugin的区别,并对loader进行深度剖析。
Loader和Plugin的区别
聊到Loader,肯定会有一个绕不开的问题:Loader和Plugin的区别是什么。
简单来说:
Loader是一个模块转换器,将非JS模块转换成JS模块;
Plugin是webpack运行生命周期的各个阶段上挂载的事件,会被指定的时间节点被触发(相当于订阅/发布模式),能够改变构建结果、拆分和优化bundle等。
从loader和plugin的配置方式就能看出一些端倪,loader的配置是在声明检测(test)在某种文件类型时就使用(use)指定的loader去编译,而plugin是在实例化(new)插件,将插件挂载到指定的生命周期节点上。
Webpack运行机制中的loader和plugin
来看一下webpack运行的流程简图:
可以看到,loader只作用在编译静态资源这一步,在一次次构建(Compilation)的循环中被调用。
那么plugin在哪里呢?
如下图蓝色虚线箭头所示,webpack会在各个生命周期中广播事件,并触发对应的插件:
Loader详解
Loader是什么
Loader是一个具有单一职责的转换器。
这里有两个关键词,转换器和单一职责。
先看转换器。我们知道,在webpack中一切皆js模块,而loader的作用就是把非js模块转化为js模块,供webpack进行打包处理。
非js模块即样式文件(.css、.less、.scss等),非标准JS文件(.ts、.jsx、.vue),以及其他类型的文件(svg、png | jpg | jpeg等)。
单一职责,就是字面意思,指一个loader只负责一种转换。单一职责是webpack社区对loader定义的约束。如果一个源文件需要经历多步转换才能被使用,就应该通过多个loader去转换。
当然你非只写一个loader实现所有的任务也是可以的,只是不推荐你怎么做。
Loader写法
Loader的实现是module.exports为一个function的js模块:
// my-loader.js
module.exports = function (content, map, meta) {
...
};
Webpack 会对loader注入三个参数:
content:资源文件的内容。对于起始loader,只有这一个参数map:前面loader生成的source map,可以传递给后方loader共享meta:其他需要传给后方loader共享的信息,可自定义
Loader是纯函数吗?
从定义和职责来看,loader的实现很像是一个纯函数,输入一个文件,输出转换后的内容给下一个loader或者交给webpack处理,但loader不是纯函数,主要有两个原因:
一是loader有执行上下文(context),也就是通过this访问内置的属性和方法,以实现特定的功能;
二是loader的return语句不一定有返回。
Loader的种类
Loader分为四类:
- 前置(Pre)
- 普通(Normal)
- 后置(Post)
- 行内(Inline)
可在配置文件中通过Rule.enforce属性指定loader的类型。默认为空,表示normal;值可为pre和post。
类型也会影响loader的执行顺序,这会在下文中介绍。
module: {
rules: [
{
test: /\.js$/,
use: ['pre-loader'],
enforce: 'pre',
},
{
test: /\.js$/,
use: ['normal-loader'],
},
{
test: /\.js$/,
use: ['post-loader'],
enforce: 'post',
},
]
}
还有特殊的行内loader,即直接在import/require语句中调用loader。
// 使用 ! 将资源中的 loader 分开
import Styles from 'style-loader!css-loader?modules!./styles.css';
// 在Inline loader中使用 !、!!、-! 前缀,可以禁用配置文件中的部分loader
import Styles from '!style-loader!css-loader?modules!./styles.css';
import Styles from '!!style-loader!css-loader?modules!./styles.css';
import Styles from '-!style-loader!css-loader?modules!./styles.css';
| 前缀 | 作用 |
|---|---|
| ! | 禁用配置中的normal loader |
| !! | 禁用配置中的所有loader(pre、normal、post) |
| -! | 禁用配置中的pre loader和normal loader |
Inline loader也可以向loader传递参数,支持url query方式和JSON方式
import Styles from 'style-loader?key=value&foo=bar!css-loader?modules!./styles.css';
// 或者
import Styles from 'style-loader?{"key": "value", "foo": "bar"}!css-loader?modules!./styles.css';
相当于配置文件:
[
{
loader: 'style-loader',
options: { key: 'value', foo: 'bar' }
},
{
loader: 'css-loader',
options: { modules: true }
},
]
Tip:
官方过推荐尽可能使用module.rules,而避免写inline loader,这样可以减少源码中样板文件的代码量,并且可以减少系统的维护成本。Inline-loader一般只针对某些需要特殊处理的模块使用。
输入和输出
默认情况下,资源文件会被转化为UTF-8字符串,然后传给loader。通过设置raw为 true,loader可以接收原始的Buffer。
module.exports = function (content) {
return someSyncOperation(content);
};
module.exports.raw = true;
loader的输出内容必须是String或Buffer类型。
同步和异步
Loader可以是同步的,也可以是异步的。
同步写法:
使用this.callback()或者直接return输出; this.callback的好处在于可以传递更多的内容参数。
module.exports = function (content, map, meta) {
const output = someSyncOperation(content);
return output;
// or
this.callback(null, output, map, meta);
return;
};
异步写法:
通过this.async()获取回调方法。
module.exports = function (content, map, meta) {
const callback = this.async();
someAsyncOperation(content, function (err, result, sourceMaps, meta) {
if (err) return callback(err);
callback(null, result, sourceMaps, meta);
});
};
官方tip:
loader最初被设计为可以在同步loader pipelines(如Node.js),以及在异步 pipelines(如webpack)中运行。然而,由于同步计算过于耗时,在Node.js这样的单线程环境下进行此操作并不是好的方案,我们建议尽可能地使你的loader异步化。但如果计算量很小,同步loader也是可以的。
缓存
默认情况下,webpack会缓存loader的输出结果,输入和相关依赖(通过this.addDependency或this.addContextDependency添加)没有变化时,会返回相同的结果。
可通过在loader中执行this.cacheable(false)关闭缓存功能。
cacheable(flag = true: boolean)
执行顺序
先看个例子,根据下面的配置,输入一个js文件,loader最终的执行顺序是怎么样的?
// webpack.config.js
rules: [
{
test: '/\.js$/',
use: ['a-loader', 'b-loader', 'c-loader'],
},
],
很多同学的回答可能都是 c-loader => b-loader => a-loader,也就是从右到左。
这个答案既对也不对。对是因为大多数情况下loader的执行顺序确实是这样的;不对是因为上例给的信息其实不全,没办法推测出实际上真正的执行顺序。
Pitch 和 Normal
Loader执行包括两个阶段,pitch阶段和normal阶段。
Normal阶段,就是大家一般认为的loader对源文件进行转译的阶段。
Pitch阶段会先于normal阶段进行,如果loader定义了pitch方法,就会在pitch阶段被执行;如果loader的pitch方法返回了内容,则会跳过后方loader的pitch和normal阶段。
如上例的配置,loader实际执行的顺序其实是:
若loader-b的pitch方法有返回,执行顺序则会变为:
即:loader-a(pitch)=> loader-b(pitch) => loader-a(normal)
Pitch方法的写法如下:
module.exports = function (content) {
return someOperation(content);
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
if (someCondition()) {
return someContent;
}
};
webpack会传给pitch方法三个参数:
remainingRequest: loader链中在自己之后的loader的request字符串precedingRequest: loader链中在自己之前的loader的request字符串data: data对象,该对象在normal阶段可以通过this.data获取,可用于传递共享的信息
request字符串:loader以及目标资源文件的绝对路径以"!"拼接起来的字符串,类似inline loader的require路径。如:
// remainingRequest
// Loader链中,当前loader后还有css-loader、less-loader,将被转译的目标文件是index.less
'/src/project/node_modules/css-loader/index.js!/src/project/node_modules/less-loader/dist/cjs.js!/src/project/src/styles/index.less'
参数data的使用:
module.exports = function (content) {
console.log(this.data.value) // 42
return someOperation(content);
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
data.value = 42;
};
官方文档对pitch的描述:
有些情况下,loader 只关心 request 后面的 元数据(metadata),并且忽略前一个 loader 的结果
Pitch和normal阶段可以理解为js里DOM事件机制的捕获和冒泡阶段,pitch方法为loader提供了跳过某些执行阶段的能力。
真实顺序
Loader实际的执行顺序与loader的类型,pitch方法,inline-loader的前缀都有关系。
其中:
-
Pitching阶段的调用顺序:
post > inline > normal > pre; -
Normal阶段的调用顺序:
pre > normal > inline > post。
举个例子,有以下webpack配置:
module: {
rules: [
{
test: /\.js$/,
use: ['pre-loader'],
enforce: 'pre',
},
{
test: /\.js$/,
use: ['normal-loader-a', 'normal-loader-b'],
},
{
enforce: 'post',
test: /\.js$/,
use: ['post-loader'],
},
],
}
并且js文件中调用inline-loader
// demo.js
const someModule = import('inline-loader-a!inline-loader-b!./someModule.js');
对于someModule.js,对其进行处理的loader的调用链为:
['post-loader', 'inline-loader-a', 'inline-loader-b', 'normal-loader-a', 'normal-loader-b', 'pre-loader']
假如js文件中对inline-loader的调用带有前缀:
// demo.js
const someModule = import('-!inline-loader-a!inline-loader-b!./someModule.js'); // 使用 -! 前缀禁用配置中的pre loader和normal loader
那么执行顺序会变为:
在上述前提下,若inline-loader-a的pitch方法有返回值,那么执行顺序又会变为:
loader-runner
Loader的实现本质上是一个方法,输入一个模块后,在webpack内部由loader-runner负责组织和调用loader,工作流程如下:
Loader context
Loader存在运行上下文,可以通过this去访问一些属性和方法,以下列举常用的部分(截至webpack v5.24.2)
this.getOptions
获取配置文件中传给该loader的options。
this.callback
this.callback(
err: Error | null,
content: string | Buffer,
sourceMap?: SourceMap,
meta?: any
);
- sourceMap: 返回本次转换中生成的source map。
- meta: 本次转换中生成的额外信息,可自定义。例如本次转换为源文件生成了AST,则可将该AST传给后面的loader,以免需要AST的loader去重复生成而降低性能。
this.async
告诉loader-runner这个loader将会异步地回调。返回this.callback
this.request
被解析出来的 request 字符串,类似Inline loader的调用,如:
"/abc/loader1.js?xyz!/abc/node_modules/loader2/index.js!/abc/resource.js?rrr"
this.loaders
Loader的调用链数组
this.addDependency
添加一个文件作为loader的依赖,若loader开启了缓存,该文件变化时会使缓存失效并重新调用loader。
例如,sass-loader和less-loader就使用了这方法,当它发现导入的css文件发生变化时就会重新编译。
this.addContextDependency
添加一个目录作为loader的依赖。
this.sourceMap
可通过this.sourceMap()获取配置中是否要求生成source map。
this.emitFile
emitFile(name: string, content: Buffer|string, sourceMap: {...})
输出一个文件。
更多context内容可查看Webpack官方文档。
本地开发
默认情况下,webpack只会去node_modules中寻找loader,可以通过修改配置文件中的resolveLoader.modules让webpack查找本地的loader。
例如我们开发中的loader路径为./path-to-your-loader/first-loader,则需要以下设置:
// webpack.config.js
module.exports = {
resolveLoader:{
modules: ['node_modules', 'path-to-your-loader'], // 指定webpack去哪些目录下查找loader(有先后顺序)
}
}
然后在module.rules中就可以使用自定义的loader了:
// webpack.config.js
{
module: {
rules: [
{
test: /\.css$/,
use: [ 'first-loader' ],
}
]
}
}
Loader开发原则
最后附上webpack官方推荐的十条Loader开发原则:
简单易用(Keep them simple)
loaders 应该只做单一任务。这不仅使每个 loader 易维护,也可以在更多场景链式调用。
使用链式传递(Utilize chaining)
利用 loader 可以链式调用的优势。写五个简单的 loader 实现五项任务,而不是一个 loader 实现五项任务。功能隔离不仅使 loader 更简单,可能还可以将它们用于你原先没有想到的功能。
模块化输出(Emit modular output)
保证输出模块化。loader 生成的模块与普通模块遵循相同的设计原则。
确保无状态(Make sure they're stateless)
确保 loader 在不同模块转换之间不保存状态。每次运行都应该独立于其他编译模块以及相同模块之前的编译结果。
使用loader utilities(Employ loader utilities)
充分利用loader-utils包,它提供了许多有用的工具。
记录loader的依赖(Mark loader dependencies)
如果一个loader使用外部资源(例如从文件系统读取),必须声明它。这些信息用于使缓存loaders无效,以及在观察模式(watch mode)下重编译。
解析模块依赖关系(Resolve module dependencies)
根据模块类型,可能会有不同的模式指定依赖关系,这些依赖关系应该由模块系统解析。
解析模块有以下两种方式:
- 通过把它们转化成
require语句 - 使用
this.resolve函数解析路径
如css-loader就是第一种方式的一个例子。它将@import语句替换为require来引用其他样式文件,将url(...)替换为require来引用文件,从而实现将依赖关系转化为require声明。
提取通用代码(Extract common code)
避免在loader处理的每个模块中生成通用代码。相反,你应该在loader中创建一个运行时文件,并生成require语句以引用该共享模块。
避免绝对路径(Avoid absolute paths)
不要在模块代码中插入绝对路径,因为当项目根路径变化时,文件绝对路径也会变化。loader-utils中的stringifyRequest方法,可以将绝对路径转化为相对路径。
使用peer dependencies(Use peer dependencies)
如果你的loader依赖另一个包,你应该把这个包作为一个peerDependency引入,这样可以让使用你的包的开发者更好地管理依赖。
广告
本文是对loader原理的解析,对loader实现源码感兴趣可以看我的另一篇文章: 【Webpack进阶】less-loader、css-loader、style-loader实现原理