webpack系列目录
- webpack5之核心配置梳理
- webpack5之模块化原理
- webpack5之Babel/ESlint/浏览器兼容
- webpack5之性能优化
- webpack5之Loader和Plugin的实现
- webpack5之核心源码解析
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'
})
]
}