返回首页

说说 Loader 和 Plugin 的区别?编写 Loader、Plugin 的思路?

问题解析

这道题是 Webpack 面试的核心考点,需要清晰地对比 Loader 和 Plugin 的定位执行时机功能范围,并能够说明如何编写自定义的 Loader 和 Plugin。

核心概念

Loader vs Plugin 对比

┌─────────────────────────────────────────────────────────────────────────┐
│                        Loader vs Plugin 对比                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Loader                              Plugin                             │
│   ┌─────────────────────────┐        ┌──────────────────────────────┐   │
│   │ 文件转换器               │        │ 扩展插件                      │   │
│   │ File Transformer         │        │ Extension Plugin             │   │
│   ├─────────────────────────┤        ├──────────────────────────────┤   │
│   │ • 处理单个文件           │        │ • 处理整个编译过程            │   │
│   │ • 输入 -> 转换 -> 输出   │        │ • 监听编译生命周期            │   │
│   │ • 链式调用               │        │ • 访问 Compiler/Compilation  │   │
│   ├─────────────────────────┤        ├──────────────────────────────┤   │
│   │ 执行时机:               │        │ 执行时机:                    │   │
│   │ 模块加载时               │        │ 编译各阶段                    │   │
│   ├─────────────────────────┤        ├──────────────────────────────┤   │
│   │ 典型用途:               │        │ 典型用途:                    │   │
│   │ • 转译 ES6+/TS           │        │ • 生成 HTML                  │   │
│   │ • 编译 CSS/SCSS          │        │ • 清理输出目录               │   │
│   │ • 处理图片资源           │        │ • 代码压缩                   │   │
│   └─────────────────────────┘        └──────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

详细解答

Loader 和 Plugin 的区别

对比维度 Loader Plugin
本质 文件转换函数 扩展插件类
作用 将非 JS 资源转换为模块 扩展 Webpack 功能
执行时机 模块加载时 编译生命周期各阶段
处理范围 单个文件 整个编译过程
配置方式 module.rules plugins 数组
链式调用 支持(从右到左) 不支持
访问 Compiler
典型代表 babel-loader、css-loader HtmlWebpackPlugin、CleanWebpackPlugin

执行流程对比

// Loader 执行流程(模块加载阶段)
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   源文件     │ -> │  Loader A   │ -> │  Loader B   │ -> │  Loader C   │
│  (SCSS)     │    │ (sass-loader)│    │ (css-loader)│    │(style-loader)│
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
                                                              │
                                                              ▼
                                                        ┌─────────────┐
                                                        │ JS 模块代码  │
                                                        └─────────────┘

// Plugin 执行流程(编译生命周期)
┌──────────────────────────────────────────────────────────────────────┐
│                        编译生命周期                                   │
├──────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  compile ──> make ──> buildModule ──> seal ──> emit ──> done         │
│    │          │           │            │        │        │           │
│    ▼          ▼           ▼            ▼        ▼        ▼           │
│ [Plugin]   [Plugin]    [Plugin]     [Plugin] [Plugin] [Plugin]       │
│                                                                       │
│  通过 compiler.hooks 在不同阶段执行                                   │
│                                                                       │
└──────────────────────────────────────────────────────────────────────┘

编写 Loader 的思路

Loader 基本结构

// 1. 创建 Loader 文件:loaders/reverse-loader.js

/**
 * Loader 是一个函数,接收源文件内容,返回转换后的内容
 * @param {string|Buffer} source - 源文件内容
 * @returns {string|Buffer} - 转换后的内容
 */
module.exports = function(source) {
  // 获取 Loader 选项
  const options = this.getOptions();

  // 执行转换逻辑
  const result = source.split('').reverse().join('');

  // 返回转换结果
  return result;
};

使用 this.callback

// 当需要返回 sourceMap 或额外信息时使用
module.exports = function(source) {
  const options = this.getOptions();

  // 执行转换
  const result = transform(source, options);

  // 生成 sourceMap(可选)
  const sourceMap = generateSourceMap(source, result);

  // 使用 callback 返回多个值
  // this.callback(err, content, sourceMap, meta)
  this.callback(null, result, sourceMap);

  // 如果使用 callback,不需要 return
};

异步 Loader

// 异步操作时使用 this.async()
module.exports = function(source) {
  // 声明异步
  const callback = this.async();

  // 异步操作
  someAsyncOperation(source, (err, result) => {
    if (err) {
      // 返回错误
      return callback(err);
    }
    // 返回成功结果
    callback(null, result);
  });
};

Loader 上下文(this)

module.exports = function(source) {
  // this 对象提供的常用属性和方法

  // 1. 获取选项
  const options = this.getOptions();

  // 2. 开启/关闭缓存
  this.cacheable(true);

  // 3. 添加文件依赖(文件变化时重新编译)
  this.addDependency('/path/to/file');

  // 4. 发出文件(输出额外文件)
  this.emitFile('output.txt', 'content');

  // 5. 解析路径
  const resolvedPath = this.resolve('/context', './file');

  // 6. 获取 Loader 索引
  const currentLoaderIndex = this.loaderIndex;

  // 7. 获取资源信息
  const resourcePath = this.resourcePath;
  const resourceQuery = this.resourceQuery;

  return source;
};

完整 Loader 示例

// loaders/markdown-loader.js
// 将 Markdown 转换为 HTML 模块

const marked = require('marked');

module.exports = function(source) {
  // 获取选项
  const options = this.getOptions() || {};

  // 开启缓存
  this.cacheable && this.cacheable();

  // Markdown 转 HTML
  const html = marked.parse(source, {
    gfm: true,
    ...options
  });

  // 返回 JS 模块代码
  // 这样导入时可以直接使用 HTML 字符串
  return `module.exports = ${JSON.stringify(html)};`;
};

// 使用
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.md$/,
        use: './loaders/markdown-loader.js'
      }
    ]
  }
};

// 代码中导入
import readmeHtml from './README.md';
console.log(readmeHtml);  // HTML 字符串

编写 Plugin 的思路

Plugin 基本结构

// 1. 创建 Plugin 文件:plugins/HelloPlugin.js

/**
 * Plugin 是一个类,包含 apply 方法
 */
class HelloPlugin {
  constructor(options = {}) {
    // 接收配置选项
    this.options = options;
    console.log('Plugin 实例化,选项:', options);
  }

  /**
   * apply 方法,Webpack 会调用此方法
   * @param {Compiler} compiler - 编译器对象
   */
  apply(compiler) {
    // 通过 compiler.hooks 注册事件监听
    compiler.hooks.done.tap('HelloPlugin', (stats) => {
      console.log('构建完成!');
    });
  }
}

module.exports = HelloPlugin;

常用钩子

class MyPlugin {
  apply(compiler) {
    // 1. 编译开始前
    compiler.hooks.beforeCompile.tap('MyPlugin', (params) => {
      console.log('编译即将开始');
    });

    // 2. 编译开始
    compiler.hooks.compile.tap('MyPlugin', (params) => {
      console.log('编译开始');
    });

    // 3. 从入口开始构建
    compiler.hooks.make.tapAsync('MyPlugin', (compilation, callback) => {
      console.log('开始构建模块');
      callback();
    });

    // 4. 封装阶段(生成 chunks)
    compiler.hooks.seal.tap('MyPlugin', () => {
      console.log('封装阶段');
    });

    // 5. 输出文件前(最常用)
    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      console.log('准备输出文件');
      console.log('输出文件列表:', Object.keys(compilation.assets));
      callback();
    });

    // 6. 输出文件后
    compiler.hooks.afterEmit.tapAsync('MyPlugin', (compilation, callback) => {
      console.log('文件输出完成');
      callback();
    });

    // 7. 构建完成
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成');
      console.log('构建信息:', stats.toString());
    });

    // 8. 构建失败
    compiler.hooks.failed.tap('MyPlugin', (error) => {
      console.log('构建失败:', error);
    });
  }
}

操作 Compilation

class FileSizePlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('FileSizePlugin', (compilation) => {
      // compilation 包含当前编译的所有信息

      console.log('\n文件大小统计:');
      console.log('==============');

      // 遍历所有输出资源
      for (const filename of Object.keys(compilation.assets)) {
        const asset = compilation.assets[filename];
        const size = asset.size();
        const sizeInKB = (size / 1024).toFixed(2);

        console.log(`${filename}: ${sizeInKB} KB`);
      }
    });
  }
}

添加新文件到输出

class GenerateAssetPlugin {
  constructor(options = {}) {
    this.filename = options.filename || 'asset.json';
    this.content = options.content || '{}';
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('GenerateAssetPlugin', (compilation) => {
      // 添加额外资产到 compilation
      compilation.hooks.additionalAssets.tapAsync('GenerateAssetPlugin', (callback) => {
        // 创建新文件
        const content = JSON.stringify({
          timestamp: new Date().toISOString(),
          version: '1.0.0'
        }, null, 2);

        // 添加到 assets
        compilation.assets[this.filename] = {
          source() {
            return content;
          },
          size() {
            return content.length;
          }
        };

        callback();
      });
    });
  }
}

完整 Plugin 示例

// plugins/BuildInfoPlugin.js
// 生成构建信息文件

const path = require('path');

class BuildInfoPlugin {
  constructor(options = {}) {
    this.options = {
      filename: 'build-info.json',
      ...options
    };
  }

  apply(compiler) {
    const { filename } = this.options;

    compiler.hooks.emit.tapAsync('BuildInfoPlugin', (compilation, callback) => {
      // 收集构建信息
      const info = {
        // 构建时间
        buildTime: new Date().toISOString(),

        // Webpack 版本
        webpackVersion: require('webpack/package.json').version,

        // 构建模式
        mode: compiler.options.mode || 'none',

        // 入口配置
        entry: compiler.options.entry,

        // 输出文件列表
        outputFiles: Object.keys(compilation.assets).map(name => ({
          name,
          size: compilation.assets[name].size()
        })),

        // 模块数量
        moduleCount: compilation.modules.size,

        // Chunk 数量
        chunkCount: compilation.chunks.size
      };

      // 转换为 JSON
      const content = JSON.stringify(info, null, 2);

      // 添加到输出
      compilation.assets[filename] = {
        source() {
          return content;
        },
        size() {
          return content.length;
        }
      };

      console.log(`\n✓ 构建信息已生成: ${filename}`);

      callback();
    });
  }
}

module.exports = BuildInfoPlugin;

// 使用
// webpack.config.js
const BuildInfoPlugin = require('./plugins/BuildInfoPlugin');

module.exports = {
  plugins: [
    new BuildInfoPlugin({
      filename: 'build-info.json'
    })
  ]
};

深入理解

Loader 的 Pitch 阶段

// Loader 有两个执行阶段:pitch 和 normal

module.exports = function(source) {
  // normal 阶段:处理源代码
  console.log('Normal 阶段');
  return source;
};

module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  // pitch 阶段:在 normal 之前执行
  console.log('Pitch 阶段');
  console.log('剩余请求:', remainingRequest);
  console.log('前置请求:', precedingRequest);

  // 可以在这里拦截,返回非 undefined 会短路
  // return `module.exports = require(${JSON.stringify('-!' + remainingRequest)});`;
};

// 执行顺序(use: ['a-loader', 'b-loader', 'c-loader']):
// a.pitch -> b.pitch -> c.pitch -> c -> b -> a

Tapable 钩子类型

const {
  SyncHook,           // 同步串行
  SyncBailHook,       // 同步串行,返回非 undefined 会停止
  SyncWaterfallHook,  // 同步串行,参数会传递给下一个
  SyncLoopHook,       // 同步循环,直到返回 undefined
  AsyncParallelHook,  // 异步并行
  AsyncSeriesHook,    // 异步串行
  AsyncSeriesBailHook,// 异步串行,返回非 undefined 会停止
  AsyncSeriesWaterfallHook // 异步串行,参数传递
} = require('tapable');

// 使用示例
const hook = new AsyncSeriesHook(['arg1', 'arg2']);

hook.tapAsync('Plugin1', (arg1, arg2, callback) => {
  setTimeout(() => {
    console.log('Plugin1:', arg1, arg2);
    callback();
  }, 100);
});

hook.tapPromise('Plugin2', (arg1, arg2) => {
  return new Promise((resolve) => {
    console.log('Plugin2:', arg1, arg2);
    resolve();
  });
});

hook.callAsync('hello', 'world', () => {
  console.log('完成');
});

最佳实践

1. Loader 开发最佳实践

// loaders/best-practice-loader.js
const { validate } = require('schema-utils');

// 定义选项 schema
const schema = {
  type: 'object',
  properties: {
    option1: { type: 'boolean' },
    option2: { type: 'string' }
  }
};

module.exports = function(source) {
  // 1. 声明异步(如果有异步操作)
  const callback = this.async();

  // 2. 验证选项
  const options = this.getOptions();
  validate(schema, options, { name: 'BestPractice Loader' });

  // 3. 开启缓存
  this.cacheable(true);

  // 4. 处理逻辑
  const result = doTransform(source, options);

  // 5. 返回结果
  callback(null, result);
};

2. Plugin 开发最佳实践

class BestPracticePlugin {
  constructor(options = {}) {
    // 1. 验证选项
    if (typeof options !== 'object') {
      throw new Error('Options must be an object');
    }
    this.options = options;
  }

  apply(compiler) {
    const pluginName = 'BestPracticePlugin';

    // 2. 使用正确的插件名
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      try {
        // 3. 处理逻辑
        this.handleEmit(compilation);

        // 4. 正确调用 callback
        callback();
      } catch (error) {
        // 5. 错误处理
        callback(error);
      }
    });
  }

  handleEmit(compilation) {
    // 具体逻辑
  }
}

面试要点

回答思路

  1. 区别对比

    • Loader:文件转换器,处理单个文件,模块加载时执行
    • Plugin:扩展插件,处理整个编译过程,生命周期各阶段执行
  2. 编写 Loader

    • 导出函数,接收 source
    • 使用 this.getOptions() 获取配置
    • 使用 this.callback 返回 sourceMap
    • 使用 this.async() 处理异步
  3. 编写 Plugin

    • 创建类,定义 apply 方法
    • 通过 compiler.hooks 注册事件
    • 在回调中操作 compilation.assets

常见追问

Q: 什么时候用 Loader,什么时候用 Plugin?

A: 如果只是文件类型转换(如 SCSS 转 CSS、ES6 转 ES5),用 Loader;如果需要操作构建过程(如生成 HTML、清理目录、压缩代码),用 Plugin。

Q: Loader 为什么从右到左执行?

A: 这是 compose 函数组合的设计,数据从右向左流动,符合管道思想。也可以用 enforce: 'pre'/'post' 控制顺序。

Q: Plugin 可以访问 Loader 处理后的内容吗?

A: 可以。在 emit 阶段,compilation.assets 包含了所有处理后的最终内容。

Q: 如何调试自定义 Loader/Plugin?

A: 可以使用 console.log 输出信息,或者使用 Node.js 的调试工具。Loader 可以用 pitch 阶段调试执行顺序。

一句话总结

Loader 是文件转换器,运行在模块加载时,处理单个文件;Plugin 是扩展插件,通过 Tapable 钩子监听编译生命周期,处理整个构建过程。编写 Loader 需要导出转换函数,编写 Plugin 需要定义包含 apply 方法的类。