Алгоритм разрешения модулей: как Node находит твой код
CJS require() выполняет синхронный алгоритм — классифицировать спецификатор, пробовать файл, затем директорию, затем index, вверх по node_modules — тогда как ESM разрешает URL с обязательными расширениями. Знание точных шагов превращает MODULE_NOT_FOUND из загадки в чек-лист.
Error: Cannot find module './utils' — а файл вот он, ты видишь utils.ts в редакторе. Или наоборот: на ноутбуке всё работает, а в контейнере падает ERR_MODULE_NOT_FOUND на спецификаторе, который не менялся. Оба случая — из одного источника: точный многошаговый алгоритм разрешения, который запускается на каждый require() или import, и делает он не то, что ты предполагаешь. Он пробует расширения в фиксированном порядке, обращается с директорией иначе, чем с файлом, и — под ESM — вовсе отказывается угадывать расширение. Как только ты можешь прогнать алгоритм в голове, эти ошибки перестают быть везением.
Три вида спецификатора и развилка, которую они запускают
Прежде чем Node посмотрит хоть на один байт на диске, он классифицирует спецификатор — строку, переданную в require() или import, — в один из трёх видов, потому что каждый идёт своим путём через алгоритм:
- Относительный — начинается с
./,../или/.require("./utils"). Разрешается относительно директории импортирующего файла. - Голый (он же «пакетный») — имя без ведущей точки или слэша.
require("express"),import "lodash/fp". Запускает обходnode_modules. - Абсолютный / URL — полный путь в файловой системе, либо под ESM — URL вида
file:/node:/data:.import "node:fs".
require("./parser"); // относительный → рядом с текущим файлом
require("parser"); // голый → поиск в node_modules вверх по дереву
require("/opt/app/p.js"); // абсолютный → путь используется напрямуюЭто первое решение чаще всего пропускают. "./utils" и "utils" выглядят почти одинаково, но идут через совершенно разные механизмы: первый никогда не трогает node_modules, второй никогда не смотрит рядом с твоим файлом. Забытый ./ — или случайно добавленный — отправляет разрешение по совсем другой ветке.
CJS: пробуем файл, затем директорию, затем index
Для относительного спецификатора в CommonJS Node берёт путь и пробует, в строгом порядке, пока что-то не сработает:
- Как точный файл.
LOAD_AS_FILE(X): пробуемXбуквально, затемX.js, затемX.json, затемX.node(скомпилированный аддон). Это проба расширений —require("./utils")тихо становится./utils.js. Порядок фиксирован: соседнийutils.jsвсегда выигрывает уutils.json. - Как директорию. Если
X— директория,LOAD_AS_DIRECTORY(X): читаемX/package.jsonи следуем его полюmain(снова разрешая как файл); еслиpackage.jsonнет или нетmain, откатываемся кX/index.js, затемX/index.json, затемX/index.node. - Провал. Если ни то ни другое не сработало, бросаем
MODULE_NOT_FOUND.
// require("./shapes") пробует, по порядку:
// ./shapes (точный файл — обычно его нет)
// ./shapes.js
// ./shapes.json
// ./shapes.node
// ./shapes/ → её package.json "main", иначе ./shapes/index.js …Два вывода для загадки Cannot find module './utils'. Во-первых, CJS не разрешает .ts — require знает только .js/.json/.node, поэтому голый ./utils, указывающий на utils.ts, упадёт, пока загрузчик (ts-node, tsx, сборщик) не научит Node этому расширению. Во-вторых, папка «просто работает» только из-за отката на index.js; удали index — и тот же require("./shapes") сломается, хотя директория на месте.
Обход node_modules: голые спецификаторы карабкаются вверх по дереву
Голый спецификатор запускает самую характерную часть алгоритма: NODE_MODULES_PATHS. У Node нет одной глобальной директории библиотек. Вместо этого, начиная с директории импортирующего файла, он ищет ./node_modules/<pkg>, и если её там нет — поднимается на одну директорию вверх и пробует снова, повторяя до самого корня файловой системы.
импорт в /app/src/api/handler.js → require("lodash")
ищем /app/src/api/node_modules/lodash
ищем /app/src/node_modules/lodash
ищем /app/node_modules/lodash ← найдено здесь
ищем /node_modules/lodash (только если всё ещё не найдено)Найдя директорию lodash, он разрешает внутри неё той же логикой файл/директория/index (учитывая main или exports этого пакета). Этот обход вверх — ровно причина, по которой зависимость, установленная в корне проекта, видна глубоко вложенным файлам, и по которой две копии библиотеки на разной глубине могут сосуществовать — каждый импортёр разрешается к ближайшей. Это же объясняет, почему MODULE_NOT_FOUND на голом имени почти всегда значит «не установлено на этом уровне или выше», а не «опечатка в имени файла».
▸Почему это работает
Этот обход вверх — механизм за npm-овской дедупликацией зависимостей и за пресловутым багом «два React». Пакет, поднятый в /app/node_modules, разделяется всеми импортёрами ниже. Но если транзитивная зависимость закрепляет другую версию, npm вкладывает вторую копию в её node_modules, и импортёр там разрешается к вложенной копии. Теперь существуют два экземпляра модуля «той же» библиотеки, каждый со своим состоянием — что ломает всё, опирающееся на синглтон (React-хуки, проверки instanceof). Фикс — дедупнуть или поднять до единой версии, а понимание обхода — то, что подсказывает, к чему каждый импортёр на самом деле разрешается.
Разрешение в ESM: URL, обязательные расширения, без index
ESM выполняет другой алгоритм, и различия — ровно там, где живут падения между средами. ESM разрешает всё в URL (обычно file:), и эта смена рамки меняет правила:
- Расширения обязательны.
import "./utils.js"работает;import "./utils"бросаетERR_MODULE_NOT_FOUND. Нет пробы расширений и нет отката наindex.jsдля относительных спецификаторов — пишешь полный путь, или падаешь. (Это самый частый сюрприз при миграции CJS-кода на ESM.) - Голые спецификаторы всё ещё используют обход
node_modules, но точка входа пакета выбирается полемexports/main, а подпути должны быть явно экспонированы (следующий урок). node:явный. Встроенные модули разрешаются через схемуnode:(import "node:fs"); голая формаimport "fs"для встроенных всё ещё работает, ноnode:однозначна и современный дефолт.
import { readFile } from "node:fs/promises"; // встроенный, явная схема
import { parse } from "./parser.js"; // расширение ОБЯЗАТЕЛЬНО
import express from "express"; // голый → обход node_modules + exports
// import { parse } from "./parser"; // ✗ ERR_MODULE_NOT_FOUND под ESM| Поведение | CommonJS require() | ESM import |
|---|---|---|
| Расширение в относительном пути | Необязательно — пробуется (.js, .json, .node) | Обязательно — пишется полностью |
Откат на index в директории | Да (index.js …) | Нет |
| Во что разрешается | В путь файловой системы | В URL (file:, node:, data:) |
| Голый спецификатор | обход node_modules + main | обход node_modules + exports |
| Встроенный модуль | require(“fs”) | import “node:fs” (явная схема) |
Поля package.json, рулящие разрешением
Три поля package.json решают, как разрешается пакет (голый импорт), и они образуют цепочку приоритета:
"type"—"module"делает.js-файлы в этом пакете ESM;"commonjs"или отсутствие делает их CJS. Управляет тем, как интерпретируется найденный файл, а не где он находится."main"— устаревшая точка входа: файл, в который разрешается голыйrequire("pkg")/import "pkg", когдаexportsотсутствует."main": "./lib/index.js"."exports"— современная карта входов. При наличии перекрываетmainи становится авторитетным: может направлятьimportиrequireна разные файлы и, главное, блокирует любой путь, не указанный в нём (полностью — в следующем уроке)."module"— нестандартное поле только для сборщиков — Node его игнорирует.
Приоритет и есть подвох: если пакет поставляет поле exports, main игнорируется Node, и глубокий импорт вроде require("pkg/lib/internal"), работавший годами, может вдруг бросить ERR_PACKAGE_PATH_NOT_EXPORTED после того, как пакет добавил exports. Файл по-прежнему существует; резолвер теперь отказывается на него смотреть.
Тот же import('./parser') успешен в CommonJS-файле, но бросает ERR_MODULE_NOT_FOUND, как только файл становится ESM. Почему?
require('lodash') в /app/src/api/handler.js падает с MODULE_NOT_FOUND, но lodash установлен в /app/node_modules. Что делает алгоритм?
TypeScript-проект компилируется в ESM. Относительные импорты падают с ERR_MODULE_NOT_FOUND в рантайме, потому что в сгенерированном .js нет расширений. Как починить надёжно?
- 01Пройди по шагам, что делает require('./shapes'), и где каждый шаг может упасть.
- 02Почему тот же спецификатор './parser' работает под CommonJS, но бросает ERR_MODULE_NOT_FOUND под ESM, и что эти две системы делают по-разному?
Разрешение модулей — детерминированный алгоритм, который запускается на каждый require() и import, и начинается он с классификации спецификатора на относительный (./, ../, /), голый (имя пакета) или абсолютный/URL — каждый идёт через свою машинерию. Для относительного спецификатора CommonJS выполняет LOAD_AS_FILE (пробует точный путь, затем расширения .js, .json, .node) и затем LOAD_AS_DIRECTORY (следует package.json main, иначе откатывается на index.js/json/node), бросая MODULE_NOT_FOUND, если оба промахнулись; поэтому расширения необязательны, а папки «просто работают». Для голого спецификатора CJS выполняет обход node_modules — начиная с директории импортирующего файла и карабкаясь вверх по дереву, проверяя node_modules на каждом уровне — что является механизмом за подъёмом зависимостей, дедупликацией и багом «две копии одной библиотеки». ESM выполняет другой, URL-ориентированный алгоритм, где расширения обязательны и нет отката на index, поэтому ’./parser’ надо писать ’./parser.js’; голые спецификаторы всё ещё обходят node_modules, но выбирают точку входа через поле exports пакета, а встроенные модули используют явную схему node:. Три поля package.json рулят разрешением пакета: “type” решает, считать ли найденный .js за CJS или ESM, “main” — устаревшая точка входа, а “exports” — при наличии — перекрывает main и становится авторитетным, поэтому его добавление может вдруг сломать глубокий импорт, работавший годами. Прогоняй алгоритм в голове — и MODULE_NOT_FOUND превратится из загадки в короткий чек-лист.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.