Loading...

深入理解 Webpack Loader

2025-06-16 · 10min

前言

前面已经啃完了 Webpack 的核心概念了,现在准备深入了解下 loader。

loader 的本质

loader 核心概念 里以及讲过了,loader 的本质就是一个函数,接收一个字符串或者 buffer,返回一个 JS 模块的函数。

特性

  • 单一职责原则:一个 loader 只做一件事情。不要想着用一个 loader 就把所有事情处理完了。
  • 链式调用:多个 loader 串联时,会按倒序调用,输出结果会传给下一个 loader。
  • 同步/异步:loader 可以是同步的(直接返回转换结果),也可以是异步的(需要调用回调函数返回结果)。
  • 可接收配置:可以通过 Webpack 配置中的 options 对象向 loader 传参。
  • 无状态:在转换过程中,loader 不应该保留自身状态,每次转换都应该是独立的。

此外 Webpack 提供了 loader-utils 和 schema-utils 等工具包辅助开发。

loader 执行机制与生命周期

前面已经提到过了,loader 链式调用的执行顺序。倒序调用,上一个 loader 输出结果,会给下一个 loader 接收继续执行。

除此之外还有一些需要知道的点。

loader 函数签名

/**
 * @param {string|Buffer} content 源文件的内容
 * @param {object} [map] 可选的 SourceMap 数据(来自上一个 loader)
 * @param {any} [meta] 可选的元数据
 * @returns {string|Buffer|void} 转换后的内容。可以可选地返回 SourceMap 和元数据。
 */
module.exports = function(content, map, meta) {
  // 同步处理:直接返回转换结果
  // return someSyncTransform(content);

  // 异步处理:使用 this.async() 获取回调函数
  const callback = this.async();
  someAsyncTransform(content, (err, result, sourceMaps, newMeta) => {
    if (err) return callback(err);
    // 参数:错误, 转换后的内容, 可选的 SourceMap, 可选的元数据
    callback(null, result, sourceMaps, newMeta);
  });
};

this 上下文

函数签名例子中可以看到,Webpack 为 loader 提供了一个 this 上下文对象,具有以下属性和方法:

  • this.async:异步 loader 需要调用此函数,通知 Webpack 该 loader 是异步的。同时返回一个 callback 回调函数,异步 loader 需要通过 callback 返回结果或错误:
    // 参数:错误, 转换后的内容, 可选的 SourceMap, 可选的元数据
    callback(null, result, sourceMaps, newMeta);
    
  • this.callback:与 this.async() 返回的回调函数一样,用于返回结果或错误,如果使用 this.async(),就用它返回的 callback。
  • this.emitFile: 产生一个输出文件。
    this.emitFile(
      name: string, 
      content: Buffer | string, 
      sourceMap: SourceMap
    )
    
  • this.resourcePath:当前处理文件的绝对路径。
  • this.resourceQuery:当前处理文件的查询字符串(如 ?date=2025-06-16)。
  • this.rootContext:Webpack 配置中的 context 选项。
  • this.getOptions(schema?: JSONSchema):获取 loader 配置中 options 对象,结合 schema-utils 对选项进行校验。(Webpack 5 之前该方法需要从 loader-utils 库中导入)。
  • this.emitWarning(warning: Error):发出警告。
  • this.emitError(error: Error):发出错误。
  • this.loadModule(request: string, callback: function): 加载另一个模块(谨慎使用)。
  • this.addDependency(file: string): 添加一个文件作为依赖,使 Webpack 能够监听其变化并触发重新构建(如 less-loader 添加导入的 .less 文件)。
  • this.cacheable(flag: boolean = true): 标记 loader 的结果是否可缓存。默认 true 对性能很重要。
  • this.mode: Webpack 的运行模式 ('production' | 'development' | 'none')。

Pitch 阶段

Loader 执行顺序是倒序执行的,这个阶段也被称为 normal 阶段,但在此之前还存在一个顺序执行的 pitch 阶段,在实际处理文件内容之前发生。

方法签名:

module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  // remainingRequest: 当前 loader 之后还需要处理的 loader 和资源路径的字符串
  // precedingRequest: 当前 loader 之前已经处理过的 loader 的字符串
  // data: 可以在 pitch 和 normal 函数间共享的 data 对象
  // 如果返回非 undefined 值,会跳过后面 loader 的 pitch 和 normal 执行,以及文件读取,直接进入前一个 loader 的 normal 阶段!
};

用途

  • 提前中断:如果 pitch 方法返回了内容,则跳过包括它自己和后面(右边) loader 的 pitch 和 normal 方法,以及实际读取文件的操作,直接进入前一个(左边) loader 的 normal 阶段,并将 pitch 返回值作为该 loader 的 normal 函数输入。
  • 共享数据:通过 data 对象在 pitch 和 normal 两个函数之间共享数据。
  • 元数据处理/前置检查: 在真正处理文件前做一些操作。

场景举例

style-loader 的职责是将 CSS 动态插入 DOM,如果在 normal 阶段处理,style-loader 是无法直接获取 css-loader 的处理结果的,因为:

  • loader 输入是字符串或者 Buffer,输出的 JS 模块,是以字符串形式输出的 export default...,下一个 loader 接收的是这个字符串。
  • require() 会触发二次加载,导致 Webpack 重新解析依赖,造成循环或冗余处理。

通过 pitch 方法,可以按以下步骤解决问题:

  1. 在 pitch 阶段返回一段动态生成的 JS 代码,提前中断后续 loader 执行。
  2. 在生成的 JS 代码中,通过 require 显示指定后需要 loader 链处理目标 CSS 文件:
    module.exports.pitch = function (remainingRequest) {
      // 1. 生成内联请求路径(包含后续 loader)
      const request = `!!${remainingRequest}`; // 示例: "!!css-loader!./style.css"
    
      // 2. 返回动态 JS 代码
      return `
        var content = require(${JSON.stringify(request)});
        var style = document.createElement('style');
        style.innerHTML = content.toString();
        document.head.appendChild(style);
      `;
    };
    

    !! 语法表示跳过全局配置的 loader,仅使用内联指定的 loader,避免重复处理。
  3. 生成的代码在浏览器运行时执行,调用 require() 获取 css-loader 处理后的模块对象,而非字符串,从中提取 CSS 字符串插入 DOM 中。

自定义 Loader 开发

了解完了 Loader 的本质,执行机制与生命周期,其实就可以尝试开发一款自己的自定义 loader了。

正当我没什么灵感的时候突然想到,我一直很喜欢 Unocss 里读取指定目录下 *.svg 文件,自动转成生成图标 CSS 类的操作,这不正是 loader 核心职责吗!

开发

依赖安装

pnpm add -save -D style-loader css-loader

loader 开发

在根目录中新建文件 svg-icon-loader.js

const path = require("path");

// 定义 loader 选项的 JSON Schema
const schema = {
  type: "object",
  properties: {
    prefix: {
      type: "string",
      default: "icon-",
    },
  },
  additionalProperties: false,
};


/**
 * @description SVG 转 CSS 类
 * @param {string} content
 */
module.exports = function (content) {
  // 标记为可缓存
  this.cacheable();

  // 获取并验证选项
  const options = this.getOptions(schema);

  // 异步回调
  const callback = this.async();

  const { prefix = "icon-" } = options;

  // 1. 获取当前 SVG 文件路径
  const svgPath = this.resourcePath;

  // 2. 提取纯净的文件名(不含扩展名和路径)
  const fileName = path.basename(svgPath, ".svg");

  // 3. 生成 CSS 类名
  const className = `${prefix}${fileName}`;

  console.log(`transform svg: ${fileName}`);

  try {
    // 4. 优化 SVG 内容(可选)
    const optimizedSvg = content
      .replace(/\s+/g, " ") // 简化空格
      .replace(/#/g, "%23") // 转义 # 符号
      .replace(/"/g, "'"); // 统一引号

    // 5. 转换为 Base64 Data URL
    const base64 = Buffer.from(optimizedSvg).toString("base64");
    const dataUrl = `data:image/svg+xml;base64,${base64}`;

    // 6. 生成 CSS 规则
    // SVG 默认作为mask
    const cssMaskRule = `
      .${className} {
          mask-image: url("${dataUrl}");
          mask-repeat: no-repeat;
          mask-size: contain;
          display: inline-block;
          width: 1em;
          height: 1em;
          background-color: currentColor;
        }
    `;
    // SVG 作为背景图
    const cssBgRule = `
      .${className}?bg {
        background-image: url("${dataUrl}");
        background-repeat: no-repeat;
        background-size: contain;
        display: inline-block;
        width: 1em;
        height: 1em;
      }
    `;

    callback(null, `${cssMaskRule} ${cssBgRule}`);
  } catch (error) {
    callback(error);
  }
};

新建 src/icons 文件夹,里面放入几个 SVG 文件。

src/icons 目录

Webpack 配置

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

const svgIconLoader = path.resolve(__dirname, "svg-icon-loader");
const iconPath = path.resolve(__dirname, "src/icons"); // SVG目录

module.exports = {
  // ...其他配置
  module: {
    rules: [
      // ... 其他配置
      {
        test: /\.svg$/,
        include: [iconPath],
        use: [
          "style-loader", // 将 CSS 注入 DOM
          "css-loader", // 解析 CSS
          {
            loader: svgIconLoader,
            options: {
              prefix: "icon-",
            },
          },
        ],
      },
    ],
  },
};

使用

src/App.jsx

const App = () => {
  return (
    <div style={{ color: "black", fontSize: 36 }}>
      <div className="icon-sun"></div>
      <div className="icon-moon"></div>
      <div className="icon-user"></div>
    </div>
  );
};

export default App

执行 pnpm run start 发现没效果,因为 Webpack 默认只会处理在代码中显式引用的文件,如果要自动导入的话,就需要通过 plugin 实现。

后面学完 plugin 刚刚好好就自定义这个 plugin 吧!!!

这里先手动引入测试下,在 App.jsx 中添加:

import "./icons/moon.svg"
import "./icons/sun.svg"
import "./icons/user.svg"

运行效果:

svg-icon-loader 运行效果

总结

loader 的本质就是一个转换函数,掌握好其特性,遵循其开发规范的话,自定义 loader 还是挺简单的。