Резолвинг модулей вручную

Резолвинг модулей в Rollup — это процесс определения конечного идентификатора модуля (module id), по которому Rollup загружает, кэширует и включает файл в граф зависимостей. В отличие от рантайм-бандлеров, где резолвинг часто тесно связан с Node.js require, Rollup строит статический граф и требует более детального контроля над тем, как именно строка импорта преобразуется в конкретный файл.

Ключевой момент заключается в том, что импорт в исходном коде не обязан однозначно соответствовать файловому пути. Строка import x from 'lib' может означать:

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

Именно поэтому Rollup предоставляет механизм ручного резолвинга через плагины.


Базовый механизм разрешения модулей

На этапе построения графа Rollup встречает импорт:

import utils from './utils.js';

Далее происходит попытка определить реальный файл:

  • относительные пути (./, ../) резолвятся относительно текущего модуля
  • абсолютные пути обрабатываются как есть
  • bare-imports (react, lodash) требуют плагинов (например, node-resolve)

Без дополнительных плагинов Rollup не реализует полноценный Node.js resolution algorithm. Это принципиальное отличие архитектуры.


Роль хука resolveId

Основной инструмент ручного резолвинга — хук плагина resolveId.

export default function myResolver() {
  return {
    name: 'my-resolver',

    resolveId(source, importer) {
      if (source === 'special-lib') {
        return '/src/lib/special/index.js';
      }
    }
  };
}

Сигнатура поведения

  • source — строка импорта

  • importer — модуль, из которого происходит импорт

  • возвращаемое значение:

    • string → финальный путь
    • null → передать следующему плагину
    • false → пометить как external (не включать в бандл)

Контекст this.resolve внутри плагинов

Rollup предоставляет API this.resolve, позволяющее использовать встроенный механизм резолвинга внутри собственного плагина.

resolveId(source, importer) {
  return this.resolve(source, importer, { skipSelf: true })
    .then(resolved => resolved && resolved.id);
}

Особенности поведения

  • skipSelf: true предотвращает рекурсивный вызов текущего плагина
  • результат учитывает весь pipeline плагинов
  • возвращается объект с метаданными (id, external, moduleSideEffects)

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

Резолвинг в Rollup — это цепочка. Каждый плагин может:

  1. обработать импорт
  2. изменить путь
  3. заблокировать дальнейшую обработку
  4. делегировать следующему плагину

Пример конфликтного поведения:

resolveId(source) {
  if (source === 'config') {
    return '/a.js';
  }
}

и позже:

resolveId(source) {
  if (source === 'config') {
    return '/b.js';
  }
}

Фактически сработает первый плагин, который вернул не null.


Ручной резолвинг относительных путей

Частая задача — переопределить резолвинг конкретных директорий:

import path from 'path';

export default function aliasPlugin() {
  return {
    name: 'alias',

    resolveId(source, importer) {
      if (source.startsWith('@app/')) {
        return path.resolve(
          process.cwd(),
          'src',
          source.replace('@app/', '')
        );
      }
    }
  };
}

Важные нюансы

  • importer обязателен для корректного относительного резолвинга
  • без учета importer легко получить некорректные абсолютные пути
  • Rollup не нормализует пути автоматически в пользовательском resolveId

Виртуальные модули и ручной резолвинг

Одна из ключевых возможностей ручного резолвинга — создание виртуальных модулей.

const VIRTUAL_PREFIX = 'virtual:';

export default function virtualPlugin() {
  return {
    name: 'virtual-modules',

    resolveId(source) {
      if (source.startsWith(VIRTUAL_PREFIX)) {
        return source;
      }
    },

    load(id) {
      if (id === 'virtual:config') {
        return `export default { value: 123 };`;
      }
    }
  };
}

Поведение

  • resolveId возвращает идентификатор без физического файла
  • load генерирует содержимое на лету
  • Rollup воспринимает это как обычный модуль

Управление external через резолвинг

Резолвинг напрямую связан с понятием external-модулей.

resolveId(source) {
  if (source === 'fs') {
    return false;
  }
}

Значение false

  • модуль не включается в бандл
  • сохраняется как require('fs') или import 'fs'
  • используется для Node.js встроенных модулей или внешних зависимостей

Условный резолвинг в зависимости от окружения

Резолвинг часто зависит от режима сборки:

export default function envResolver(options) {
  const isProd = options.env === 'production';

  return {
    name: 'env-resolver',

    resolveId(source) {
      if (source === 'env-config') {
        return isProd
          ? '/src/config/prod.js'
          : '/src/config/dev.js';
      }
    }
  };
}

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


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

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

1. Потеря расширений

Rollup не всегда автоматически добавляет .js:

return '/src/utils/index'; // может не резолвиться без плагинов

2. Конфликт с node-resolve

Плагин @rollup/plugin-node-resolve может перехватывать импорт раньше кастомного резолвера.

3. Дублирование модулей

Если разные пути резолвятся в один и тот же файл с разными id:

./utils/index.js
./utils

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


Нормализация идентификаторов

Для предотвращения дублей важно нормализовать пути:

import path from 'path';

function normalize(id) {
  return path.resolve(id);
}

И использовать это в resolveId:

resolveId(source, importer) {
  const resolved = path.resolve(path.dirname(importer), source);
  return normalize(resolved);
}

Интеграция с package exports

В современных проектах значимую роль играет поле exports в package.json. Rollup сам по себе не всегда интерпретирует его без дополнительных плагинов.

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

resolveId(source) {
  if (source === 'my-lib') {
    return '/node_modules/my-lib/dist/index.mjs';
  }
}

Однако при таком подходе теряется поддержка conditional exports, поэтому обычно требуется делегирование стандартному резолверу:

resolveId(source, importer) {
  return this.resolve(source, importer, {
    skipSelf: true
  });
}

Приоритеты и цепочка резолвинга

Порядок обработки:

  1. resolveId всех плагинов
  2. встроенный резолвинг Rollup
  3. внешние плагины node-resolve (если подключены)
  4. fallback на null → ошибка unresolved import

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


Кэширование и влияние на резолвинг

Rollup кэширует результаты резолвинга для ускорения повторных сборок:

  • одинаковый source + importer → один результат
  • изменение логики плагина требует invalidation
  • watch mode может пересчитывать только измененные модули

Ошибки в ручном резолвинге часто приводят к «залипшим» путям из-за кэша.


Типичные паттерны ручного резолвинга

Alias-плагин

const aliases = {
  '@': '/src',
  '~': '/src/shared'
};

resolveId(source) {
  for (const key in aliases) {
    if (source.startsWith(key)) {
      return source.replace(key, aliases[key]);
    }
  }
}

Conditional feature flags

resolveId(source) {
  if (source === 'feature-x') {
    return process.env.FEATURE_X === 'true'
      ? '/src/feature-x/on.js'
      : '/src/feature-x/off.js';
  }
}

Transparent passthrough

resolveId(source, importer) {
  if (source.endsWith('.svg')) {
    return this.resolve(source, importer, { skipSelf: true });
  }
}

Архитектурные ограничения ручного резолвинга

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

  • детерминированностью путей
  • отсутствием побочных эффектов в resolveId
  • согласованностью между dev и build режимами
  • совместимостью с другими плагинами

Любая недетерминированность (например, случайные или time-based решения) приводит к нестабильному графу модулей и неконсистентным бандлам.