Алгоритм разрешения: node_modules и относительные пути

Алгоритм разрешения модулей — один из центральных механизмов Webpack. Именно он определяет, как сборщик находит файлы при использовании import, require, динамических импортов и внутренних зависимостей. От правильного понимания этого процесса зависит структура проекта, скорость сборки, работа алиасов, корректность подключения пакетов и поведение TypeScript, Babel и других инструментов.

Webpack использует собственную систему поиска модулей, основанную на Node.js, но значительно расширяет её возможностями конфигурации.


Что происходит при импорте

При встрече конструкции:

import Button from './components/Button';

Webpack выполняет последовательность действий:

  1. Анализирует тип пути.
  2. Определяет базовую директорию.
  3. Проверяет наличие файла.
  4. Подбирает расширение.
  5. Ищет директорию с index.js.
  6. Проверяет поля package.json.
  7. Применяет алиасы.
  8. Использует resolve.modules.
  9. Формирует итоговый абсолютный путь.

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


Типы путей

Webpack разделяет пути на несколько категорий.

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

Начинаются с:

./
../

Примеры:

import Header from './Header';
import utils from '../utils';

Такие пути рассчитываются относительно текущего файла.

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

src/pages/Home.js

то:

import Button from './Button';

означает:

src/pages/Button.js

А:

import api from '../api';

преобразуется в:

src/api.js

Абсолютные пути внутри проекта

Могут работать через:

resolve.modules

или:

resolve.alias

Пример:

import Button from 'components/Button';

Webpack может искать модуль внутри:

src/components/Button.js

если настроен:

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

Пакеты из node_modules

Если путь не начинается с:

./
../
/

Webpack предполагает, что импортируется пакет.

Пример:

import React from 'react';

Тогда запускается поиск внутри node_modules.


Разрешение относительных путей

Базовый принцип

Webpack начинает поиск относительно файла-инициатора.

Структура:

src/
├── app.js
├── utils/
│   └── math.js

Код:

import math from './utils/math';

Webpack:

  1. Берёт директорию src.
  2. Добавляет ./utils/math.
  3. Проверяет существование файла.

Получается:

src/utils/math

Автоматический подбор расширений

Если расширение не указано:

import Button from './Button';

Webpack перебирает расширения из:

resolve.extensions

Пример:

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

Проверка будет происходить в порядке:

Button.js
Button.jsx
Button.ts

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


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

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

import Layout from './layout';

Webpack проверяет:

layout/index.js
layout/index.jsx
layout/index.ts

Это поведение аналогично Node.js.


Разрешение модулей из node_modules

Основной алгоритм

При импорте:

import lodash from 'lodash';

Webpack начинает искать папку:

node_modules/lodash

Поиск происходит не только в текущей директории.


Подъём вверх по дереву каталогов

Структура:

project/
├── src/
│   └── pages/
│       └── Home.js
├── node_modules/

Файл:

src/pages/Home.js

Импорт:

import _ from 'lodash';

Webpack проверяет:

src/pages/node_modules/lodash
src/node_modules/lodash
project/node_modules/lodash

Алгоритм поднимается вверх до корня файловой системы.

Это полностью повторяет поведение Node.js.


Почему используется подъём вверх

Такой подход позволяет:

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

Роль package.json

После нахождения директории пакета Webpack анализирует package.json.

Пример:

node_modules/react/package.json

Поле main

{
    "main": "index.js"
}

Webpack загрузит:

react/index.js

Поле module

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

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

Webpack предпочитает:

module

потому что ES Modules лучше подходят для tree shaking.


Поле browser

Некоторые пакеты публикуют браузерные версии:

{
    "browser": "dist/browser.js"
}

Webpack может выбрать именно этот файл.


mainFields

Порядок проверки задаётся через:

resolve: {
    mainFields: ['browser', 'module', 'main']
}

Webpack будет искать файл именно в этой последовательности.


resolve.modules

Изменение директорий поиска

По умолчанию:

resolve: {
    modules: ['node_modules']
}

Можно добавить собственные директории:

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

Теперь:

import Button from 'components/Button';

будет искать:

src/components/Button.js

Преимущества

Удаление длинных относительных путей

Вместо:

import Button from '../. ./. ./components/Button';

можно писать:

import Button from 'components/Button';

Улучшение читаемости

Абсолютные импорты делают архитектуру проекта понятнее.


Проблемы больших проектов

Без настройки:

../. ./. ./. ./

цепочки становятся трудно поддерживаемыми.


resolve.alias

Создание псевдонимов

Webpack позволяет задавать короткие имена для путей.

Пример:

resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src')
    }
}

Теперь:

import Button from '@/components/Button';

означает:

src/components/Button

Несколько алиасов

resolve: {
    alias: {
        '@components': path.resolve(__dirname, 'src/components'),
        '@pages': path.resolve(__dirname, 'src/pages'),
        '@utils': path.resolve(__dirname, 'src/utils')
    }
}

Алиасы для конкретных файлов

resolve: {
    alias: {
        config$: path.resolve(__dirname, 'src/config/prod.js')
    }
}

Символ $ означает точное совпадение.


Замена библиотек

Webpack может подменять модули.

Пример:

resolve: {
    alias: {
        lodash: 'lodash-es'
    }
}

Теперь любой импорт:

import _ from 'lodash';

будет использовать:

lodash-es

resolve.extensions

Автоматическое расширение путей

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

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

Позволяет писать:

import App from './App';

вместо:

import App from './App.jsx';

Порядок имеет значение

Webpack проверяет расширения последовательно.

Если существуют:

App.js
App.ts

и указано:

extensions: ['.ts', '.js']

будет выбран:

App.ts

Производительность

Слишком большое количество расширений замедляет сборку.

Плохо:

extensions: [
    '.js',
    '.jsx',
    '.ts',
    '.tsx',
    '.json',
    '.mjs',
    '.cjs',
    '.vue',
    '.coffee'
]

Каждый импорт требует множества проверок файловой системы.


resolve.mainFiles

Имена файлов по умолчанию

По умолчанию:

resolve: {
    mainFiles: ['index']
}

Поэтому:

import layout from './layout';

ищет:

layout/index.js

Кастомные mainFiles

resolve: {
    mainFiles: ['main', 'index']
}

Теперь Webpack сначала проверит:

layout/main.js

resolve.roots

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

Пример:

resolve: {
    roots: [
        path.resolve(__dirname, 'src')
    ]
}

Импорт:

import '/components/Button';

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

src/components/Button

Символические ссылки и symlinks

Поведение по умолчанию

Webpack разворачивает symlink:

resolve: {
    symlinks: true
}

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

  • npm link;
  • yarn link;
  • monorepo;
  • локальных пакетов.

resolve: {
    symlinks: false
}

Иногда улучшает производительность.


Кэширование разрешения модулей

Разрешение модулей — дорогая операция.

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

resolve: {
    unsafeCache: true
}

Что ускоряется

Кэшируются:

  • найденные пути;
  • результаты проверки расширений;
  • package.json;
  • alias-резолвинг.

Fully Specified Imports

В Webpack 5 появилась поддержка строгих ESM-импортов.


Проблема

Некоторые ESM-модули требуют полного пути:

import utils from './utils.js';

Без расширения импорт может не работать.


Настройка

resolve: {
    fullySpecified: false
}

Позволяет использовать старый стиль:

import utils from './utils';

Condition Names

Webpack учитывает условия экспорта пакетов.

Пример:

{
    "exports": {
        ".": {
            "import": "./esm.js",
            "require": "./cjs.js"
        }
    }
}

Выбор версии

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

resolve: {
    conditionNames: ['import', 'require']
}

и подбирает нужный файл.


exports в package.json

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

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

Ограничение доступа

Если файл не указан в exports, импорт невозможен:

import x from 'library/internal/file';

Webpack выдаст ошибку.


Разница между Node.js и Webpack

Несмотря на похожий алгоритм, есть отличия.


Node.js

Node ориентирован на выполнение кода.


Webpack

Webpack ориентирован на сборку.

Он дополнительно поддерживает:

  • alias;
  • browser field;
  • loaders;
  • virtual modules;
  • tree shaking;
  • conditionNames;
  • fallback;
  • resolve plugins.

Resolve Plugins

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

Пример:

resolve: {
    plugins: []
}

Через плагины можно:

  • изменять пути;
  • генерировать виртуальные файлы;
  • подключать PnP;
  • интегрировать TypeScript paths.

Практический пример полного алгоритма

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

resolve: {
    extensions: ['.js', '.jsx'],
    modules: ['src', 'node_modules'],
    alias: {
        '@': path.resolve(__dirname, 'src')
    }
}

Импорт:

import Button from '@/components/Button';

Webpack:

  1. Находит alias @.
  2. Подставляет src.
  3. Формирует путь:
src/components/Button
  1. Проверяет:
Button.js
Button.jsx
Button/index.js
Button/index.jsx
  1. Использует первый найденный файл.

Ошибки разрешения модулей

Module not found

Самая распространённая ошибка:

Module not found: Error: Can't resolve ...

Основные причины

Неверный относительный путь

import Button from './components/Button';

при отсутствии файла.


Ошибка регистра

Linux чувствителен к регистру:

Button.js

и:

button.js

— разные файлы.


Отсутствующее расширение

Если расширение не входит в resolve.extensions.


Ошибка alias

Неверная настройка:

alias: {
    '@': './src'
}

вместо:

path.resolve(...)

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

npm install lodash

не был выполнен.


Производительность разрешения модулей

В больших проектах резолвинг может занимать значительное время.


Что замедляет сборку

Огромное количество alias

Каждый alias увеличивает объём проверок.


Большое число extensions

Каждый импорт приводит к серии обращений к файловой системе.


Глубокие node_modules

Сложная структура зависимостей увеличивает число операций поиска.


Символические ссылки

Symlink увеличивают стоимость поиска файлов.


Оптимизация

Минимальный набор extensions

extensions: ['.js']

лучше, чем:

extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']

если TypeScript не используется.


Аккуратное использование alias

Только для ключевых директорий.


Кэширование

cache: {
    type: 'filesystem'
}

значительно ускоряет повторные сборки.


Особенности монорепозиториев

В monorepo алгоритм становится сложнее.

Структура:

packages/
├── app
├── ui
├── core

Webpack может:

  • искать зависимости в корневом node_modules;
  • использовать symlink;
  • разрешать workspace-пакеты;
  • работать через Yarn PnP.

Yarn Plug’n’Play

PnP полностью убирает node_modules.

Webpack начинает использовать специальный резолвер.


Особенности

Вместо поиска по файловой системе:

node_modules/react

используется карта зависимостей.

Это ускоряет разрешение модулей и уменьшает размер проекта.


Enhanced Resolve

Внутри Webpack используется библиотека:

enhanced-resolve

Она отвечает за:

  • alias;
  • extensions;
  • mainFields;
  • exports;
  • conditionNames;
  • symlink;
  • package.json;
  • кэширование.

Последовательность полного алгоритма

При импорте:

import x from '@/utils/math';

Webpack:

  1. Проверяет тип пути.
  2. Применяет alias.
  3. Формирует абсолютный путь.
  4. Проверяет файл.
  5. Перебирает extensions.
  6. Проверяет директории.
  7. Ищет mainFiles.
  8. Анализирует package.json.
  9. Учитывает exports.
  10. Проверяет conditionNames.
  11. Разрешает symlink.
  12. Кэширует результат.

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