彻底掌握 Webpack 中 Loader 和 Plugin 的机制

1,932 阅读12分钟

概述

因为忙好久没有更新文章了,正好在公司内部做了技术分享,私底下写了篇文章。希望给有需要的小伙伴带来帮助,一起成长~💪

通过本文你可以了解到一下知识点:

  1. Webpack 配置中 Loader 的用法
  2. Webpack 配置中 Plugin 的用法
  3. LoaderPlugin 的区别
  4. 如何编写一个自定义的 Loader
  5. 如何编写一个自定义的 Plugin

众所周知,Webpack 只能处理 JavaScriptJSON 文件,其他类型的文件只能通过对应的 loader 进行转换成 JavaScript 代码供 Webpack 进行打包!

探索 Loader

Loader 就是一个代码转码器,对各种资源进行转换。接下来我们从它的特点分类用法以及执行顺序等方面来彻底了解 Loader 的本质,从而为我们实现自定义 Loader 以及了解底层原理做铺垫。

  • Loader 的特点:单一原则,每个 Loader 只做对应的事情
  • Loader 的分类:prenormal(默认)、inlinepost 四种
  • Loader 的用法:单个loader、多个loader、对象形式
  • Loader 的顺序:从右到左,从下到上

总结Loader 就是一个函数,接受原始资源作为参数,输出进行转换后的内容。

Loader 的基本用法

接下来,我们创建一个项目来探索 LoaderWebpack 中是如何使用的,有哪些需要注意的点以及如何自定义一个 Loader

mkdir webpack-loader-plugin
cd webpack-loader-plugin
npm init -y
npm install webpack webpack-cli -D

安装好依赖和创建好对应的目录后,创建 webpack 的配置文件 webpack.config.js,来尝试一下 loader 的用法,并引入一个非 js 类型的文件然后进行打包,很显然打包是报错的,并友好的提示你:You may need an appropriate loader to handle this file type. 所以在 webpack 中加载非 js 类型的文件都需要通过对应的 loader 来进行转换后再进行打包。我们以 .css 样式文件为例:

// webpack.config.js
const path = require('path')
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
}

以上是 loader 的特点和基本用法,下面通过自定义 loader 来掌握 loader 的分类以及它的顺序顺序。

自定义 Loader

我们在项目中创建一个 loader 的文件夹来存放我们自定义的 loader1.jsloader2.jsloader3.jsloader4.js,修改 webpack.config.js 配置:

const path = require('path')
module.exports = {
    ...
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [path.resolve(__dirname, 'loader/loader1.js'), path.resolve(__dirname, 'loader/loader2.js'), path.resolve(__dirname, 'loader/loader3.js'), path.resolve(__dirname, 'loader/loader4.js')]
            }
        ]
    }
}

loader 的引入方式:

    1. 通过 npm 包安装的 loader,直接使用名称即可
        {
            test: /\.css$/,
            use: 'css-loader'
        }
    
    1. 自定义 loader 时,使用绝对路径
        {
            test: /\.css$/,
            use: path.resolve(__dirname, 'loader/loader1.js')
        }
    
    1. 配置别名方式
    resolveLoader: {
        alias: {
            'loader1': path.resolve(__dirname, 'loader/loader1.js')
        }
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: 'loader1'
            }
        ]
    }
    
    1. 配置查找方式
    resolveLoader: {
        modules: ['node_modules', path.resolve(__dirname, 'loader')]
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: 'loader1'
            }
        ]
    }
    

loader 的执行顺序:

从上面的例子可知:普通 loader 的执行顺序:从右向左、从下到上

但是通过改变 loader 的四种类型的执行顺序:prenormalinlinepost。这样我们就可以随意的改变 loader 的执行顺序。

{
    test: /.js$/,
    use: 'loader1',
    enforce: 'pre'
},
{
    test: /.js$/,
    use: 'loader2'
},
{
    test: /.js$/,
    use: 'loader3',
    enforce: 'post'
}

若:不加 enforce 属性时,加载顺序:从下到上,依次为:loader3 -> loader2 -> loader1; 但加上以上 enforce 属性配置后,加载顺序改变了依次为:loader1 -> loader2 -> loader3

inline loader的加载规则

  • !: 忽略 normal loader
  • -!: 忽略 normal loaderpre loader
  • !!: 忽略 normal loaderpre loaderpost loader

总结:通过前缀来设置禁用不同种类的 loader

Picth 方法

loader 的执行分为两个阶段:Pitch 阶段Noraml 阶段。在定义一个 loader 函数时,可以导出一个 pitch 方法,这个方法会在 loader 函数执行前执行。

loader 会先执行 pitch,然后获取资源再执行 normal loader。如果 pitch 有返回值时,就不会走之后的 loader,并将返回值返回给之前的 loader。这就是为什么 pitch熔断 的作用!

// loader/loader1.js
function loader1(source) {
  console.log('loader1~~~~~~', source)
  return source
}

module.exports = loader1

module.exports.pitch = function (res) {
  console.log('pitch1')
}
// loader/loader2.js 同理
// loader/loader3.js 同理
// loader/loader4.js 同理

获取参数

想要获取用户传入的参数时,需要下载一个依赖:

npm install loader-utils -D // 注意loader-utils@2.0.0版本

loader-utils 依赖中有个 getOptions 方法,用来获取 loaderoptions 的配置

// webpack.config.js
{
    test: /.js$/,
    use: [
        {
            loader: 'loader1',
            options: {
                name: 'tmc'
            }
        }
    ]
}
// loader1.js
const loaderUtil = require('loader-utils')
function loader1 (source) {
    const options = loaderUtil.getOptions(this) || {}
    console.log(options)
    return source
}

module.exports = loader1

// 输出:{ name: 'tmc' }

验证参数

想要验证用户传入的参数是否合法,需要下载一个依赖:

npm install schema-utils -D

schema-utils 依赖中有个 validate 方法,用来验证 loaderoptions 的配置是否合法

// loader/loader1.js
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const schemaJson = require('./schema.json')
function loader1 (source) {
    const options = getOptions(this) || {}
    validate(schemaJson, options, { name: 'loader1' })
    return source
}

module.exports = loader1

// schema.json
{
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "名称"
        }
    },
    "additionalProperties": true
}

properties 中的键名就是我们需要检验的 options 中的字段名称,additionalProperties 代表了是否允许 options 中海油其他额外的属性。

注意additionalProperties 属性默认值是 false,当 loader 允许添加多个 options 属性时,将值改为 true 即可。

同步 & 异步

loader 分为 同步loader异步loader

当使用同步方式转换内容时,可以使用 returnthis.callback() 两种形式返回结果

callback 的详细传参方法如下:

callback({
    error: Error | Null, // 当无法转换原内容时,给webpack返回一个Error
    content: String | Buffer, // 转换后的内容
    sourceMap?: SourceMap, // 转换后的内容得出原内容的Source Map(可选)
    abstrctSyntaxTree?: AST // 原内容生成 AST语法数(可选)
})
function loader3 (source, map, meta) {
    console.log('loader3~~~~~~', source)
    return source
}
// 或者
function loader3 (source, map, meta) {
    console.log('loader3~~~~~~', source)
    this.callback(null, source, map, meta)
    return;
}

module.exports = loader3
// map, meta两个参数是可选参数

注意:当调用 callback() 时,始终返回 undefined

当使用异步方式转换内容时,使用 this.async() 形式获取异步操作的回调函数,并在回调函数中返回结果

function loader4 (source) {
    console.log('loader4~~~~~~', source)
    const callback = this.async()
    setTimeout(() => {
        callback(null, source) // 第一个参数错误对象,可设置为null
    }, 1000)
}

module.exports = loader4

技巧

  • webpack 默认会缓存所有的 loader, 如果不想缓存就使用this.cacheable(false)
  • 处理二进制数据
module.exports = function(source) {
    source instanceof Buffer === true;
    return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据
module.exports.raw = true;

注意:最关键的代码是最后一行 module.exports.raw = true;,没有该行 Loader 只能拿到字符串

实战

接下来,我们手写一个 Loader 来实战一下它的用法:

需求:模拟实现 babel-loader 的功能

// loader/babelLoader.js
const {
    getOptions
} = require('loader-utils')
const {
    validate
} = require('schema-utils')
const babel = require('@babel/core')
const schema = require('./babelSchema.json')

function babelLoader(source) {
    // 获取用户传入的参数
    const options = getOptions(this)
    // 验证参数
    validate(schema, options, {
        name: 'babelLoader'
    })
    const callback = this.async()
    // 转换代码
    babel.transform(source, {
        presets: options.presets,
        sourceMap: true
    }, (err, result) => {
        callback(err, result.code, result.map)
    })
}

module.exports = babelLoader

// loader/babelSchema.json
{
    "type": "object",
    "properties": {
        "presets": {
            "type": "array"
        }
    },
    "additionalProperties": true
}

// 使用
module.exports = {
    module: {
        rules: [
            {
                test: /.js$/,
                use: {
                    loader: 'babelLoader',
                    options: {
                        presets: [
                            "@babel/preset-env"
                        ]
                    }
                }
            }
        ]
    },
    // 解析loader的规则
    resolveLoader: {
        modules: ['node_modules', path.resolve(__dirname, 'loader')]
    }
}

探索 Plugin

Plugin 就是一个扩展器,它比 Loader 更加灵活,因为它可以接触到 Webpack 编译器。在 Webpack 运行的生命周期中会广播出许多的事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。这样 Plugin 就可以通过一些 hook 函数来拦截 Webpack 的执行,做一些 Webpack 打包之外的事情。像:打包优化资源管理注入环境变量等等。

Plugin 的基本用法

使用一个插件三步:

    1. npm 安装对应的插件
    1. 引入安装的插件
    1. plugins 中使用
// 1. npm 安装对应的插件
npm install clean-webpack-plugin -D

// 2. 引入安装的插件
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

// 3. 在 plugins 中使用
export default {
    plugins: [
        new CleanWebpackPlugin()
    ]
}

自定义 Plugin

在编写插件之前,我们首先要熟悉 webpack 整体的运行流程。webpack 的运行本质上是一种事件流的机制,在 webpack 运行过程中,从初始化参数编译文件确认入口编译模块编译完成生成资源到最后输出资源等一系列过程。在这整个过程中,webpack 都会在对应的节点中向外广播一些事件,我们可以监听这些事件,在合适的时机通过 webpack 提供的 API 做一些合适的事情。

Webpack 插件由以下组成:

  1. 一个 JavaScript 命名函数
  2. 在插件函数的 propotype 上定义一个 apply 方法
  3. 制定一个绑定到 webpack 自身的事件钩子
  4. 处理 webpack 内部实例的特定数据
  5. 功能完成后调用 webpack 提供的回调

Plugin 的核心在于,apply 方法执行时,可以操作 webpack 本次打包的各个时间节点(hooks), 在不同的时间节点做一些操作。其工作流程如下:

  1. webpack 启动后,执行 new myPlugin(options),初始化插件并获取实例
  2. 初始化 compiler 对象,调用 myPlugin.apply(compiler) 给插件传入 compiler 对象
  3. 插件实例获取 compiler, 它监听 webpack 广播的事件,通过 compiler 操作 webpack

使用插件时往往都是 new XXXPlugin() ,那么换句话说一个插件就是一个类,使用该插件就是 new 一个该插件的实例,并且把插件所需要的配置参数传给该类的构造函数。在构造函数中获取用户传入的参数。

得出编写一个插件的第一步,如下:

class myPlugin {
    constructor(options) {
        this.options = options // 用户传入的参数
    }
}

module.exports = myPlugin

webpack 源码得知,插件实例上都会有个 apply 方法,并将 compiler 作为其参数。

// webpack.js源码
if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
            plugin.call(compiler, compiler);
        } else {
            plugin.apply(compiler);
        }
    }
}

扩展webpack 中插件都会有一个 apply 方法,类似于 Vue 插件中都会有个 install 方法

得出编写一个插件的第二步,如下:

class myPlugin {
    constructor(options) {
        this.options = options // 用户传入的参数
    }
    apply(compiler) {

    }
}

module.exports = myPlugin

在开发 Plugin 时最常用的两个对象 CompilerCompilation,它们都继承自Tapable,是 PluginWebpack 之间的桥梁。类似于 react-redux 是连接 ReactRedux 的桥梁。

核心对象

  • 负责整体编译流程的 Compiler 对象
  • 负责编译 Module 的 Compilation 对象

Compiler

Compiler 对象表示了完整的 Webpack 环境配置(可以理解为 webpack 一个实例)。该对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 optionsloaderplugin。当在 webpack 环境中应用一个插件时,插件将收到此 Compiler 对象的引用。可以使用它来访问 webpack 的主环境。

Compilation

Compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 Compilation,从而生成一组新的编译资源。一个 Compilation 对象表现了当前的 模块资源编译生成资源变化的文件、以及 被跟踪依赖的状态信息Compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

注意:两者的区别在于:一个是代表了整个构建过程,一个是代表构建过程中的某个模块

  • compiler 代表了整个 webpack 从启动到关闭的生命周期,而 compilation 只是代表了一次性的编译过程。
  • compilercompilation 两者都暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理。

Tapable

Tapable 提供了一系列事件的发布订阅 API,通过 Tapable 可以注册事件,从而在不同时机去触发注册的事件回调进行执行。Webpack 中的 Plugin 机制正是基于这种机制实现在不同编译阶段调用不同的插件从而影响编译结果。

const {
 SyncHook,
 SyncBailHook,
 SyncWaterfallHook,
 SyncLoopHook,
 AsyncParallelHook,
 AsyncParallelBailHook,
 AsyncSeriesHook,
 AsyncSeriesBailHook,
 AsyncSeriesWaterfallHook
} = require("tapable");

同步钩子只有一种注册的方法

  1. 只能通过tap注册,由 call 来执行

异步钩子提供三种注册的方法:

  1. tap:以同步方式注册钩子,用 call 来执行
  2. tapAsync: 以异步方式注册钩子,用 callAsync 来执行
  3. tapPromise: 以异步方式注册钩子,返回一个 Promise

注意:异步钩子可以分为:

  • 异步串行钩子( AsyncSeries ):可以被串联(连续按照顺序调用)执行的异步钩子函数
  • 异步并行钩子( AsyncParallel ):可以被并联(并发调用)执行的异步钩子函数

不同类型钩子的区别?

  • 基本类型: 它不关心每个被调用的事件的返回值,仅仅执行注册的回调函数
  • 瀑布类型: 会将上一个回调函数的返回值传递给下一个回调函数作为参数
  • 保险类型: 如果回调函数中存在非 undefined 返回值时,后面的回调函数都不会被执行了
  • 循环类型: 如果回调函数中存在非 undefined 返回值,就会重新开始执行所有的回调函数,直到所有的回调函数都返回 undefined

当知道了 webpack 会广播哪些事件后,我们就可以在 apply 中监听事件并编写对应的逻辑,如下:

class myPlugin {
    constructor(options) {
        this.options = options // 用户传入的参数
    }
    apply(compiler) {
        // 监听某个事件
        compiler.hooks.'compiler事件名称'.tap('myPlugin', (compilation) => {
            // 编写对应的逻辑
        })
    }
}

module.exports = myPlugin

注意:当监听 compiler 对象中的 compilation 事件时,此时也可以在回调函数中继续监听 compilation 对象里的事件,如下:

class myPlugin {
    constructor(options) {
        this.options = options // 用户传入的参数
    }
    apply(compiler) {
        // 监听某个事件
        compiler.hooks.'compiler事件名称'.tap('myPlugin', (compilation) => {
            // 编写对应的逻辑
            compilation.hooks.'compilation事件名称'.tap('myPlugin', () => {
                // 编写对应的逻辑
            })
        })
    }
}

module.exports = myPlugin

上面监听的事件都是同步的钩子,用 tap 进行注册。当监听异步的钩子时,我们就需要用 tapAsync 和 tapPromise 来进行注册了。并且还需要传入一个 cb 回调函数,在插件运行完后,必须调用这个这个 cb 回调函数,如下:

class myPlugin {
    constructor(options) {
        this.options = options // 用户传入的参数
    }
    apply(compiler) {
        // 监听某个事件
        compiler.hooks.emit.tapAsync('myPlugin', (compilation, cb) => {
            setTimeout(() => {
                // 编写对应的逻辑
                cb()
            }, 1000)
        })
    }
}

module.exports = myPlugin

获取 & 验证参数

我们知道在插件的钩子函数中可以获取到外部传入给插件的参数。在编写一个插件时,我们需要验证参数传入的是否合法。和 Loader 类似,验证参数是否合法,需下载:

npm install schema-utils -D

调试技巧:

"scripts": {
    "start": "node --inspect-brk ./node_modules/webpack/bin/webpack.js"
},

webpack 插件本质上就是通过发布订阅的模式,通过 compiler 上监听事件。然后再打包编译过程中触发监听的事件从而添加一定的逻辑影响打包结果。

实战

接下来,我们通过手写 Plugin 来实战一下它的用法

需求:打包前删除js文件中的注释

class MyPlugin {
      constructor(options) {
        console.log('插件选项:', options)
        this.userOptions = options || {}
      }

      // 必须带有apply方法
      apply(compiler) {
        compiler.hooks.emit.tap('插件名称', (compilation) => {
          // compilation 此次打包的上下文
          console.log('webpack 构建过程开始!', compilation)
          for (const name in compilation.assets) {
            // if(name.endsWith('.js'))
            if (name.endsWith(this.userOptions.target)) {
              // 获取处理之前的内容
              const content = compilation.assets[name]
              // 将原来的内容,通过正则表达式,删除注释
              const noComments = content.replace(/\/\*[\s\S*?]\*\//g, '')
              // 将处理后的结果,替换
              compilation.assets[name] = {
                source: () => noComments,
                size: () => noComments.length,
              }
            }
          }
        })
      }
    }

    module.exports = MyPlugin

    // 使用
    const MyPlugin = require('./plugin/my-plugin')

    module.exports = {
      // 插件配置
      plugins: [
        new MyPlugin({
          // 插件选项
          target: '.js',
        }),
      ],
    }

总结

通过以上我们大致掌握了 Webpack 中比较重要的两个概念:LoaderPlugin。前端工程化在前端领域越来越的重要,不管是平时工作需要,还是面试提升自己的技术功底。掌握好 Webpack 的用法并了解其原理会给我们带来很大的好处!