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

Любая система модулей сталкивается с необходимостью определить, какой именно файл соответствует указанному пути в инструкции import или export. Этот процесс называется резолвингом модулей (module resolution).

Когда встречается конструкция:

import { format } from './utils.js';

или

import React from 'react';

Rollup должен определить:

  1. Является ли путь относительным или пакетным.
  2. Какой файл требуется загрузить.
  3. Где находится этот файл на диске.
  4. Какие зависимости содержит найденный модуль.
  5. Нужно ли продолжать поиск дальше по графу зависимостей.

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


Общая схема работы

При запуске сборки Rollup начинает с входного файла:

export default {
    input: 'src/main.js'
};

После открытия файла анализируются все инструкции импорта:

import './polyfills.js';
import { api } from './services/api.js';
import { render } from './ui/render.js';

Для каждого импорта выполняется отдельный цикл поиска.

Упрощённо процесс выглядит следующим образом:

main.js
 ├─ polyfills.js
 ├─ api.js
 │   └─ config.js
 └─ render.js
     ├─ button.js
     └─ modal.js

Rollup постепенно строит полный граф зависимостей проекта.


Относительные пути

Наиболее простой случай — относительный импорт.

Исходный файл:

src/main.js

Импорт:

import './utils.js';

Rollup интерпретирует путь относительно текущего файла.

Если файл расположен здесь:

src/
 ├─ main.js
 └─ utils.js

то будет найден:

src/utils.js

Для конструкции:

import '../config.js';

поиск производится на уровень выше:

project/
 ├─ config.js
 └─ src/
     └─ main.js

Результатом станет:

project/config.js

Относительные пути всегда начинаются с:

./
../

Поэтому Rollup может сразу определить, что необходимо искать локальный файл.


Абсолютные пути

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

import '/src/core.js';

Интерпретация зависит от среды выполнения и настроек плагинов.

Сам Rollup не занимается полноценной обработкой веб-путей и обычно ожидает реальные пути файловой системы.

Поэтому абсолютные импорты часто требуют дополнительной настройки через плагины или параметр resolveId.


Пакетные импорты

Особый случай представляет импорт пакета:

import lodash from 'lodash';

Здесь отсутствуют:

./
../
/

Следовательно, Rollup понимает, что требуется поиск внешнего пакета.

В отличие от Node.js, ядро Rollup не реализует полный алгоритм поиска пакетов самостоятельно. Для этого обычно используется специальный плагин резолвинга.

Наиболее распространённый вариант:

import resolve from '@rollup/plugin-node-resolve';

Без него Rollup не сможет определить расположение большинства npm-пакетов.


Почему нужен node-resolve

Рассмотрим импорт:

import React from 'react';

Физический путь может выглядеть так:

node_modules/
 └─ react/
     ├─ package.json
     ├─ index.js
     └─ cjs/

Rollup видит лишь строку:

'react'

Для преобразования её в конкретный файл требуется алгоритм поиска пакета.

Именно его реализует плагин:

plugins: [
    resolve()
]

После подключения Rollup начинает работать по правилам, близким к Node.js.


Поиск внутри node_modules

Предположим, имеется импорт:

import dayjs from 'dayjs';

Алгоритм упрощённо выполняет следующие действия:

  1. Проверяет текущую директорию.
  2. Ищет каталог node_modules.
  3. Проверяет наличие папки dayjs.
  4. Загружает её метаданные.
  5. Определяет входной файл пакета.

Пример структуры:

node_modules/
 └─ dayjs/
     ├─ package.json
     └─ dayjs.min.js

После обнаружения пакета поиск продолжается внутри него.


Роль package.json

Основная информация о точке входа находится в файле:

{
  "name": "my-library",
  "main": "dist/index.js"
}

При обнаружении пакета Rollup анализирует его метаданные.

Если найдено:

{
  "main": "dist/index.js"
}

то фактическим модулем станет:

dist/index.js

а не корень каталога.


Поле module

Современные библиотеки часто содержат несколько сборок.

Например:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js"
}

Здесь:

  • main предназначен для CommonJS;
  • module содержит ES-модульную версию.

Для Rollup предпочтительнее использовать ESM-сборку, поскольку она лучше подходит для tree shaking.

Поэтому при наличии поля:

{
  "module": "dist/index.esm.js"
}

обычно выбирается именно оно.


Поле exports

Современный стандарт Node.js ввёл механизм:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}

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

Импорт:

import lib from 'library';

будет преобразован в:

library/dist/index.js

А импорт:

import utils from 'library/utils';

приведёт к:

library/dist/utils.js

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


Расширения файлов

Импорт может выглядеть так:

import helper from './helper';

Файл же называется:

helper.js

Алгоритм резолвинга способен проверять допустимые расширения.

Обычно перебираются варианты:

helper.js
helper.mjs
helper.cjs
helper.json

Конкретный список зависит от настроек и используемых плагинов.


Поиск index-файлов

Если импорт указывает на каталог:

import config from './config';

структура проекта:

config/
 └─ index.js

то возможна автоматическая подстановка:

config/index.js

Подобный механизм давно используется в экосистеме Node.js и поддерживается большинством инструментов сборки.


Резолвинг вложенных импортов

После нахождения первого файла процесс не заканчивается.

Например:

import { start } from './app.js';

Файл:

import { api } from './api.js';

Файл:

import { request } from './request.js';

Получается цепочка:

main.js
  ↓
app.js
  ↓
api.js
  ↓
request.js

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


Формирование графа модулей

Внутри Rollup создаётся ориентированный граф.

Пример:

main.js
 ├─ auth.js
 │   ├─ api.js
 │   └─ storage.js
 └─ ui.js
     └─ modal.js

Каждая вершина графа представляет модуль.

Каждое ребро показывает связь через импорт.

На основе этого графа затем выполняются:

  • tree shaking;
  • code splitting;
  • анализ циклических зависимостей;
  • генерация чанков.

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

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

В большом приложении граф способен содержать:

1000+
модулей

Повторный обход файловой системы для каждого импорта привёл бы к серьёзным затратам времени.

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

"./utils.js"
      ↓
"/project/src/utils.js"

При повторном обращении используется уже известный путь.

Это существенно ускоряет сборку.


Обработка символических ссылок

В некоторых проектах используются symlink-ссылки:

packages/
 └─ shared

node_modules/
 └─ shared -> ../. ./packages/shared

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

Во время резолвинга Rollup может:

  • сохранять симлинк как есть;
  • раскрывать его до реального пути.

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


Резолвинг в монорепозиториях

Структура монорепозитория может выглядеть так:

packages/
 ├─ core
 ├─ ui
 └─ utils

Пакет:

import { log } from '@project/utils';

может находиться не в обычном node_modules, а внутри рабочей области (workspace).

Современные инструменты:

npm workspaces
pnpm
yarn workspaces

создают специальные ссылки между пакетами.

Алгоритм поиска должен корректно учитывать такую структуру.


Внешние зависимости

Иногда модуль не должен включаться в сборку.

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

export default {
    external: ['react']
};

Импорт:

import React from 'react';

будет найден и распознан, но содержимое пакета не попадёт в итоговый bundle.

Rollup сохранит импорт во внешнем виде:

import React from 'react';

или преобразует его в формат, соответствующий целевому модульному стандарту.


Пользовательский резолвинг через resolveId

Одной из важнейших точек расширения является хук:

resolveId

Пример:

export default {
    plugins: [
        {
            name: 'virtual',

            resolveId(id) {
                if (id === 'config') {
                    return '\0virtual-config';
                }
            }
        }
    ]
};

Теперь импорт:

import config from 'config';

будет перенаправлен на виртуальный модуль.

Таким образом можно реализовывать:

  • алиасы;
  • виртуальные файлы;
  • загрузку данных из БД;
  • генерацию модулей на лету;
  • интеграцию с внешними системами.

Конфликты и неоднозначности

Иногда один и тот же импорт может соответствовать нескольким вариантам.

Например:

helper.js
helper.mjs
helper.cjs

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

Поэтому в крупных проектах часто рекомендуется писать расширения явно:

import helper from './helper.js';

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


Ошибки резолвинга

Наиболее распространённые проблемы:

Отсутствующий файл:

import './unknown.js';

Ошибка:

Could not resolve "./unknown.js"

Отсутствующий пакет:

import axios from 'axios';

при неустановленной зависимости приводит к ошибке поиска пакета.

Неверный экспорт:

import x from 'library/internal';

может вызвать ошибку, если путь не разрешён через поле exports.


Влияние резолвинга на tree shaking

Качество tree shaking напрямую зависит от успешного определения всех модулей.

После построения полного графа Rollup знает:

какой модуль
↓
какой экспортирует символ
↓
кто этот символ использует

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

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