Написание собственного плагина

Система плагинов Webpack построена вокруг событийной модели и механизма хуков, реализованного через библиотеку Tapable. В основе лежит идея: весь процесс сборки разбит на этапы, а каждый этап предоставляет точки расширения, через которые можно вмешиваться в поведение компилятора.

Ключевые сущности:

Compiler — глобальный объект сборщика, создаётся один раз при запуске Webpack. Управляет всем процессом сборки.

Compilation — объект конкретной сборки. Создаётся при каждой новой компиляции (например, при watch-режиме при изменениях файлов).

Tapable hooks — система событий, включающая sync, async, waterfall и другие типы хуков.

Плагин в Webpack — это класс или функция с методом apply, который получает экземпляр compiler.


Минимальная структура плагина

Любой плагин начинается с реализации метода apply:

class MyPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('Сборка завершена');
    });
  }
}

module.exports = MyPlugin;

Суть конструкции:

  • apply(compiler) вызывается Webpack при инициализации
  • tap регистрирует синхронный обработчик события
  • 'MyPlugin' — имя плагина для отладки

Основные хуки Compiler

На уровне компилятора доступны ключевые точки:

  • environment — окружение настроено
  • compile — начинается новая компиляция
  • make — построение графа модулей
  • emit — перед записью ассетов на диск
  • done — завершение сборки

Пример подключения:

class LogCompilePlugin {
  apply(compiler) {
    compiler.hooks.compile.tap('LogCompilePlugin', () => {
      console.log('Начало компиляции');
    });
  }
}

Работа с объектом Compilation

compilation предоставляет доступ к результатам сборки: модулям, чанкам, ассетам.

class ModulesLoggerPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('ModulesLoggerPlugin', (compilation) => {
      compilation.hooks.finishModules.tap('ModulesLoggerPlugin', (modules) => {
        console.log('Количество модулей:', modules.size);
      });
    });
  }
}

Ключевые возможности:

  • доступ к графу модулей
  • изменение ассетов
  • добавление новых файлов
  • анализ зависимостей

Добавление и модификация ассетов

Одно из основных применений плагинов — генерация файлов.

Добавление нового файла

class FilePlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('FilePlugin', (compilation) => {
      const content = 'console.log("Generated file");';

      compilation.assets['generated.js'] = {
        source: () => content,
        size: () => content.length
      };
    });
  }
}

В момент emit все ассеты уже сформированы, но их можно изменить или добавить новые.


Изменение существующих ассетов

class ModifyBundlePlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('ModifyBundlePlugin', (compilation) => {
      Object.keys(compilation.assets).forEach((filename) => {
        if (filename.endsWith('.js')) {
          const originalSource = compilation.assets[filename].source();
          const wrapped = `/* patched */\n${originalSource}`;

          compilation.assets[filename] = {
            source: () => wrapped,
            size: () => wrapped.length
          };
        }
      });
    });
  }
}

Такой подход применяется для минификации, вставки баннеров, аналитики.


Использование Banner-подобной логики

Типичный пример — добавление заголовка:

class BannerPlugin {
  constructor(options) {
    this.banner = options.banner;
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('BannerPlugin', (compilation) => {
      compilation.hooks.processAssets.tap(
        {
          name: 'BannerPlugin',
          stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS
        },
        (assets) => {
          for (const filename in assets) {
            if (filename.endsWith('.js')) {
              const asset = compilation.assets[filename];
              const content = `${this.banner}\n${asset.source()}`;

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

Асинхронные хуки

Некоторые хуки требуют асинхронного выполнения.

class AsyncExamplePlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('AsyncExamplePlugin', (compilation, callback) => {
      setTimeout(() => {
        console.log('Асинхронная операция завершена');
        callback();
      }, 1000);
    });
  }
}

Также существует Promise-версия:

compiler.hooks.emit.tapPromise('AsyncExamplePlugin', async (compilation) => {
  await new Promise((resolve) => setTimeout(resolve, 500));
});

Работа с зависимостями модулей

Плагины могут вмешиваться в граф зависимостей:

compiler.hooks.compilation.tap('DependencyPlugin', (compilation) => {
  compilation.hooks.buildModule.tap('DependencyPlugin', (module) => {
    console.log('Сборка модуля:', module.resource);
  });
});

Возможности:

  • анализ импортов
  • добавление виртуальных зависимостей
  • переопределение модулей

Создание виртуальных файлов

Можно генерировать модули без реальных файлов:

class VirtualModulePlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('VirtualModulePlugin', (compilation) => {
      compilation.hooks.additionalAssets.tap('VirtualModulePlugin', () => {
        compilation.emitAsset(
          'virtual.js',
          {
            source: () => 'export const a = 42;',
            size: () => 20
          }
        );
      });
    });
  }
}

Использование Hook Stages

Webpack 5 ввёл стадии выполнения хуков:

  • PROCESS_ASSETS_STAGE_ADDITIONS
  • PROCESS_ASSETS_STAGE_OPTIMIZE
  • PROCESS_ASSETS_STAGE_SUMMARIZE

Пример:

compilation.hooks.processAssets.tap(
  {
    name: 'StagePlugin',
    stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE
  },
  () => {
    console.log('Оптимизация ассетов');
  }
);

Это позволяет точно управлять порядком выполнения логики.


Работа с контекстом сборки

Compiler содержит важные параметры:

apply(compiler) {
  console.log(compiler.options.mode);
  console.log(compiler.context);
}

Использование:

  • адаптация поведения под development/production
  • выбор стратегии оптимизации
  • условная логика плагина

Ошибки и диагностика

Плагин может влиять на ошибки сборки:

compilation.errors.push(new Error('Кастомная ошибка'));

Также можно отслеживать предупреждения:

compilation.warnings.push(new Error('Предупреждение'));

Паттерны проектирования плагинов

Плагин с конфигурацией

class ConfigurablePlugin {
  constructor(options = {}) {
    this.prefix = options.prefix || '';
  }

  apply(compiler) {
    compiler.hooks.emit.tap('ConfigurablePlugin', (compilation) => {
      console.log(this.prefix + 'emit phase');
    });
  }
}

Композиция нескольких хуков

class MultiHookPlugin {
  apply(compiler) {
    compiler.hooks.compile.tap('MultiHookPlugin', () => {
      console.log('compile');
    });

    compiler.hooks.emit.tap('MultiHookPlugin', () => {
      console.log('emit');
    });

    compiler.hooks.done.tap('MultiHookPlugin', () => {
      console.log('done');
    });
  }
}

Асинхронные цепочки и контроль завершения

compiler.hooks.make.tapAsync('ChainPlugin', (compilation, callback) => {
  doAsyncTask()
    .then(() => callback())
    .catch((err) => callback(err));
});

Ошибки, переданные в callback, прерывают сборку.


Тестирование плагинов

Плагины тестируются через запуск compiler вручную:

const webpack = require('webpack');
const config = require('./webpack.config');

const compiler = webpack(config);

compiler.run((err, stats) => {
  console.log(stats.toString());
});

Подходы:

  • проверка ассетов
  • проверка логов
  • snapshot тестирование результата сборки

Взаимодействие с stats объектом

compiler.hooks.done.tap('StatsPlugin', (stats) => {
  console.log(stats.toJson().modules.length);
});

Stats содержит:

  • модули
  • чанки
  • размеры
  • ошибки и предупреждения

Порядок выполнения плагинов

Плагины выполняются в порядке регистрации, но порядок внутри хуков может зависеть от:

  • типа hook (sync/async/waterfall)
  • stage (в processAssets)
  • приоритета tapable

Расширенные возможности

Переопределение модулей

compilation.hooks.normalModuleLoader.tap('OverridePlugin', (loaderContext, module) => {
  if (module.resource.includes('special')) {
    loaderContext._source = 'console.log("replaced")';
  }
});

Интеграция с кешированием

Плагины могут участвовать в кешировании через compilation cache:

compilation.cache.set('key', 'value');

Поток выполнения сборки и точки вмешательства

Типичная цепочка:

  • initialization
  • run
  • compile
  • compilation creation
  • make
  • seal
  • emit
  • afterEmit
  • done

Каждый этап имеет собственные хуки, позволяя плагинам вмешиваться в процесс на разных уровнях абстракции.