WebPack5-Loader-Plugin

44 阅读8分钟

WebPack5

Loader

loader 执行顺序

1.分类

  • pre: 前置 loader
  • normal: 普通 loader
  • inline: 内联 loader
  • post: 后置 loader

2.执行顺序

  • 4 类 loader 的执行优级为:pre > normal > inline > post
  • 相同优先级的 loader 执行顺序为:从右到左,从下到上

3.使用 loader 的方式

  • 配置方式:在 webpack.config.js 文件中指定 loader。(pre、normal、post loader)
  • 内联方式:在每个 import 语句中显式指定 loader。(inline loader)

4.inline loader

用法:import Styles from 'style-loader!css-loader?modules!./styles.css';

含义:

  • 使用 css-loaderstyle-loader 处理 styles.css 文件
  • 通过 ! 将资源中的 loader 分开

inline loader 可以通过添加不同前缀,跳过其他类型 loader。

  • ! 跳过 normal loader。
import Styles from '!style-loader!css-loader?modules!./styles.css';
  • -! 跳过 pre 和 normal loader。
import Styles from '-!style-loader!css-loader?modules!./styles.css';
  • !! 跳过 pre、 normal 和 post loader。
import Styles from '!!style-loader!css-loader?modules!./styles.css';

loader 接受的参数

  • content 源文件的内容
  • map SourceMap 数据
  • meta 数据,可以是任何内容

loader 分类

同步 loader

// 写法1
// module.exports = function (content){
//     return content
// }

// 写法2
module.exports = function (content, map, meta) {
  console.log("同步");
  /**
   * 第一个: error 是否有错误
   * 第二个: 内容
   * 第三个: sourceMap,继续往下传递 sourcemap
   * 第四个: 给下一个loader传递参数
   * 
   * 同步loader中不能执行异步loader
   */
  this.callback(null, content, map);
};

异步 loader

module.exports = function (content, map, meta) {
  const callback = this.async();
  setTimeout(() => {
    console.log("异步");
    callback(null, content, map, meta);
  }, 1000);
};

Raw Loade

默认情况下,资源文件会被转化为 UTF-8 字符串,然后传给 loader。通过设置 raw 为 true,loader 可以接收原始的 Buffer。

// row loader 接收到的数据content是buffer数据

// module.exports = function (content) {
//   console.log(content);
//   return content
// };

// module.exports.raw = true;

function loader(content) {
  console.log(content);
  return content;
}
loader.raw = true;
module.exports = loader;

Pitching Loader

webpack 会先从左到右执行 loader 链中的每个 loader 上的 pitch 方法(如果有),然后再从右到左执行 loader 链中的每个 loader 上的普通 loader 方法。

在这个过程中如果任何 pitch 有返回值,则 loader 链被阻断。webpack 会跳过后面所有的的 pitch 和 loader,直接进入上一个 loader 。

module.exports = function (content) {
  console.log("Pitching Loader0");
  return content;
};
module.exports.pitch = function () {
  console.log("Pitching Loader0 pitch");
};

loader API

方法名含义用法
this.async异步回调 loader。返回 this.callbackconst callback = this.async()
this.callback可以同步或者异步调用的并返回多个结果的函数this.callback(err, content, sourceMap?, meta?)
this.getOptions(schema)获取 loader 的 optionsthis.getOptions(schema)
this.emitFile产生一个文件this.emitFile(name, content, sourceMap)
this.utils.contextify返回一个相对路径this.utils.contextify(context, request)
this.utils.absolutify返回一个绝对路径this.utils.absolutify(context, request)

更多文档,请查阅 webpack 官方 loader api 文档

手写 Loader

clean-log-loader

module.exports = function (content) {
  // 清除内容中的console.log(xxx)语句
  return content.replace(/console\.log\(.*\):?/g, "");
};

banner-loader

作用:给 js 代码添加文本注释

// banner-loader/index.js
const schema = require("./schema");
module.exports = function (content) {
  // schema 对options的校验规则
  // schema 需要符合Json schema 的规则
  const options = this.getOptions(schema);

  const prefix = `
    /*
    * Author: ${options.author}
    */
    `;
  return prefix + content;
};


// banner-loader/schema.json
{
  "type": "object",
  "properties": {
    "author": {
      "type": "string"
    }
  },
  "additionalProperties": false
}

babel-loader

作用:编译 js 代码,将 ES6+语法编译成 ES5-语法。

npm i @babel/core @babel/preset-env -D

// babel-loader/index.js
const babel = require("@babel/core");
const schema = require("./schema.json");

// https://www.babeljs.cn/docs/babel-core

module.exports = function (content) {
  const callback = this.async();
  const options = this.getOptions(schema);
  //   使用babel对代码进行编译
  babel.transform(content, options, function (err, result) {
    if (err) {
      callback(err, null);
    } else {
      callback(null, result.code);
    }
  });
};


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

file-loader

作用:将文件原封不动输出出去

npm i loader-utils -D


// file-loader.js
const loaderUtils = require("loader-utils");
module.exports = function (content) {
  // 1. 根据文件内容生产带hash值文件名
  let interpolatedName = loaderUtils.interpolateName(
    this,
    "[hash].[ext][query]",
    {
      content,
    }
  );
  //   把图片都放在一个目录里面,也可以通过外面传进来
  interpolatedName = `images/${interpolatedName}`;
  // 2. 将文件输出出去
  this.emitFile(interpolatedName, content);

  // 3. 返回 module.exports = 文件路径(文件名)
  return `module.exports = "${interpolatedName}"`;
};
// 需要处理图片字体等文件,他们都是buffer数据
// 需要使用 row

module.exports.raw = true;


// loader 配置
{
  test: /\.(png|jpe?g|gif)$/,
  loader: "./loaders/file-loader.js",
  type: "javascript/auto", // 解决图片重复打包问题
},

style-loader

作用:动态创建 style 标签,插入 js 中的样式代码,使样式生效。

style-loader.js

module.exports = function (comtent) {
  /**
   * 1. 直接使用定义的 style-loader 只能处理样式问题,不能处理样式中引入的其他资源,
   * use: ["./loaders/style-loader"],
   *
   * 2. 需要借助css-loader解决样式中引入其他资源的问题
   * use: ["./loaders/style-loader", "css-loader"],
   * 问题是css-loader暴露的是js代码,style-loader需要执行js代码,得到返回值再动态创建style标签然后再插入到页面上(不好操作)
   *
   * 官方的style-loader是通过pitch-loader解决的
   */
  //   const script = `
  //         const styleEl = document.createElement('style')
  //         styleEl.innerHTML = ${JSON.stringify(comtent)}
  //         document.head.appendChild(styleEl)
  //     `;
  //   return script;
};

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  // remainingRequest 剩下还需要处理的loader
  console.log(remainingRequest); // D:\桌面\webpack\webpack\webpack-loader\node_modules\css-loader\dist\cjs.js!D:\桌面\webpack\webpack\webpack-loader\src\css\index.css

  // 1. 需要将remainingRequest中的绝对路径改为相对路径,后面只能使用相对路径来操作
  //   希望变成 ../../node_modules/css-loader/dist/cjs.js
  //   希望变成 ./index.css
  const relativePath = remainingRequest
    .split("!")
    .map((absolutePath) => {
      // 返回一个相对路径
      return this.utils.contextify(this.context, absolutePath);
    })
    .join("!");
  console.log(relativePath); // ../../node_modules/css-loader/dist/cjs.js!./index.css

  //   2. 引入css-loader处理后的资源
  //   3. 创建style将内容输入页面当中
  //   !! 终止剩下的loader执行
  const script = `
                import style from "!!${relativePath}"
                const styleEl = document.createElement('style')
                styleEl.innerHTML = style
                document.head.appendChild(styleEl)
                `;
  // 终止后面的loader执行
  return script;
};

Plugin

作用

通过插件我们可以扩展 webpack,加入自定义的构建行为,使 webpack 可以执行更广泛的任务,拥有更强的构建能力。

工作原理

站在代码逻辑的角度就是:webpack 在编译代码过程中,会触发一系列 Tapable 钩子事件,插件所做的,就是找到相应的钩子,往上面挂上自己的任务,也就是注册事件,这样,当 webpack 构建的时候,插件注册的事件就会随着钩子的触发而执行了。

Webpack 内部的钩子

钩子的本质就是:事件。为了方便我们直接介入和控制编译过程,webpack 把编译过程中触发的各类关键事件封装成事件接口暴露了出来。这些接口被很形象地称做:hooks(钩子)。开发插件,离不开这些钩子。

Tapable

Tapable 为 webpack 提供了统一的插件接口(钩子)类型定义,它是 webpack 的核心功能库。webpack 中目前有十种 hooks,在 Tapable 源码中可以看到,他们是:

// https://github.com/webpack/tapable/blob/master/lib/index.js
exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");

Tapable 还统一暴露了三个方法给插件,用于注入不同类型的自定义构建行为:

  • tap:可以注册同步钩子和异步钩子。
  • tapAsync:回调方式注册异步钩子。
  • tapPromise:Promise 方式注册异步钩子。

Plugin 构建对象

Compiler

compiler 对象中保存着完整的 Webpack 环境配置,每次启动 webpack 构建时它都是一个独一无二,仅仅会创建一次的对象。

这个对象会在首次启动 Webpack 时创建,我们可以通过 compiler 对象上访问到 Webapck 的主环境配置,比如 loader 、 plugin 等等配置信息。

它有以下主要属性:

  • compiler.options 可以访问本次启动 webpack 时候所有的配置文件,包括但不限于 loaders 、 entry 、 output 、 plugin 等等完整配置信息。
  • compiler.inputFileSystemcompiler.outputFileSystem 可以进行文件操作,相当于 Nodejs 中 fs。
  • compiler.hooks 可以注册 tapable 的不同种类 Hook,从而可以在 compiler 生命周期中植入不同的逻辑。

compiler hooks 文档open in new window

Compilation

compilation 对象代表一次资源的构建,compilation 实例能够访问所有的模块和它们的依赖。

一个 compilation 对象会对构建依赖图中所有模块,进行编译。 在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。

它有以下主要属性:

  • compilation.modules 可以访问所有模块,打包的每一个文件都是一个模块。
  • compilation.chunks chunk 即是多个 modules 组成而来的一个代码块。入口文件引入的资源组成一个 chunk,通过代码分割的模块又是另外的 chunk。
  • compilation.assets 可以访问本次打包生成所有文件的结果。
  • compilation.hooks 可以注册 tapable 的不同种类 Hook,用于在 compilation 编译模块阶段进行逻辑添加以及修改。

compilation hooks 文档

手写 Plugin

简单的插件

plugins/test-plugin.js

class TestPlugin {
  constructor() {
    console.log("TestPlugin constructor()");
  }
  // 1. webpack读取配置时,new TestPlugin() ,会执行插件 constructor 方法
  // 2. webpack创建 compiler 对象
  // 3. 遍历所有插件,调用插件的 apply 方法
  apply(compiler) {
    console.log("TestPlugin apply()");
  }
}

module.exports = TestPlugin;

注册 hook

class TestPlugin {
  constructor() {
    console.log("TestPlugin constructor()");
  }
  // 1. webpack读取配置时,new TestPlugin() ,会执行插件 constructor 方法
  // 2. webpack创建 compiler 对象
  // 3. 遍历所有插件,调用插件的 apply 方法
  apply(compiler) {
    console.log("TestPlugin apply()");

    // 从文档可知, compile hook 是 SyncHook, 也就是同步钩子, 只能用tap注册
    compiler.hooks.compile.tap("TestPlugin", (compilationParams) => {
      console.log("compiler.compile()");
    });

    // 从文档可知, make 是 AsyncParallelHook, 也就是异步并行钩子, 特点就是异步任务同时执行
    // 可以使用 tap、tapAsync、tapPromise 注册。
    // 如果使用tap注册的话,进行异步操作是不会等待异步操作执行完成的。
    compiler.hooks.make.tap("TestPlugin", (compilation) => {
      setTimeout(() => {
        console.log("compiler.make() 111");
      }, 2000);
    });

    // 使用tapAsync、tapPromise注册,进行异步操作会等异步操作做完再继续往下执行
    compiler.hooks.make.tapAsync("TestPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("compiler.make() 222");
        // 必须调用
        callback();
      }, 1000);
    });

    compiler.hooks.make.tapPromise("TestPlugin", (compilation) => {
      console.log("compiler.make() 333");
      // 必须返回promise
      return new Promise((resolve) => {
        resolve();
      });
    });

    // 从文档可知, emit 是 AsyncSeriesHook, 也就是异步串行钩子,特点就是异步任务顺序执行
    compiler.hooks.emit.tapAsync("TestPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("compiler.emit() 111");
        callback();
      }, 3000);
    });

    compiler.hooks.emit.tapAsync("TestPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("compiler.emit() 222");
        callback();
      }, 2000);
    });

    compiler.hooks.emit.tapAsync("TestPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("compiler.emit() 333");
        callback();
      }, 1000);
    });
  }
}

module.exports = TestPlugin;

启动调试

{
  "name": "source",
  "version": "1.0.0",
  "scripts": {
    "debug": "node --inspect-brk ./node_modules/webpack-cli/bin/cli.js"
  },
  "keywords": [],
  "author": "xiongjian",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.17.10",
    "@babel/preset-env": "^7.17.10",
    "css-loader": "^6.7.1",
    "loader-utils": "^3.2.0",
    "webpack": "^5.72.0",
    "webpack-cli": "^4.9.2"
  }
}

运行指令 npm run debug

此时控制台输出以下内容:

PS C:\Users\86176\Desktop\source> npm run debug

> source@1.0.0 debug
> node --inspect-brk ./node_modules/webpack-cli/bin/cli.js

Debugger listening on ws://127.0.0.1:9229/629ea097-7b52-4011-93a7-02f83c75c797
For help, see: https://nodejs.org/en/docs/inspecto
  1. 打开 Chrome 浏览器,F12 打开浏览器调试控制台。

此时控制台会显示一个绿色的图标

BannerWebpackPlugin

作用:给打包输出文件添加注释。

开发思路:

需要打包输出前添加注释:需要使用 compiler.hooks.emit 钩子, 它是打包输出前触发。 如何获取打包输出的资源?compilation.assets 可以获取所有即将输出的资源文件。

class BannerWebpackPlugin {
  constructor(options = {}) {
    this.options = options;
  }
  apply(compiler) {
    // 资源输出之前触发的钩子函数
    compiler.hooks.emit.tapAsync(
      "BannerWebpackPlugin",
      (compilation, callback) => {
        const extensions = ["js", "css"];
        // 1. 获取即将输出的资源 compilation.assets
        // 2. 只保留js和css资源
        const assets = Object.keys(compilation.assets).filter((assetsPath) => {
          //   ["xxx", ".js"]     或者      [("xxx", ".css")];
          const splited = assetsPath.split(".");
          //   获取最后文件扩展名
          const ext = splited[splited.length - 1];
          //   判断是否在范围内;
          return extensions.includes(ext);
        });
        console.log(assets);
        // 3. 遍历剩下的文件添加上注释
        const prefix = `
        /*
        * Author: ${this.options.author}
        */
        `;
        assets.forEach((asset) => {
          // 读取原来的内容
          const source = compilation.assets[asset].source();
          //   拼接起来内容
          const content = prefix + source;
          //   修改资源
          compilation.assets[asset] = {
            // 最终资源输出会调用source方法.这个方法的返回值就是资源的最终内容
            source() {
              return content;
            },
            // 资源大小
            size() {
              content.length;
            },
          };
          callback();
        });
      }
    );
  }
}
module.exports = BannerWebpackPlugin;

CleanWebpackPlugin

作用:在 webpack 打包输出前将上次打包内容清空。

开发思路:

如何在打包输出前执行?需要使用 compiler.hooks.emit 钩子, 它是打包输出前触发。 如何清空上次打包内容? 获取打包输出目录:通过 compiler 对象。 通过文件操作清空内容:通过 compiler.outputFileSystem 操作文件。

const { output } = require("../webpack.config");

class CleanWebpackPlugin {
  apply(compiler) {
    // 2. 获取打包的输出的目录
    const outputPath = compiler.options.output.path;
    const fs = compiler.outputFileSystem;
    // 1. 注册钩子,再打包之前 emit
    compiler.hooks.emit.tap("CleanWebpackPlugin", (compilation) => {
      // 3. fs删除目录下的文件
      this.removeFiles(fs, outputPath);
    });
  }
  removeFiles(fs, filePath) {
    // 想要删除目录,需要先将目录下的资源删除,才能删除目录
    // 1. 读取当前目录下的所以资源
    const files = fs.readdirSync(filePath);
    // console.log(files); // [ 'images', 'index.html', 'js' ]
    // 2. 遍历一个个删除
    files.forEach((file) => {
      // 2.1 遍历所有文件,判断是文件夹还是文件,
      const path = `${filePath}/${file}`;
      const fileStat = fs.statSync(path);
      // console.log(fileStat);
      if (fileStat.isDirectory()) {
        // if (fileStat.size === 0) {
        //   // 2.2 如果是文件夹,并且文件夹是空的,就直接删除
        //   fs.rmdirSync(path);
        // } else {
        // 2.2 是文件夹就删除下面所以文件才能删除文件夹
        this.removeFiles(fs, path);
        // }
      } else {
        // 2.3 文件可以直接删除
        fs.unlinkSync(path);
      }
    });
  }
}

module.exports = CleanWebpackPlugin;

AnalyzeWebpackPlugin

作用:分析 webpack 打包资源大小,并输出分析文件。

开发思路: 在哪做? compiler.hooks.emit, 它是在打包输出前触发,我们需要分析资源大小同时添加上分析后的 md 文件。

class AnalyzeWebpackPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap("AnalyzeWebpackPlugin", (compilation) => {
      // 1. 遍历所以即将输出的文件得到其大小
      // {a:1,b:2} --> [[a,1],[b,2]]
      const assets = Object.entries(compilation.assets);
      //   md中表格语法
      /**
       * |名称|大小|
       * |xx|10|
       * |YY|20|
       */
      let content = `| 资源名称 | 资源大小 |
      | --- | --- |`;
      assets.forEach(([fileName, file]) => {
        content += `\n| ${fileName} | ${Math.ceil(file.size() / 1024)} kb |`;
      });
      // 2. 生成一个md文件
      compilation.assets["analyze.md"] = {
        source: () => content,
        size: () => content.length,
      };
    });
  }
}
module.exports = AnalyzeWebpackPlugin;

InlineChunkWebpackPlugin

  1. 作用:webpack 打包生成的 runtime 文件太小了,额外发送请求性能不好,所以需要将其内联到 js 中,从而减少请求数量。
  2. 开发思路:
  • 我们需要借助 html-webpack-plugin

    来实现

    • html-webpack-plugin 输出 index.html 前将内联 runtime 注入进去
    • 删除多余的 runtime 文件
  • 如何操作 html-webpack-plugin官方文档

const HtmlWebpackPlugin = require("safe-require")("html-webpack-plugin");

class InlineChunkWebpackPlugin {
  constructor(tests) {
    this.tests = tests;
  }
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "InlineChunkWebpackPlugin",
      (compilation) => {
        const hooks = HtmlWebpackPlugin.getHooks(compilation);

        // 1. 获取html-webpack-plugin 的 hooks
        // 2. 注册 html-webpack-plugin hooks --> alterAssetTagGroups
        // 3. 从里面将 script 标签里面的 runtime 文件编程 inline-script
        hooks.alterAssetTagGroups.tap(
          "InlineChunkWebpackPlugin", // <-- Set a meaningful name here for stacktraces
          (assets) => {
            assets.headTags = this.getInlineChunk(
              assets.headTags,
              compilation.assets
            );
            assets.bodyTags = this.getInlineChunk(
              assets.bodyTags,
              compilation.assets
            );
          }
        );

        // 删除runtime文件
        hooks.afterEmit.tap("InlineChunkWebpackPlugin", () => {
          Object.keys(compilation.assets).forEach((filepath) => {
            if (this.tests.some((test) => test.test(filepath))) {
              delete compilation.assets[filepath];
            }
          });
        });
      }
    );
  }
  getInlineChunk(tags, assets) {
    // 目前
    //   {
    //     tagName: 'script',
    //     voidTag: false,
    //     meta: { plugin: 'html-webpack-plugin' },
    //     attributes: { defer: true, src: 'js/runtime~main.js.js' }
    //   },
    // 修改为
    //   {
    //     tagName: 'script',
    //     innerHTML: runtime文件的内容
    //     closeTag: true
    //   },
    return tags.map((tag) => {
      if (tag.tagName !== "script") return tag;
      // 获取文件资源路径
      const filepath = tag.attributes.src;
      if (!filepath) return tag;
      if (!this.tests.some((test) => test.test(filepath))) return tag;
      return {
        tagName: "script",
        innerHTML: assets[filepath].source(),
        closeTag: true,
      };
    });
  }
}

module.exports = InlineChunkWebpackPlugin;