Плагин для замены импортов во время сборки

В Esbuild система плагинов построена вокруг перехвата этапов резолва и загрузки модулей. Это даёт возможность изменять поведение сборщика без модификации исходного кода проекта. Основной инструмент для работы с импортами — хук onResolve, который управляет тем, как интерпретируются пути модулей, и onLoad, который определяет содержимое загружаемых модулей.

Плагин, предназначенный для замены импортов во время сборки, обычно решает задачи:

  • подмена модулей на альтернативные реализации
  • перенаправление импортов на другие пути
  • внедрение окруженческих зависимостей
  • мокирование модулей
  • условная замена реализаций для production/dev сборок

Ключевая особенность подхода Esbuild — разделение этапов:

  • onResolve отвечает за куда ведёт импорт
  • onLoad отвечает за что загружается из этого пути

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


Базовая структура плагина для перехвата импортов

Плагин Esbuild представляет собой объект с методом setup, который получает API сборщика.

const replaceImportsPlugin = (options = {}) => ({
  name: 'replace-imports',
  setup(build) {
    // логика плагина
  }
});

Внутри setup регистрируются обработчики:

  • build.onResolve
  • build.onLoad

Перехват импортов через onResolve

onResolve позволяет изменить путь модуля до того, как Esbuild начнёт его загружать.

Пример простой замены импортов

Задача: заменить импорт libA на libB.

const replaceImportsPlugin = () => ({
  name: 'replace-imports',
  setup(build) {
    build.onResolve({ filter: /^libA$/ }, (args) => {
      return {
        path: 'libB'
      };
    });
  }
});

В этом случае любой импорт:

import x from 'libA';

будет перенаправлен в:

import x from 'libB';

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

Esbuild позволяет задавать виртуальные пространства имён через namespace, что особенно полезно при сложных подменах.

build.onResolve({ filter: /^virtual:/ }, (args) => {
  return {
    path: args.path,
    namespace: 'virtual-modules'
  };
});

Далее onLoad обрабатывает этот namespace:

build.onLoad({ filter: /.*/, namespace: 'virtual-modules' }, () => {
  return {
    contents: `export const value = 42;`,
    loader: 'js'
  };
});

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


Полная замена импорта через onLoad

Иногда недостаточно изменить путь — требуется полностью подменить содержимое модуля.

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

build.onLoad({ filter: /.*/, namespace: 'env' }, () => {
  return {
    contents: `
      export const API_URL = "https://prod.api.com";
    `,
    loader: 'js'
  };
});

Здесь импорт остаётся прежним, но содержимое подменяется на этапе загрузки.


Условная замена импортов (development / production)

Один из наиболее распространённых сценариев — замена модулей в зависимости от режима сборки.

const env = process.env.NODE_ENV;

const replaceImportsPlugin = () => ({
  name: 'replace-imports',
  setup(build) {
    build.onResolve({ filter: /^logger$/ }, () => {
      return {
        path: env === 'production' ? 'logger.prod' : 'logger.dev'
      };
    });
  }
});

В production подключается лёгкая версия логгера, в development — расширенная.


Алиасинг путей как частный случай замены импортов

Esbuild не имеет встроенного alias как Webpack, но его легко реализовать через onResolve.

const aliasMap = {
  '@': './src',
  '@utils': './src/utils',
  '@api': './src/api'
};

build.onResolve({ filter: /^@/ }, (args) => {
  for (const alias in aliasMap) {
    if (args.path.startsWith(alias)) {
      return {
        path: args.path.replace(alias, aliasMap[alias])
      };
    }
  }
});

Такой механизм позволяет полностью эмулировать поведение alias-систем.


Перехват и подмена внешних зависимостей

Иногда требуется заменить стороннюю библиотеку собственной реализацией.

build.onResolve({ filter: /^lodash$/ }, () => {
  return {
    path: 'my-lodash-shim',
    external: false
  };
});

Или наоборот — исключить модуль из бандла:

build.onResolve({ filter: /^fs$/ }, () => {
  return {
    path: 'fs',
    external: true
  };
});

Подмена модулей через регулярные выражения

Esbuild позволяет гибко перехватывать группы импортов.

build.onResolve({ filter: /^@components\/.*$/ }, (args) => {
  return {
    path: args.path.replace('@components', './src/components')
  };
});

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


Виртуальные модули как механизм инъекции кода

Виртуальные модули часто используются для:

  • конфигурации окружения
  • внедрения build-time констант
  • генерации кода на лету
build.onResolve({ filter: /^virtual:config$/ }, () => {
  return {
    path: 'virtual:config',
    namespace: 'virtual'
  };
});

build.onLoad({ filter: /.*/, namespace: 'virtual' }, () => {
  const config = {
    mode: process.env.NODE_ENV,
    version: '1.0.0'
  };

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

Подмена импортов с учётом пути исходного файла

Аргумент args в onResolve содержит контекст:

  • importer — файл, из которого идёт импорт
  • path — импортируемый модуль

Это позволяет делать контекстные замены:

build.onResolve({ filter: /^service$/ }, (args) => {
  if (args.importer.includes('/admin/')) {
    return { path: 'admin-service' };
  }

  return { path: 'public-service' };
});

Так реализуются разные реализации одного модуля в зависимости от слоя приложения.


Каскадная замена импортов

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

build.onResolve({ filter: /^api$/ }, () => {
  return { path: 'api/v2' };
});

build.onResolve({ filter: /^api\/v2$/ }, () => {
  return { path: 'api/v2/index' };
});

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


Обработка TypeScript и JavaScript одновременно

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

build.onResolve({ filter: /\.ts$/ }, (args) => {
  return {
    path: args.path.replace('.ts', '.js')
  };
});

Или более аккуратно:

build.onResolve({ filter: /\.[jt]s$/ }, (args) => {
  return {
    path: args.path.replace(/\.[jt]s$/, '.js')
  };
});

Ограничения и поведение Esbuild при замене импортов

При работе с заменой импортов важно учитывать особенности:

  • onResolve не изменяет содержимое файла, только путь
  • onLoad всегда привязан к результату резолва
  • циклические зависимости могут сохраняться после подмены
  • external: true полностью исключает модуль из графа сборки

Особенно критично учитывать порядок регистрации обработчиков: Esbuild применяет их в порядке добавления.


Композиция плагинов для сложной логики подмены

В реальных проектах замена импортов часто комбинируется:

  • alias + environment switching
  • virtual modules + external dependencies
  • context-based routing + mocking
const plugin = () => ({
  name: 'complex-replace',
  setup(build) {
    // алиасы
    build.onResolve({ filter: /^@utils/ }, (args) => {
      return { path: args.path.replace('@utils', './src/utils') };
    });

    // окружение
    build.onResolve({ filter: /^config$/ }, () => {
      return {
        path: process.env.NODE_ENV === 'prod'
          ? 'config.prod'
          : 'config.dev'
      };
    });

    // мокирование
    build.onResolve({ filter: /^http-client$/ }, () => {
      return { path: 'http-client-mock' };
    });
  }
});

Типовые сценарии применения

  • изоляция тестовых окружений через подмену API модулей
  • сборка разных клиентских конфигураций без изменения кода
  • миграция старых библиотек на новые реализации
  • создание облегчённых production-бандлов
  • внедрение feature flags на уровне импорта модулей