webpack5之Loader和Plugin的实现

859 阅读3分钟

webpack系列目录

  1. webpack5之核心配置梳理
  2. webpack5之模块化原理
  3. webpack5之Babel/ESlint/浏览器兼容
  4. webpack5之性能优化
  5. webpack5之Loader和Plugin的实现
  6. webpack5之核心源码解析

Loader底层实现

我们之前已经在核心配置中提到了很多Loader,比如style-loadercss-loadervue-loaderbabel-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

Snipaste_2022-03-26_19-52-21.png

可以看到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执行')
}

再重新执行打包

Snipaste_2022-03-26_19-56-40.png

可以看到pitch-loader是从01开始,这是什么原因呢,我们可以查看源码loader-runner这个库下面的lib/LoaderRunner.js这个文件。

Snipaste_2022-03-26_20-01-48.png

在执行runLoaders函数中先执行iteratePitchingLoaders这个函数,也就是说先执行pitch-loader。

Snipaste_2022-03-26_20-04-50.png

并且在iteratePitchingLoadersloaderContext.loaderIndex++,并且递归执行iteratePitchingLoaders,执行完后才执行iterateNormalLoaders,也就是正常的loader。

Snipaste_2022-03-26_20-07-07.png

往下看可以看到loaderContext.loaderIndex--,并执行iterateNormalLoaders。所以loader的执行顺序是按loaderIndex来执行的

总结:

  • runLoader先优先执行PitchLoader,在执行PitchLoader时进行loaderIndex++
  • runLoader之后会执行NormalLoader,在执行NormalLoader时进行loaderIndex--

那我们能否自定义执行顺序呢,可以,我们需要拆分成多个Rule对象,通过enforce来改变它们的顺序

enforce一共有四种方式:

  • 默认所有的loader都是normal
  • 在行内设置的loader是inline
  • 可以通过enforce设置prepost
  1. Pitching 阶段: loader上的pitch方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用
  2. 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',
  ]
}

Snipaste_2022-03-26_20-16-51.png

现在可以看到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)
}

Snipaste_2022-03-26_20-21-37.png

我们现在使用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)
}

Snipaste_2022-03-26_20-27-33.png

现在依然能够按顺序打印出来,并且在打包过程中可以看到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)
}

Snipaste_2022-03-26_20-37-33.png

可以看到我们通过调用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传入字符串并重新打包

Snipaste_2022-03-26_20-46-11.png

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编译内容

Snipaste_2022-03-26_21-11-47.png

Plugins底层实现

webpack有两个非常重要的类:CompilerCompilation,他们通过注入插件的方式监听webpack的整个过程,插件的注入离不开hooks,而这些hooks是由官方维护的一个Tapable库管理的,因此我们需要先来弄清楚这个库的使用方式。

Tapable

我们可以源码查看下Tapable导出的hooks,包含了以下几种

  • SyncHook
  • SyncBailHook
  • SyncWaterfallHook
  • SyncLoopHook
  • AsyncParallelHook
  • AsyncParallelBailHook
  • AsyncSeriesHook
  • AsyncSeriesBailHook
  • AsyncSeriesLoopHook
  • AsyncSeriesWaterfallHook

Snipaste_2022-03-27_12-42-57.png

我们可以将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.打印结果

Snipaste_2022-03-27_12-58-59.png

可以看到第一个注册hook将return 123返回给了第二个hook的第一个参数

plugin注册原理

在webpack中到底是怎么注册插件的呢,我们可以通过源码查看

Snipaste_2022-03-27_13-18-34.png

  1. 在调用webpack函数的createCompiler方法中,注册所有的插件
  2. 在注册插件时,会调用插件函数或者插件对象的apply方法
  3. 插件方法会接收compiler对象,我们可以通过compiler对象来注册Hook的事件
  4. 某些插件也会传入一个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'
    })
  ]
}