open atlas
↑ К треку
Node.js с нуля до senior NODE · 13 · 01

Алгоритм разрешения модулей: как Node находит твой код

CJS require() выполняет синхронный алгоритм — классифицировать спецификатор, пробовать файл, затем директорию, затем index, вверх по node_modules — тогда как ESM разрешает URL с обязательными расширениями. Знание точных шагов превращает MODULE_NOT_FOUND из загадки в чек-лист.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

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 берёт путь и пробует, в строгом порядке, пока что-то не сработает:

  1. Как точный файл. LOAD_AS_FILE(X): пробуем X буквально, затем X.js, затем X.json, затем X.node (скомпилированный аддон). Это проба расширенийrequire("./utils") тихо становится ./utils.js. Порядок фиксирован: соседний utils.js всегда выигрывает у utils.json.
  2. Как директорию. Если X — директория, LOAD_AS_DIRECTORY(X): читаем X/package.json и следуем его полю main (снова разрешая как файл); если package.json нет или нет main, откатываемся к X/index.js, затем X/index.json, затем X/index.node.
  3. Провал. Если ни то ни другое не сработало, бросаем MODULE_NOT_FOUND.
// require("./shapes") пробует, по порядку:
//   ./shapes            (точный файл — обычно его нет)
//   ./shapes.js
//   ./shapes.json
//   ./shapes.node
//   ./shapes/           → её package.json "main", иначе ./shapes/index.js …

Два вывода для загадки Cannot find module './utils'. Во-первых, CJS не разрешает .tsrequire знает только .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 нет расширений. Как починить надёжно?

Вспомните перед уходом
  1. 01
    Пройди по шагам, что делает require('./shapes'), и где каждый шаг может упасть.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.