由浅及深实现自定义Plugin

286 阅读11分钟

webpack系列文章

  1. 实现一个简易的模块打包器
  2. 由浅及深实现一个自定义loader
  3. webpack源码阅读一:webpack流程以及重要钩子
  4. webpack的核心之tapable机制

写在前面

在之前的webpack的系列课程中,我们已经介绍了:

  1. webpack的核心打包原理,并手动实现了一个简易的模块打包器。详见:实现一个简易的模块打包器
  2. webpack的核心之loader,并实现了自定义的loader。详见:由浅及深实现一个自定义loader
  3. 粗劣地查看了webpack的源码,了解了webpack的整个工作流程及各个阶段的常见钩子。详见:webpack源码阅读一:webpack流程以及重要钩子
  4. webpack的核心之tapable机制,详细介绍了核心库tapable,并简单地实现了这个库的核心类。详见:webpack的核心之tapable机制

接下来,只剩下webpack的最后一个核心Plugin,之所以将Plugin放到最后,是因为Plugin的部分依赖于webpack的整个编译流程,以及tapable的钩子。也就是说我们需要有这两部分的前置知识。同理,这篇文章与之前的文章一样,都是希望通过由浅及深的方式向大家讲述Plugin,同时实现Plugin插件。

什么是Plugin?

我们在日常开发中,可能会用到很多插件(Plugin),但是我们可能很少去仔细地思考什么是插件。用官方的话来说:插件(Plugin)是webpack的支柱功能,webpack自身就是构建于插件之上(说了等于没说)。平常我们的理解就是插件是用于处理webpack在编译过程中的某个特定任务的功能模块。更加通俗地理解:Plugin就是在webpack编译的某个阶段专注于实现某个功能。这就涉及到Plugin的两个重点:

  1. **阶段。**也就是说它需要插入webpack的某个阶段。
  2. **实现某个功能。**也就是说它需要再这个阶段做一些事情。而这个事情是我们编写插件的人确定的。你想要实现某个功能就去实现一个插件。 这样的话,大家可能就更加容易理解插件(Plugin)了。

webpack的编译阶段

在上面的介绍中,我们知道插件需要插入到webpack编译的某个阶段,那到底有多少个编译阶段了,这就需要用到我们在之前的阅读源码的文章中了解到的webpack的常见阶段。 img 插件原则上是可以作用于webpack编译的整个阶段。但是通常我们会在以下几个重要阶段进行插入:

  1. 编译阶段:

    钩子说明
    compile编译启动
    compilation编译(常用)
    make正式开始编译,编译的核心过程
    afterCompile结束编译
  2. 输出阶段

    钩子说明
    emit输出编译后的文件(常用)
    afterEmit输出完成
    donewebpack所有过程完成(常用)

其中,compilationemitdone又是最常用的三个阶段。

Plugin实现功能

我们在上面的介绍中提到了:Plugin的另外一个重要特点是需要实现特定的功能。提到实现某个功能,我们第一次想法就是它是一个函数或者说一个类。事实上一个插件就是一个函数或者说一个类,我们更常用的还是一个类。因此,接下来我们的核心其实就是去实现一个类。

如何实现一个Plugin?

在webpack官网中writting a Plugin描述了如何去实现一个类:

  1. 编写一个具名的函数或者类。
  2. 在函数或者类身上定义一个apply方法。
  3. 注入一个事件钩子。
  4. 处理webpack内部实例身上的特定数据。(compilation)。
  5. 功能完成之后,调用wbepack提供的回调。

接下来我们就按照它的描述一步一步由浅及深地去实现:

步骤一:创建一个类或者函数

正如我们在上面所理解的那样,Plugin需要实现特定的功能,因此它可能是一个函数或者类。这里我们使用类来创建Plugin。

class MyPlugin{
    // 插件内容
}

步骤二:在类身上实现一个apply方法

Plugin比较奇特的一点是必须创建一个apply方法,Plugin插件的核心实现都在这个apply方法身上。我们可以从源码中去查看为什么一定要实现一个apply方法。我们可以看下webpack中源码中Plugin部分,如下所示:

if (Array.isArray(plugins)) {
    for (const plugin of plugins) {
        plugin.apply(childCompiler);   // 调用apply方法
    }
}

我们可以看到,如果plugins是一个数组(这就是为什么我们在webpack.config.js中需要定义plugins成一个数组),然后会遍历这个数组,对数组的每个元素,也就是每个插件,调用它的apply方法并传入compiler对象。这就是为什么所有编写的插件都必须有一个apply方法,而且传入了compiler对象作为apply方法的参数。因此,我们也需要定义一个apply方法。

class MyPlugin{
    apply(compiler){
        // 功能实现
    }
}

步骤三:注入一个事件钩子

我们反复提到Plugin需要在webpack编译的某个阶段进行功能实现,因此需要注入一个钩子,用于在特定阶段进行监听。这里的所有阶段都可以通过applycompiler参数获取到。

class MyPlugin{
    apply(compiler){
        console.log(Object.keys(compiler.hooks));
    }
}

通过打印compiler.hooks,我们可以知道我们能够在哪些阶段注入钩子。

 [ 
  'initialize',
  'shouldEmit',
  'done',
  'afterDone',
  'additionalPass',
  'beforeRun',
  'run',
  'emit',
  'assetEmitted',
  'afterEmit',
  'thisCompilation',
  'compilation',
  'normalModuleFactory',
  'contextModuleFactory',
  'beforeCompile',
  'compile',
  'make',
  'finishMake',
  'afterCompile',
  'watchRun',
  'failed',
  'invalid',
  'watchClose',
  'infrastructureLog',
  'environment',
  'afterEnvironment',
  'afterPlugins',
  'afterResolvers',
  'entryOption' 
]

因此,apply的方法实现大致应该是这样,这里我们以done阶段为例:

class MyPlugin{
    apply(compiler){
        compiler.hooks.done.tap("xxx",(stat) => {
        })
    }
}

其中每个阶段可能是同步的,也可能是异步的,都可以通过打印compiler.hooks.钩子名称来获取到,如果是同步的,那么只有一个参数stat,如果是异步的,那么除了有一个参数compilation之外,还应该有一个回调函数callback。也就是说:

同步Plugin的apply方法大致是这样:

class MyPlugin{
    apply(compiler){
        compiler.hooks.done.tap("xxx",(stat) => {
            console.log("stat:",stat)
        })
    }
}

异步Plugin的apply方法大致是这样:

class MyPlugin{
    apply(compiler){
        compiler.hooks.emit.tapAsync("xxx",(compilation,callback) => {
            // 其他实现:
            callback();
        })
    }
}

**好了,其实到目前为止,我们已经实现了一个最简单的Plugin。**虽然这个Plugin并没有什么功能,因为我们并没有去实现第四步处理webpack内部实例身上的特定数据。但是我们基本上已经知道了如何去创建最简单的Plugin。至于第四步需要使用实际的例子来进行阐述。因此,看下一部分。

实现一个简单的Plugin之获取文件列表

在上面的介绍中,我们已经能够实现最简单的插件了,但是我们并没有实现特定的功能,因此也就没有去操作webpack内部实例身上的特定数据,但是在实际开发插件过程中,我们要实现特定功能,肯定需要去操作数据,那么如何去操作数据了,这也是官方的第四步。我们以webpack官网的例子为例,获取打包后的文件列表,并将其写入一个README文档中。

class MyFileListPlugin{
    constructor({filename}){
        this.filename = filename;
    }
    apply(compiler){
        compiler.hooks.emit.tapAsync("MyFileListPlugin",(compilation,callback) => {
           const assets = compilation.assets;    // 看这里,看这里
           let content = `## 文件名    资源大小`;
           Object.entries(assets).forEach(([filename,statObj]) => {
             content += `\n ${filename}    ${statObj.size()}`;
           })
           assets[this.filename] = {            // 看这里,看这里
               source:() => {
                 return content;
               },
               size(){
                 return content.length;
               }
           }
           callback();
        })
    }
}

在之前,我们介绍过,在apply方法定义时,注册事件有一个回调函数,回到函数的参数是compilation:

class MyPlugin{
    apply(compiler){
        compiler.hooks.emit.tapAsync("xxx",(compilation,callback) => {
            // 其他实现:
            console.log("compilation:",compilation)
            callback();
        })
    }
}

这个compilation就是我们可以操作的数据,它能够获取到这个阶段最常见的数据。其中最常用的就是compilation.assets。看过我之前的文章实现一个简易的模块打包器的同学应该知道,assets实际上就是打包后的文件,它的组成一般是一个key:value。key值是路径,value值是每一个模块的内容。这里的compilation.assets下的每一个模块也是差不多:只不过它是两个函数source:文件内容和size:文件大写。

assets[xxxx] = {
    source:() => {
        return content;
    },
    size(){
        return content.length;
    }
}

我们经常会根据xxx路径去获取到对象的source即文件内容,然后进行操作。以上面的获取文件列表为例,其核心功能实现如下:

           const assets = compilation.assets;    // 看这里,看这里
           let content = `## 文件名    资源大小`;
           Object.entries(assets).forEach(([filename,statObj]) => {
             content += `\n ${filename}    ${statObj.size()}`;
           })
           assets[this.filename] = {            // 看这里,看这里
               source:() => {
                 return content;
               },
               size(){
                 return content.length;
               }
           }

实际上就是:

  1. 通过compilaiton.assets获取到所有的文件的内容和大小
  2. 创建一个新的文件assets[this.filename],这个文件也包括source和size。其中source就是要写入的文件内容,size就是文件的尺寸。

我们可以发现:其实我们的所有操作都是围绕compilation.assets,我们在实际的开发过程中其实也是这样,就是去操作compilation.assets,当然如果有更多复杂的功能,那么可能需要操作compilation下面的更多数据,比如pathoutput等。

实现一个复杂的Plugin之内联Plugin

在上面的例子中,我们实现了一个简单的获取文件列表的Plugin,但是这个Plugin比较简单基本上不涉及到较多的操作,但是实际开发中我们经常可能需要做一些复杂的操作,这里有一条希望大家记住:大部分的插件涉及到的功能,其他开发者基本上已经实现了,因此我们更多的还是去找相关的插件,而不是自己开发;如果没有找到完全符合的,而必须自己开发的,那么你通常需要找功能最相关的插件,然后在他们的功能基础上进行实现,也就是说复杂插件的实现,通常是需要使用其他的插件的或者其他npm包。

这里我们以实现一个内联Plugin为例:它的功能就是将link中css的内容,从href引入变成style引入,将script中的js内容,也从src变成直接写入script标签。这个功能的实现我们首先想到:肯定需要操作index.html中的link标签script标签。我们都知道index.html这个模板文件通常都是通过html-webpack-plugin这个插件生成的,那么我们能否在这个插件的功能基础上进行实现,我们查看它的官网,可以发现,它提供了各种各样的钩子,可以帮助我们进行操作。也就是说我们能够在实现我们自己的钩子的过程中,调用它的钩子来实现功能。实现的详细过程就不进行描述了,最终的代码如下:

const HtmlWebpackPlugin = require('html-webpack-plugin');
class MyInlineSourcePlugin {
    constructor({ match}){
        this.match = match;
    }
    processTags(data,compilation){
      let headTags = [];
      let bodyTags = [];
      data.headTags.forEach((headTag) => {
          headTags.push(this.processTag(headTag, compilation))
      });
      data.bodyTags.forEach((bodyTag) => {
          bodyTags.push(this.processTag(bodyTag, compilation))
      });
      return {
          ...data,
          headTags,
          bodyTags
      }
    }
    
    //处理每个link和script标签。
    processTag(tag,compilation){
      let newTag ,url;
      if (tag.tagName === "link" && this.match.test(tag.attributes.href)){
        newTag = {
            tagName:"style",
            attributes:{
                type:"text/css"
            }
        }
        url = tag.attributes.href;
      }
      if (tag.tagName === "script" && this.match.test(tag.attributes.src)){
        newTag = {
            tagName: "script",
            attributes: {
                type: "application/javascript"
            }
        }
        url = tag.attributes.src;
      };
      if(url){
          // 通过compilation.assets[文件地址]可以获取到每个文件的内容。
          newTag.innerHTML = compilation.assets[url].source();
          delete compilation.assets[url];  // 删除原来的资源,不让生成文件
          return newTag;
      }
      return tag;
    }
    
    apply(compiler) {
        console.log("compiler:",compiler.hooks)
        // 要通过webpack-plugin来实现这个功能
        compiler.hooks.compilation.tap("MyInlineSourcePlugin", (compilation) => {
           // 看这里看这里,调用html-webpack-plugin的内部的钩子alterAssetTagGroups。
           HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
            'MyInlineSourcePlugin', (data, callback) => {
                    data = this.processTags(data,compilation);
                    callback(null, data)
                }
            )
        })
    }
}

总结

到目前为止,这篇文章我们介绍了:

  1. 什么是Plugin?Plugin就是在webpack编译的某个阶段专注于实现某个功能。因此需要牢记常见的阶段以及实现你想要的功能。
  2. 如何实现一个Plugin。从零开始,一步一步地教你如何实现一个Plugin
  3. 实现了一个简单的Plugin。带你初步了解如何去操作webpack的数据。主要是compilation.assets
  4. 实现了一个复杂的Plugin。带你去实现一个复杂的Plugin。对于复杂的Plugin,我们通常会借助其他的一些已有的Plugin,或者借助一些已有的npm包,主要还是借助这些已有的功能进行实现。

通过这篇文章,相信你再也不会因为对Plugin不熟悉,而感到恐惧了。所有的难都只是因为不了解,当你真正去了解一个东西时,你就会发现一切没有你想象中那么难。

本文的代码可以在webpack/plugin中进行查看,欢迎star。

参考文献

write a plugin