Хук onLoad: перехват загрузки файлов

В плагинах esbuild хук onLoad отвечает за перехват процесса чтения содержимого модулей после того, как путь файла уже был разрешён. Он срабатывает на этапе загрузки ресурса и позволяет полностью заменить, модифицировать или динамически сгенерировать содержимое модуля до того, как esbuild применит трансформации (loader, JSX, TypeScript и т.д.).

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

  • разрешение пути (onResolve)
  • загрузка содержимого (onLoad)

onResolve определяет, откуда берётся модуль, а onLoad — что именно в этом модуле будет обработано дальше.

Хук onLoad работает уже с финальным идентификатором файла (или виртуального ресурса), который представлен в виде объекта:

  • path — путь к ресурсу
  • namespace — пространство имён (важно для виртуальных модулей и нестандартных источников)

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

export default {
  name: 'example-plugin',
  setup(build) {
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      return {
        contents: `export default ${JSON.stringify(await fs.promises.readFile(args.path, 'utf8'))}`,
        loader: 'js'
      };
    });
  }
};

В этом примере любой .txt файл превращается в ES-модуль, экспортирующий строку.

Ключевые элементы результата:

  • contents — итоговое содержимое модуля
  • loader — способ интерпретации (js, ts, json, text, base64, file и др.)

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

Первый аргумент onLoad — объект с filter (регулярное выражение). Он определяет, какие пути будут обрабатываться:

build.onLoad({ filter: /\.md$/ }, handler);

Фильтр применяется к path, поэтому он работает уже после onResolve. Это означает, что нельзя влиять на выбор файла на этом этапе — только на его содержимое.

Порядок выполнения нескольких onLoad

В esbuild можно зарегистрировать несколько onLoad для одного и того же фильтра. Они выполняются в порядке регистрации, но фактическое выполнение зависит от namespace и точного совпадения фильтра.

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

  • первый подходящий onLoad, вернувший результат, завершает цепочку
  • если обработчик возвращает null, выполнение продолжается
  • конкурирующие плагины могут перехватывать один и тот же файл
build.onLoad({ filter: /.*/ }, () => null);

build.onLoad({ filter: /\.js$/ }, () => ({
  contents: 'export const x = 1',
  loader: 'js'
}));

Виртуальные модули и namespace

onLoad особенно важен для работы с виртуальными файлами. В связке с onResolve он позволяет создавать модули, которых не существует на диске.

build.onResolve({ filter: /^virtual:config$/ }, () => ({
  path: 'virtual:config',
  namespace: 'virtual'
}));

build.onLoad({ filter: /.*/, namespace: 'virtual' }, () => {
  return {
    contents: `export const config = { debug: true }`,
    loader: 'js'
  };
});

Здесь:

  • onResolve создаёт фиктивный путь
  • onLoad предоставляет содержимое для этого пути

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

Асинхронная загрузка данных

onLoad поддерживает асинхронные операции, что позволяет подключать внешние источники:

build.onLoad({ filter: /\.api$/ }, async (args) => {
  const res = await fetch(`https://example.com/data?file=${args.path}`);
  const json = await res.json();

  return {
    contents: `export default ${JSON.stringify(json)}`,
    loader: 'js'
  };
});

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

Работа с loader и преобразованием типов

Поле loader определяет, как esbuild интерпретирует contents.

Основные режимы:

  • js — JavaScript
  • ts — TypeScript
  • json — JSON
  • text — строка
  • base64 — бинарные данные
  • file — файл как ресурс

Пример преобразования Markdown в строковый модуль:

build.onLoad({ filter: /\.md$/ }, async (args) => {
  const source = await fs.promises.readFile(args.path, 'utf8');

  return {
    contents: JSON.stringify(source),
    loader: 'text'
  };
});

В этом случае esbuild не будет пытаться интерпретировать содержимое как код.

Генерация модулей на лету

onLoad позволяет полностью игнорировать исходный файл:

build.onLoad({ filter: /generate:uuid/ }, () => {
  return {
    contents: `
      export const uuid = () => crypto.randomUUID();
    `,
    loader: 'js'
  };
});

Такой подход используется для:

  • конфигурационных модулей
  • моков
  • автоматической генерации API-клиентов
  • инлайн-констант

Кэширование и производительность

esbuild не кэширует результаты onLoad автоматически на уровне плагина. Это означает, что ответственность за оптимизацию лежит на разработчике.

Типичная стратегия:

const cache = new Map();

build.onLoad({ filter: /\.data$/ }, async (args) => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }

  const data = await fs.promises.readFile(args.path, 'utf8');

  const result = {
    contents: `export default ${JSON.stringify(data)}`,
    loader: 'js'
  };

  cache.set(args.path, result);
  return result;
});

Без кэширования повторные сборки или инкрементальные запуски могут приводить к лишним I/O операциям.

Отличие onLoad от onResolve

onResolve:

  • определяет путь модуля
  • работает до чтения файла
  • управляет aliasing, перенаправлением, виртуальными путями

onLoad:

  • работает с уже найденным ресурсом
  • формирует содержимое модуля
  • отвечает за трансформацию данных

Типичный пайплайн:

  1. импорт import x from './file.ext'
  2. onResolve меняет путь или namespace
  3. esbuild передаёт результат в onLoad
  4. onLoad возвращает содержимое
  5. применяется loader и встроенные трансформации

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

Если onLoad выбрасывает исключение, сборка завершается ошибкой. Корректнее возвращать диагностическое сообщение через errors:

build.onLoad({ filter: /\.cfg$/ }, () => {
  return {
    errors: [{
      text: 'Неверный формат конфигурации',
      location: null
    }]
  };
});

Можно также комбинировать warnings и errors для частичной деградации поведения.

Использование с namespace для изоляции источников

Namespace позволяет разделять разные типы виртуальных ресурсов:

build.onLoad({ filter: /.*/, namespace: 'http' }, async (args) => {
  const res = await fetch(args.path);
  const text = await res.text();

  return {
    contents: text,
    loader: 'text'
  };
});

Это создаёт модель, в которой разные источники данных (файловая система, HTTP, виртуальные модули) обрабатываются единым механизмом, но не конфликтуют между собой.

Особенности поведения при конкурентных плагинах

При наличии нескольких плагинов важно учитывать:

  • порядок регистрации влияет на приоритет
  • более специфичный filter может не перебить общий
  • namespace часто важнее фильтра
  • первый вернувший результат обработчик завершает цепочку

Это делает архитектуру плагинов чувствительной к порядку подключения.

Интеграция с трансформацией кода

onLoad может возвращать уже готовый JavaScript, но также может делегировать работу встроенным трансформерам esbuild:

build.onLoad({ filter: /\.custom$/ }, async (args) => {
  const source = await fs.promises.readFile(args.path, 'utf8');

  const transformed = source.replaceAll('%%VERSION%%', '1.0.0');

  return {
    contents: transformed,
    loader: 'ts'
  };
});

В этом случае после onLoad esbuild применяет TypeScript/JS трансформации, JSX-компиляцию и другие встроенные этапы.

Ограничения и практические нюансы

  • onLoad не может изменить граф зависимостей напрямую — только содержимое
  • нельзя повлиять на import-структуру без генерации нового кода
  • частые асинхронные операции увеличивают время сборки
  • отсутствие встроенного кэша требует ручной оптимизации
  • namespace должен использоваться строго для разделения источников, иначе возможны коллизии

Эти ограничения формируют модель, в которой onLoad выступает точкой контроля содержимого, но не управления архитектурой модулей.