Кастомные резолверы

Механизм разрешения модулей в Webpack построен поверх библиотеки enhanced-resolve, которая реализует расширяемый пайплайн поиска и интерпретации импортов. Вся система резолва представляет собой последовательность хуков, управляемых через Tapable, где каждый этап может быть перехвачен, модифицирован или полностью заменён через плагины.

Кастомные резолверы используются для изменения поведения поиска модулей: переопределения путей, внедрения виртуальных модулей, трансформации импортов, реализации альтернативных схем резолва (монорепозитории, CDN-импорты, виртуальные FS, условные окружения).

Основная точка расширения — поле resolve.plugins в конфигурации сборщика. Каждый плагин получает доступ к ResolverFactory и цепочке хуков конкретного резолвера.


Структура резолвера и ключевые хуки

Резолвер в Webpack состоит из нескольких уровней:

  • ResolverFactory — фабрика, создающая экземпляры резолверов
  • Resolver — основной объект, обрабатывающий запросы resolve
  • Система хуков Tapable — события этапов резолва

Ключевые хуки:

  • resolve — начальная точка обработки запроса
  • parsedResolve — обработанный запрос после парсинга
  • result — финальный результат резолва
  • noResolve — ситуация, когда модуль не найден
  • file / directory — обработка файлов и директорий
  • module / descriptionFile — работа с package.json и node_modules

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


Подключение кастомного резолвера через конфигурацию

Базовый способ добавления кастомной логики — регистрация плагина:

// webpack.config.js
module.exports = {
  resolve: {
    plugins: [
      new CustomResolverPlugin()
    ]
  }
};

Плагин должен реализовать метод apply, который получает доступ к resolverFactory:

class CustomResolverPlugin {
  apply(resolverFactory) {
    resolverFactory.hooks.resolver.for('normal').tap(
      'CustomResolverPlugin',
      (resolver) => {
        resolver.hooks.resolve.tapAsync(
          'CustomResolverPlugin',
          (request, resolveContext, callback) => {
            callback();
          }
        );
      }
    );
  }
}

Модель работы enhanced-resolve

Система резолва работает по следующей цепочке:

  1. Приход запроса (import / require)
  2. Нормализация пути
  3. Применение алиасов
  4. Обработка расширений extensions
  5. Поиск в modules (node_modules и кастомные директории)
  6. Проверка mainFields и mainFiles
  7. Разрешение package.json (descriptionFile)
  8. Финальный путь или ошибка

Кастомные резолверы могут вмешиваться на любом этапе, но наиболее часто используются на стадиях:

  • before-normal-resolve
  • resolve
  • after-resolve
  • file
  • directory

Пример перехвата и подмены модулей

Один из частых сценариев — подмена импортируемого модуля:

class AliasByConditionPlugin {
  apply(resolverFactory) {
    resolverFactory.hooks.resolver.for('normal').tap(
      'AliasByConditionPlugin',
      (resolver) => {
        resolver.hooks.file.tapAsync(
          'AliasByConditionPlugin',
          (request, callback) => {
            if (request.request === 'legacy-lib') {
              const obj = {
                ...request,
                request: 'modern-lib'
              };
              return callback(null, obj);
            }

            callback();
          }
        );
      }
    );
  }
}

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


Работа с виртуальными модулями

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

Пример — виртуальный модуль:

class VirtualModuleResolver {
  apply(resolverFactory) {
    resolverFactory.hooks.resolver.for('normal').tap(
      'VirtualModuleResolver',
      (resolver) => {
        resolver.hooks.resolve.tapAsync(
          'VirtualModuleResolver',
          (request, context, callback) => {
            if (request.request === 'virtual:config') {
              const newRequest = {
                ...request,
                path: '/__virtual__/',
                request: './config.js',
                resolved: true
              };

              return callback(null, newRequest);
            }

            callback();
          }
        );
      }
    );
  }
}

В связке с loader’ами это позволяет реализовывать конфигурации, генерируемые на лету.


Интеграция с filesystem и memory-based storage

enhanced-resolve поддерживает замену файловой системы через inputFileSystem:

resolverFactory.hooks.resolver.for('normal').tap(
  'MemoryFSResolver',
  (resolver) => {
    resolver.fileSystem = myMemoryFS;
  }
);

Это используется для:

  • in-memory сборок
  • dev-server окружений
  • тестирования без диска
  • виртуальных файловых систем

Кастомный резолвер может полностью заменить чтение файлов, включая package.json и symlink обработку.


Обработка package.json и mainFields

Один из самых сложных этапов резолва — выбор entry точки пакета.

Webpack анализирует:

  • mainFields (browser, module, main)
  • exports (в новых версиях Node.js и Webpack)
  • descriptionFile (обычно package.json)

Кастомный резолвер может изменить приоритет:

resolver.hooks.descriptionFile.tapAsync(
  'CustomMainFieldsPlugin',
  (content, path, callback) => {
    if (content.browser) {
      content.preferredEntry = content.browser;
    }

    callback(null, content);
  }
);

Это позволяет реализовать:

  • разные сборки для web/node
  • feature flags на уровне пакета
  • замены entry без изменения зависимостей

Расширение логики extensions

Механизм extensions управляет поиском файлов без указания расширения:

resolve: {
  extensions: ['.ts', '.js', '.json']
}

Кастомный резолвер может вмешаться в процесс:

resolver.hooks.file.tapAsync(
  'ExtensionPriorityPlugin',
  (request, callback) => {
    if (request.request && !request.request.includes('.')) {
      request.request = request.request + '.custom.js';
    }

    callback(null, request);
  }
);

Это используется для:

  • DSL-расширений (.view, .model)
  • A/B загрузки файлов
  • мульти-рантайм систем

Монорепозитории и кастомные директории модулей

В сложных проектах modules переопределяется:

resolve: {
  modules: [
    'src',
    'shared',
    'node_modules'
  ]
}

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

resolver.hooks.module.tapAsync(
  'WorkspaceModulesPlugin',
  (request, callback) => {
    if (request.request.startsWith('@shared/')) {
      request.request = request.request.replace('@shared/', '');
      request.path = '/repo/shared/';
    }

    callback(null, request);
  }
);

Это применяется в:

  • monorepo архитектурах (pnpm, yarn workspaces)
  • domain-driven design структурах
  • разделении бизнес-модулей

Контекстный резолв и условия окружения

Кастомные резолверы часто добавляют условность:

  • development vs production
  • browser vs server
  • feature flags
  • runtime capabilities

Пример:

resolver.hooks.resolve.tapAsync(
  'EnvResolverPlugin',
  (request, context, callback) => {
    if (process.env.APP_ENV === 'production') {
      if (request.request === 'debug-tools') {
        return callback(null, false);
      }
    }

    callback();
  }
);

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


Работа с symlinks и linked packages

Webpack по умолчанию поддерживает symlinks, но поведение можно изменить:

resolve: {
  symlinks: false
}

Кастомный резолвер может полностью контролировать этот процесс, изменяя realpath:

resolver.hooks.result.tapAsync(
  'SymlinkResolverPlugin',
  (request, callback) => {
    if (request.path.includes('linked-package')) {
      request.path = fs.realpathSync(request.path);
    }

    callback(null, request);
  }
);

Это критично для:

  • локальной разработки библиотек
  • npm link сценариев
  • монорепозиториев с симлинками

Отладка и диагностика резолва

Резолвер предоставляет детализированную трассировку:

  • лог каждого шага
  • причина отказа
  • цепочка попыток поиска

Кастомные плагины могут добавлять собственную телеметрию:

resolver.hooks.noResolve.tap('DebugResolver', (request) => {
  console.log('Failed resolve:', request.request, request.path);
});

Также возможно расширение stats:

  • какие алиасы сработали
  • какие директории проверялись
  • какие расширения тестировались

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

Сложные проекты используют несколько резолверов одновременно:

  • normal
  • context
  • loader

Каждый имеет собственную цепочку хуков.

resolverFactory.hooks.resolver.for('context').tap(
  'ContextResolverPlugin',
  (resolver) => {
    // отдельная логика для require.context
  }
);

Это важно для:

  • динамических импортов
  • контекстных require
  • code splitting сценариев

Глубокая интеграция с системой плагинов Tapable

Вся система резолва построена на Tapable hooks:

  • SyncHook
  • AsyncSeriesHook
  • AsyncSeriesBailHook

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

Это даёт возможность:

  • останавливать резолв при первом совпадении
  • модифицировать цепочку асинхронно
  • внедрять fallback-логику

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

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

  1. Alias-first resolution Перехват и замена путей до стандартного резолва

  2. Middleware chaining Несколько плагинов, каждый модифицирует request

  3. Virtual filesystem mapping Полная абстракция над файловой системой

  4. Environment-based routing Разные графы зависимостей для разных сред

  5. Namespace resolvers Специальные префиксы (например, “virtual:”, “@api:”, “~internal:”)

Каждый паттерн влияет на структуру сборки и граф зависимостей, формируя альтернативную модель модульности поверх стандартного Node.js resolution algorithm.