Написание собственного загрузчика

Загрузчик в Webpack представляет собой обычную функцию, которая получает исходный контент модуля и возвращает преобразованную версию этого контента. Несмотря на простоту концепции, загрузчики образуют один из ключевых механизмов расширения системы сборки, позволяя интерпретировать любые типы файлов как модули JavaScript.

Каждый загрузчик выполняется в контексте сборки и получает на вход строку или бинарные данные исходного модуля. На выходе он обязан вернуть результат преобразования, который может быть либо строкой JavaScript-кода, либо передан асинхронно через callback.

Сигнатура загрузчика выглядит следующим образом:

module.exports = function (source) {
  return source;
};

Где source — содержимое файла после применения предыдущих загрузчиков в цепочке.

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

Контекст выполнения loader

Функция загрузчика не является изолированной. Webpack предоставляет специальный контекст через this, который содержит информацию о текущем модуле и инструменты управления процессом трансформации.

Основные свойства контекста:

  • this.resource — путь к текущему файлу
  • this.context — директория модуля
  • this.query — параметры загрузчика (устаревший формат)
  • this.getOptions() — получение опций загрузчика (Webpack 5)
  • this.emitFile() — генерация дополнительных файлов
  • this.addDependency() — добавление зависимостей
  • this.cacheable() — управление кешированием результата

Пример использования:

module.exports = function (source) {
  const resourcePath = this.resource;
  this.addDependency(resourcePath);
  return source;
};

Синхронные и асинхронные загрузчики

Синхронный вариант

Синхронный загрузчик возвращает результат напрямую:

module.exports = function (source) {
  return source.replace('foo', 'bar');
};

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

Асинхронный вариант

Если трансформация требует асинхронных операций (например, чтение файлов или запросы), используется this.async():

module.exports = function (source) {
  const callback = this.async();

  setTimeout(() => {
    const result = source.replace('foo', 'bar');
    callback(null, result);
  }, 100);
};

Функция callback принимает два аргумента:

  • error — ошибка, если она возникла
  • result — преобразованный код

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

Загрузчики выполняются справа налево (или снизу вверх в конфигурации). Это означает, что последний указанный loader обрабатывает исходный файл первым.

module.exports = {
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: ['loader-a', 'loader-b']
      }
    ]
  }
};

Здесь выполнение будет:

  1. loader-b
  2. loader-a

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

Фаза pitch

Каждый загрузчик может содержать дополнительную функцию pitch. Она выполняется до основного тела загрузчика и может прервать дальнейшую цепочку.

module.exports.pitch = function (remainingRequest) {
  return 'export default "skipped";';
};

Если pitch возвращает значение, выполнение остальных загрузчиков для данного модуля прекращается.

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

Объект options и конфигурация loader

Современный способ передачи параметров загрузчику — использование this.getOptions().

module.exports = function (source) {
  const options = this.getOptions();
  const prefix = options.prefix || '';

  return prefix + source;
};

Конфигурация:

module.exports = {
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: {
          loader: 'custom-loader',
          options: {
            prefix: 'LOG: '
          }
        }
      }
    ]
  }
};

Для валидации опций часто используется JSON Schema:

const schema = {
  type: 'object',
  properties: {
    prefix: { type: 'string' }
  }
};

module.exports = function (source) {
  const options = this.getOptions(schema);
  return options.prefix + source;
};

Генерация дополнительных файлов

Загрузчик может не только преобразовывать код, но и генерировать новые ресурсы:

module.exports = function (source) {
  this.emitFile('output.txt', source);
  return `export default "generated";`;
};

Это полезно для генерации ассетов, сопутствующих модулю: изображений, текстов, CSS-файлов.

Работа с зависимостями

Загрузчик может объявлять зависимости, которые влияют на пересборку:

module.exports = function (source) {
  this.addDependency('/path/to/config.json');
  return source;
};

Если зависимость изменяется, Webpack пересобирает модуль.

Кеширование результатов

Webpack может кешировать результат выполнения загрузчика, если он считается чистым:

module.exports = function (source) {
  this.cacheable(true);
  return source.trim();
};

Кеширование ускоряет повторные сборки, особенно в крупных проектах.

Обработка ошибок

Ошибки могут быть выброшены синхронно или переданы через callback:

module.exports = function (source) {
  throw new Error('Invalid content');
};

или

module.exports = function (source) {
  const callback = this.async();

  if (!source) {
    callback(new Error('Empty file'));
    return;
  }

  callback(null, source);
};

Webpack останавливает обработку текущего модуля при возникновении ошибки.

Raw loader и бинарные данные

Некоторые загрузчики работают с бинарными данными. Для этого используется режим raw:

module.exports = function (content) {
  return content.toString('utf8');
};

module.exports.raw = true;

В этом случае content передается как Buffer.

Контракт chaining и композиция

Цепочка загрузчиков представляет собой поток преобразований:

file → loader3 → loader2 → loader1 → bundle

Каждый загрузчик получает результат предыдущего, что позволяет строить модульные трансформации.

Важно учитывать, что:

  • порядок влияет на результат
  • каждый loader должен быть максимально изолирован
  • промежуточные форматы должны быть предсказуемыми

Разделение логики внутри loader

Хорошая практика — выделять чистую трансформационную логику отдельно от Webpack API:

function transform(code, options) {
  return code.replace(options.from, options.to);
}

module.exports = function (source) {
  const options = this.getOptions();
  return transform(source, options);
};

Это упрощает тестирование и повторное использование.

Асинхронные паттерны и промисы

Хотя Webpack loader API не использует промисы напрямую, асинхронную логику можно организовать через их обертку:

module.exports = function (source) {
  const callback = this.async();

  Promise.resolve(source)
    .then((code) => code.toUpperCase())
    .then((result) => callback(null, result))
    .catch((err) => callback(err));
};

Такой подход полезен при интеграции с современными API.

Контекст resourcePath и request цепочки

Webpack предоставляет доступ к полной цепочке запроса:

module.exports = function (source) {
  const request = this.request;
  const resource = this.resource;

  return source;
};

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

Особенности pitch vs normal phase

Фаза pitch выполняется до основной функции загрузчика и имеет обратный порядок вызова:

loader1.pitch → loader2.pitch → loader3.pitch
loader3 → loader2 → loader1

Эта модель позволяет реализовать перехватывающие цепочки.

Практическая структура собственного loader

Типичный loader в реальных проектах включает:

  • проверку опций
  • валидацию входных данных
  • основную трансформацию
  • обработку ошибок
  • объявление зависимостей
  • кеширование
module.exports = function (source) {
  const options = this.getOptions();

  this.cacheable();

  try {
    const result = source.replace(options.search, options.replace);
    return result;
  } catch (e) {
    throw e;
  }
};

Совместимость и версия Webpack

Существенное различие между версиями заключается в:

  • способе получения options (this.getOptions)
  • поддержке asset modules
  • изменениях в caching API
  • строгой типизации loader contract

Webpack 5 делает упор на строгую изоляцию и предсказуемость поведения загрузчиков.

Внутренняя модель исполнения

При компиляции Webpack:

  1. определяет подходящие loaders по rule matching
  2. формирует цепочку выполнения
  3. применяет loaders справа налево
  4. обрабатывает pitch-фазы
  5. выполняет normal-фазы
  6. получает итоговый JavaScript-код модуля

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