webpack5之Loader和Plugin的实现
Loader 底层实现
我们之前已经在核心配置中提到了很多 Loader,比如style-loader
、css-loader
、vue-loader
、babel-loader
等等,那怎么实现一个自定义 Loader 呢,Loader 本质上是一个导出为函数的 JavaScript 模块,loader runner 库会调用这个函数,然后将上一个 loader 产生的结果或者资源文件传入进去。
现在我们开发一个自定义 loader,我们新建一个 loaders 目录,在新建一个 yj-loader.js
// loaders/yj-loader.js
module.exports = function (content, map, meta) {
console.log(content);
console.log(map);
console.log(meta);
return content;
};
该函数会接受三个参数
content
: 资源文件的内容map
: sourcemap 相关的数据meta
: 一些元数据
下面我们从 loader 的引入路径,执行顺序,异步 loader,获取参数,实现一个 loader 这几个方面在探讨下。
引入路径
现在我们在 webpack 配置该自定义 loader
{
test: /\.js$/,
use: [
'./loaders/yj-loader',
]
}
可以看到,我们引入的自定义 loader 路径是相对路径,且基于 context 属性,但是如果我们依然希望可以直接去加载自己的 loader 文件,我们可以配置resolveLoader
属性
{
resolveLoader: {
modules: ["node_modules", "./loaders"];
}
}
该属性是用来配置 loader 的引入路径,默认是 node_modules,我们 node_modules 没有的话去找我们的 loaders 目录,可以在 module 属性后面添加我们的 loaders 目录,现在我们可以直接使用 loader
{
test: /\.js$/,
use: [
'yj-loader',
]
}
执行顺序
之前在介绍 loader 时讲了 loader 执行的顺序是从数组最后往前执行,现在我们新建三个自定义 loader 来证明一下这个结果,我们新建 yj-loader01.js,yj-loader02.js,yj-loader03.js。并在每个 loader 打印
// yj-loader01.js
module.exports = function (content, map, meta) {
console.log("loader01执行");
return content;
};
// yj-loader02.js
module.exports = function (content, map, meta) {
console.log("loader02执行");
return content;
};
// yj-loader03.js
module.exports = function (content, map, meta) {
console.log("loader03执行");
return content;
};
// webpack
{
test: /\.js$/,
use: [
'yj-loader01',
'yj-loader02',
'yj-loader03',
]
}
现在我们打包一下 npm run build
可以看到 loader03 先执行,loader02 第二执行,loader01 最后执行。其实在 loader 中我们还可以配置一个 pitch-loader,我们修改下 loader
// yj-loader01.js
module.exports = function (content, map, meta) {
console.log("loader01执行");
return content;
};
module.exports.pitch = function () {
console.log("pitch-loader01执行");
};
// yj-loader02.js
module.exports = function (content, map, meta) {
console.log("loader02执行");
return content;
};
module.exports.pitch = function () {
console.log("pitch-loader02执行");
};
// yj-loader03.js
module.exports = function (content, map, meta) {
console.log("loader03执行");
return content;
};
module.exports.pitch = function () {
console.log("pitch-loader03执行");
};
再重新执行打包
可以看到 pitch-loader 是从 01 开始,这是什么原因呢,我们可以查看源码 loader-runner 这个库下面的 lib/LoaderRunner.js 这个文件。
在执行 runLoaders 函数中先执行iteratePitchingLoaders这个函数,也就是说先执行 pitch-loader。
并且在iteratePitchingLoaders中loaderContext.loaderIndex++
,并且递归执行iteratePitchingLoaders,执行完后才执行iterateNormalLoaders,也就是正常的 loader。
往下看可以看到loaderContext.loaderIndex--
,并执行iterateNormalLoaders。所以 loader 的执行顺序是按 loaderIndex 来执行的
总结:
- runLoader 先优先执行 PitchLoader,在执行 PitchLoader 时进行 loaderIndex++
- runLoader 之后会执行 NormalLoader,在执行 NormalLoader 时进行 loaderIndex–
那我们能否自定义执行顺序呢,可以,我们需要拆分成多个 Rule 对象,通过enforce来改变它们的顺序
enforce 一共有四种方式:
- 默认所有的 loader 都是
normal
- 在行内设置的 loader 是
inline
- 可以通过 enforce 设置
pre
和post
- Pitching 阶段: loader 上的 pitch 方法,按照
后置(post)、行内(inline)、普通(normal)、前置(pre)
的顺序调用 - Normal 阶段: loader 上的常规方法,按照
前置(pre)、普通(normal)、行内(inline)、后置(post)
的顺序调用。模块源码的转换, 发生在这个阶段。
现在我们将 loader02 设置 pre
{
test: /\.js$/,
use: [
'yj-loader01'
],
},
{
test: /\.js$/,
use: [
'yj-loader02',
],
enforce: 'pre'
},
{
test: /\.js$/,
use: [
'yj-loader03',
]
}
现在可以看到 loader02 第一个执行了,pitch-loader02 也就变成了最后一个执行。
异步 loader
我们之前默认创建的 Loader 都是是同步的 Loader,这个 Loader 必须通过return
或者this.callback
来返回结果,交给下一个 loader 来处理。通常在有错误的情况下,我们会使用this.callback
this.callback 的用法如下:
- 第一个参数必须是 Error 或者 null
- 第二个参数是一个 string 或者 Buffer
// yj-loader.js
module.exports = function (content, map, meta) {
console.log("执行loader");
return this.callback(null, content);
};
我们现在使用 this.callback 的方式返回也是可以的,那有时候我们使用 Loader 时会进行一些异步的操作,我们希望在异步操作完成后,再返回这个 loader 处理的结果,这时候需要使用异步的 loader 了,loader-runner 已经给我们实现了this.async
函数,我们使用如下
// yj-loader03.js
module.exports = function (content, map, meta) {
console.log("执行loader03");
const callback = this.async();
setTimeout(() => {
callback(null, content);
}, 3000);
};
现在依然能够按顺序打印出来,并且在打包过程中可以看到 loader03 打印后延迟了大概 3S 才打印 loader02 和 loader01。
获取参数
我们之前在使用 css-loader 或者 babel-loader 时配置了参数,那我们如何也能配置参数并获取到呢。我们可以通过一个 webpack 官方提供的一个解析库loader-utils,该库里面有一个getOptions
方法能够帮助我们获取配置,而且该库在安装 webpack 时已自动帮我们安装。 修改我们的 loader 并在 loader 上添加参数
// webpack
{
test: /\.js$/,
use: [
{
loader: 'yj-loader03',
options: {
name: 'lyj',
age: 18
}
}
]
},
// yj-loader-03.js
const { getOptions } = require("loader-utils");
module.exports = function (content, map, meta) {
console.log("loader03执行");
// 获取参数
const options = getOptions(this);
console.log(options);
// 获取异步loader
const callback = this.async();
setTimeout(() => {
callback(null, content);
}, 3000);
};
可以看到我们通过调用getOptions(this)
获取到了参数,那如何校验传入到参数呢。我们可以通过一个 webpack 官方提供的校验库schema-utils
,里面有validate
方法用于验证参数,并且该库是在安装 webpack 时帮我们安装了。
现在我们需要一个校验规则文件,新建一个loader-schema.json
// loader-schema.json
{
"type": "object", // 传入类型
"properties": {
// 属性
"名字": {
"type": "string",
"description": "请输入您的姓名"
},
"age": {
"type": "number",
"description": "请输入您的年龄"
}
},
"additionalProperties": true // 表示除了以上属性外还可以额外添加其他的属性
}
// yj-loader-03.js
const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils"); // 用于验证loader传参
const loaderSchema = require("./loader-schema.json");
module.exports = function (content, map, meta) {
console.log("loader03执行");
// 获取参数
const options = getOptions(this);
console.log(options);
// 参数校验
validate(loaderSchema, options);
// 获取异步loader
const callback = this.async();
setTimeout(() => {
callback(null, content);
}, 3000);
};
现在我们传参对 age 传入字符串并重新打包
schema-utils
帮助我们验证了参数并提示了描述,并阻断了构建,说明验证成功。
实现一个 loader
现在我们来实现一个简易的 markdown loader,安装 marked,highlight.js。直接上代码
// mkdown-loader.js
const marked = require("marked");
const hljs = require("highlight.js");
module.exports = function (content) {
// 设置代码高亮
marked.setOptions({
highlight: function (code, lang) {
return hljs.highlight(lang, code).value;
},
});
// 转成html
const htmlContent = marked(content);
// 转成模块化导出
const innerContent = "`" + htmlContent + "`";
const moduleCode = `var code = ${innerContent};export default code;`;
console.log(moduleCode);
return moduleCode;
};
// webpack loader配置
{
test: /\.md$/,
use: 'mkdown-loader'
}
// test.md
# loader 实现
## 引入路径
## 执行顺序
## 异步 loader
```
module.exports = function(content, map, meta) {
console.log('执行loader03')
const callback = this.async()
setTimeout(() => {
callback(null, content)
}, 3000)
}
```
## 参数获取
// main.js
import mdContent from "./test.md";
import "highlight.js/styles/default.css";
document.body.innerHTML = mdContent;
重新打包后我们可以看到页面上已经出现了我们的 mkdown 编译内容
Plugins 底层实现
webpack 有两个非常重要的类:Compiler和Compilation,他们通过注入插件的方式监听 webpack 的整个过程,插件的注入离不开 hooks,而这些 hooks 是由官方维护的一个Tapable
库管理的,因此我们需要先来弄清楚这个库的使用方式。
Tapable
我们可以源码查看下Tapable导出的 hooks,包含了以下几种
SyncHook
SyncBailHook
SyncWaterfallHook
SyncLoopHook
AsyncParallelHook
AsyncParallelBailHook
AsyncSeriesHook
AsyncSeriesBailHook
AsyncSeriesLoopHook
AsyncSeriesWaterfallHook
我们可以将Tapable的 hooks 分为同步和异步,
- 以 sync 开头的,是同步的 Hook
- 以 async 开头的,两个事件处理回调,不会等待上一次处理回调结束后再执行下一次回调
我们也可以按其他的类别分类
bail
: 当有返回值时,就不会执行后续的事件触发了Loop
: 当返回值为 true,就会反复执行该事件,当返回值为 undefined 或者不返回内容,就退出事件Waterfall
: 当返回值不为 undefined 时,会将这次返回的结果作为下次事件的第一个参数Parallel
: 并行,会同时执行次事件处理回调结束,才执行下一次事件处理回调Series
: 串行,会等待上一是异步的 Hook
我们简单使用下Tapable
1.编写一个 tapable 测试文件
// tapable-test.js
const { SyncWaterfallHook } = require("tapable");
class MyTapable {
constructor() {
this.hooks = {
syncWaterfallHook: new SyncWaterfallHook(["myName", "myAge"]),
};
this.on();
}
// 注册
on() {
this.hooks.syncWaterfallHook.tap("myTap1", (name, age) => {
console.log("myTap1", name, age);
return "123";
});
this.hooks.syncWaterfallHook.tap("myTap2", (name, age) => {
console.log("myTap2", name, age);
});
}
// 初始化
emit() {
this.hooks.syncWaterfallHook.call("lyj", 18);
}
}
const tapable = new MyTapable();
tapable.emit();
2.执行tapable-test.js
node tapable-test.js
3.打印结果
可以看到第一个注册 hook 将return 123
返回给了第二个 hook 的第一个参数
plugin 注册原理
在 webpack 中到底是怎么注册插件的呢,我们可以通过源码查看
- 在调用 webpack 函数的 createCompiler 方法中,注册所有的插件
- 在注册插件时,会调用插件函数或者插件对象的 apply 方法
- 插件方法会接收 compiler 对象,我们可以通过 compiler 对象来注册 Hook 的事件
- 某些插件也会传入一个 compilation 的对象,我们也可以监听 compilation 的 Hook 事件
实现一个 plugin
我们实现一个打包构建目录后自动上传至服务器的插件AutoUploadPlugin
const { NodeSSH } = require("node-ssh");
class AutoUploadPlugin {
constructor(options) {
this.ssh = new NodeSSH();
this.options = options;
}
apply(compiler) {
compiler.hooks.afterEmit.tapAsync(
"AutoUploadPlugin",
async (compilation, callback) => {
// 1.获取输出的文件夹
const outputPath = compilation.outputOptions.path;
// 2.连接服务器(ssh连接)
await this.connectServer();
// 3.删除原来目录中的内容
const serverDir = this.options.remotePath;
await this.ssh.execCommand(`rm -rf ${serverDir}/*`);
// 4.上传文件到服务器(ssh连接)
await this.uploadFiles(outputPath, serverDir);
// 5.关闭ssh
this.ssh.dispose();
callback();
}
);
}
async connectServer() {
await this.ssh.connect({
host: this.options.host,
username: this.options.username,
password: this.options.password,
});
console.log("连接成功~");
}
async uploadFiles(localPath, remotePath) {
const status = await this.ssh.putDirectory(localPath, remotePath, {
recursive: true,
concurrency: 10,
});
console.log("传送到服务器: ", status ? "成功" : "失败");
}
}
module.exports = AutoUploadPlugin;
使用该插件
// webpack
{
plugins: [
//...
new AutoUploadPlugin({
host: "xxx.xxx.xxx.xxx",
username: "xxx",
password: "xxx",
}),
];
}