跳到正文
前端知识库
工程化

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

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

约 6 分钟

说说 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 方法的类。