import.meta в Node.js: URL, кэширование и состояние модулей¶
Источник: theNodeBook — import.meta: URLs, Caching & Module State
import.meta — объект метаданных на модуль для ES Modules в Node.js. Через него доступны расположение модуля и состояние, которое предоставляет загрузчик, без обёрточных переменных CommonJS. В Node v24 типичные поля: import.meta.url, import.meta.filename, import.meta.dirname, import.meta.main и import.meta.resolve().
import.meta и кэширование ESM в Node.js¶
Кэширование ESM отделено от require.cache. Загрузчик ESM индексирует записи модулей по URL и отслеживает фазы parsing, linking, evaluating и evaluated. При циклических зависимостях проявляются живые привязки, созданные на этапе linking — поэтому важно, в какой момент evaluation доходит до присваивания экспорту.
import.meta¶
В CJS __filename и __dirname «приходят сами» — их подставляет функция-обёртка модуля (см. главу 1). В ESM обёртки нет. Нет встроенных «магических» переменных на этапе загрузки. Информация о расположении модуля живёт на другом объекте — import.meta.
import.meta — объект, который предоставляет хост (среда выполнения). Спецификация ECMAScript задаёт только синтаксис — выражение import.meta — и почти ничего не говорит о том, какие у него должны быть свойства. Это оставлено хосту. В браузерах обычно есть лишь import.meta.url. Node добавляет больше полей, и набор расширялся в последних major-релизах.
Сам объект создаётся лениво. V8 выделяет его при первом обращении к import.meta в данном модуле. Если вы его не трогаете, V8 не вызывает callback инициализации Node. У каждого модуля свой экземпляр import.meta — общего объекта на весь граф модулей нет.
import.meta.url¶
У каждого ES-модуля import.meta.url — строка file:// URL, указывающая на исходный файл на диске.
1 2 | |
Схема URL важна. Это file:// с ведущим слэшем в authority — на Unix три слэша подряд (file:///home/...). В Windows что-то вроде file:///C:/Users/app/src/index.mjs. В path-компоненте всегда прямые слэши. Спецсимволы в именах каталогов кодируются: пробел → %20, # → %23.
Практическое применение — построение относительных путей. import.meta.url — полноценная URL-строка, её можно передать в конструктор URL как base:
1 2 3 | |
Конструктор URL разрешает относительный путь по правилам URL. На выходе объект URL, а .pathname — path-компонент. Но .pathname всё ещё URL-encoded. Файл /home/my app/data.json даст pathname /home/my%20app/data.json. Для нативного пути ОС (декодированного, с обратными слэшами в Windows) нужен url.fileURLToPath() из node:url.
До появления import.meta.filename и import.meta.dirname паттерн new URL + fileURLToPath был единственным способом получить аналог __filename и __dirname в ESM. Его везде писали в ранних ESM-кодовых базах:
1 2 3 4 5 | |
Шесть строк вместо «бесплатного» CJS. Легко ошибиться — забывали fileURLToPath, передавали file:// URL напрямую в fs.readFileSync и удивлялись, почему на Linux работало, а на Windows (с буквой диска) — нет.
import.meta.filename и import.meta.dirname¶
В Node v21.2.0 появились import.meta.filename и import.meta.dirname. В Node v24 они стабильны и делают ровно то, что ожидается.
1 2 3 4 5 | |
Это обычные пути файловой системы: без URL-encoding, без префикса file://, абсолютная строка, понятная ОС. import.meta.filename совпадает с fileURLToPath(import.meta.url). import.meta.dirname — с dirname(import.meta.filename). Старый шестистрочный паттерн устарел.
Нюанс: свойства есть только если модуль загружен с file:// URL. При загрузке по https:// (экспериментальные сетевые импорты Node) или с data: URL и filename, и dirname будут undefined. import.meta.url в таких случаях работает, но схема не file://. Для кода с диска (почти весь продакшен Node) этот крайний случай не важен.
Ещё нюанс: import.meta.filename следует symlink. Если /home/app/lib/index.mjs — симлинк на /home/shared/lib/index.mjs, import.meta.filename вернёт путь симлинка, по которому модуль реально загрузили, а не обязательно realpath. Поведение зависит от флага -preserve-symlinks.
import.meta.resolve()¶
import.meta.resolve() принимает спецификатор модуля и возвращает полностью разрешённую URL-строку в контексте текущего модуля.
1 2 3 | |
Значение всегда URL-строка; для локальных пакетов — со схемой file://. Для bare specifier вроде 'lodash' проходит алгоритм разрешения: node_modules, exports в package.json, conditions, subpath patterns и т.д. Для относительных путей вроде './utils.js' — относительно URL текущего модуля. Для встроенных модулей — node: URL: import.meta.resolve('fs') → 'node:fs'.
В Node v24 import.meta.resolve() синхронен. В спецификации изначально допускался Promise (в ранних версиях Node это было за флагом), но стабильным стало синхронное поведение. Метод разрешает путь, не загружает модуль и не выполняет код — только URL-строка.
Это ESM-аналог require.resolve() из CJS. Те же сценарии: где лежит пакет на диске, есть ли модуль (при неудаче — ERR_MODULE_NOT_FOUND), пути относительно зависимости, передача пути в другой API.
1 2 3 4 5 6 7 8 | |
Спецификатор разрешён в URL, URL превращён в путь, файл прочитан. Сам my-lib не импортировали — использовали только контекст разрешения, чтобы найти файл в пакете.
Частая ловушка: import.meta.resolve() учитывает поле exports в package.json целевого пакета. Если schema.json не экспортирован в exports, разрешение падает, хотя файл на диске есть. Резолвер уважает инкапсуляцию. require.resolve() в CJS в большинстве случаев обходит exports, поэтому при миграции CJS → ESM возможны неожиданные ошибки разрешения.
Как заполняется import.meta¶
V8 не знает про пути файлов и модульную систему Node. При evaluation, когда в исходнике встречается import.meta, вызывается хостовый hook. Node регистрирует его при bootstrap — importMetaInitializeCallback.
Callback получает два аргумента: объект import.meta (пустой, только что созданный V8) и внутреннюю запись Module. C++-слой Node берёт URL модуля из binding ModuleWrap (C++-класс над v8::Module) и заполняет свойства:
url— URL модуля изModuleWrap;filename—fileURLToPath()от URL; если схема неfile://, остаётсяundefined;dirname—dirname()от filename; тот же caveat;resolve— функция, привязанная к URL текущего модуля; внутри вызываетсяESMLoader.resolve()с parent = URL модуля, чтобы спецификаторы разрешались от правильного места.
Заполнение ленивое — callback срабатывает при первом обращении к import.meta в модуле. Модуль только с экспортами функций и без import.meta callback не вызовет. На больших деревьях зависимостей, где большинству модулей не нужен свой путь, это небольшая оптимизация.
Реализация: lib/internal/modules/esm/initialize_import_meta.js (в v24), C++ — ModuleWrap::InitializeImportMeta в src/module_wrap.cc. JavaScript-файл небольшой — порядка 30 строк; сложность в мосте C++ между hooks V8 и слоем JS Node.
Кэширование модулей¶
И CJS, и ESM кэшируют загруженные модули. После load + evaluate повторный запрос того же модуля возвращает кэш без повторного чтения и выполнения исходника. В проекте из 500 файлов утилиту могут require или import из 200 мест — файл читается и выполняется один раз, все потребители получают один результат.
Механика кэша — структуры данных, ключи, что делать со «устаревшими» записями — у систем сильно расходится.
Module._cache¶
При require('./foo') Node разрешает путь в абсолютный filename (например /home/app/foo.js) и смотрит Module._cache['/home/app/foo.js']. При попадании сразу возвращается кэшированный module.exports — без чтения файла, компиляции и evaluation.
Module._cache — обычный JavaScript-объект. Ключи — абсолютные пути после разрешения. Значения — экземпляры Module с .exports, .id, .filename, .loaded, .children, .paths.
require.cache — тот же объект:
1 2 | |
По require.cache видно все загруженные CJS-модули процесса. В среднем Express-приложении — тысячи записей.
Раз это обычный объект, из него можно удалять записи:
1 | |
Следующий require('./myModule') снова прочитает файл с диска, скомпилирует, выполнит и положит новую запись. Hot reload в dev так и устроен: nodemon и серверы на chokidar при изменении файла удаляют записи и перезагружают модули.
Но есть подвох. Удаление из кэша не обновляет уже существующие ссылки. Если другой модуль уже сохранил старый module.exports, он по-прежнему указывает на старую версию. Запись выгнали из кэша, а каждый уже выполненный const foo = require('./foo') держит локальный foo на устаревших exports. Старые замыкания живут.
Инвалидация кэша в CJS частичная. Новый require() получит свежие exports, старые ссылки — «осиротевшие» копии. Для нормального hot reload нужно перезагружать цепочку зависимостей от изменённого модуля до entry point. Некоторые библиотеки так и делают (граф зависимостей + транзитивная инвалидация), но это хрупко.
Ещё нюанс — module.children. Когда A делает require B, B попадает в children у A. Удаление B из require.cache не убирает B из children A. Для изоляции модулей между тестами иногда обходят и children.
Ключи кэша CJS и symlink¶
Ключ кэша — разрешённый filename: абсолютный путь после symlink через fs.realpathSync(). Если /home/app/node_modules/foo — симлинк на /home/shared/foo, ключ — /home/shared/foo/index.js. Два require() по разным symlink-путям к одному файлу попадут в одну запись кэша.
Есть и Module._realpathCache — без кэша realpath на каждый require() было бы слишком дорого.
Флаг -preserve-symlinks меняет поведение: ключом становится путь симлинка, а не цель. Один физический файл через два symlink даст две записи кэша и два экземпляра модуля. В monorepo с workspace symlink иногда это нужно, но может породить дубликаты модулей.
Module map (карта модулей ESM)¶
Кэш ESM устроен иначе. Он внутри загрузчика ESM — нет пользовательского import.cache. Публичного API нет. Карта живёт в lib/internal/modules/esm/module_map.js — экземпляр SafeMap (усиленный Map против prototype pollution).
Ключ — строка URL. import './foo.js' разрешается в file:///home/app/foo.js и ищется в карте. Запись есть — тот же экземпляр модуля.
Query и fragment — разные ключи:
1 2 3 | |
Один файл на диске — три экземпляра и три evaluation. ?v=1 и ?v=2 — часть URL. В dev этим иногда обходят кэш; каждая новая запись живёт до конца процесса. В цикле — утечка модулей.
Очистить карту ESM нельзя. Модуль загружен — загружен до exit. Это намеренно: у записей V8 Module необратимая state machine. Состояние «evaluated» нельзя откатить в «uninstantiated». Удаление из карты оставило бы live binding на освобождённые слоты.
Синглтоны через кэш¶
Раз CJS и ESM кэшируют модули, любое состояние на уровне модуля — синглтон на процесс: одна evaluation, один набор переменных, общий для всех импортёров.
1 2 3 4 5 6 7 8 | |
Все, кто импортирует counter.mjs, получают одни и те же живые привязки. increment() из любого места меняет один count.
В CJS то же через общий module.exports:
1 2 3 4 5 6 7 8 9 10 | |
Первый require('./counter') выполняет модуль и кэширует exports. Каждый следующий require — тот же объект. Замыкание над count общее.
Отдельный singleton-класс не нужен — кэш модулей всё делает сам, пока все потребители попадают в одну запись кэша.
Ловушка: разные записи кэша — разные экземпляры и разное состояние. Пакет в двух node_modules (корень и вложенная зависимость) — два evaluation, два пула БД, два config. Дубликаты в monorepo почти всегда от этого. npm ls помогает найти дубли.
Состояния модулей V8 и внутренности кэша¶
Карта ESM в Node — SafeMap по URL. Значения — ModuleJob: жизненный цикл от fetch исходника до evaluation.
Каждый ModuleJob оборачивает ModuleWrap (C++ между JS Node и v8::Module). У внутреннего Module в V8 поле status проходит строгую последовательность. Отсюда неизменяемость ESM-кэша и поведение циклов.
Uninstantiated. Исходник распарсен, запись модуля создана. V8 знает imports/exports по статическому анализу import/export, но слоты привязок ещё не выделены — пока метаданные, не «живые» значения.
Instantiating. Выделяются слоты, граф связывается. V8 обходит граф depth-first, для каждого import вызывает resolve hook Node, находит export и проводит import-слот к export-слоту на уровне памяти — не копия, а ссылка на привязку экспортёра.
При цикле, если модуль уже в состоянии instantiating, V8 не падает: фиксирует привязку как существующую, но возможно неинициализированную к моменту evaluation (TDZ).
Instantiated. Все привязки связаны. Код модулей ещё не выполнялся — слоты в TDZ.
Evaluating. Выполняется top-level код. Присваивания let/const/var заполняют export-слоты; импортёры видят значения через live binding. Если зависимый модуль читает export раньше присваивания (цикл) — ReferenceError, слот ещё в TDZ.
Evaluated. Evaluation успешна. Export-значения зафиксированы на момент завершения top-level (для let и мутабельных объектов изменения после evaluation импортёры всё равно видят). Статус необратим — отката нет.
Errored. Evaluation бросил исключение. Ошибка кэшируется на записи модуля; повторный доступ снова бросает ту же ошибку. Retry нет. Исправили файл — перезапуск процесса или в dev cache busting через query string и динамический import() с другим URL.
CJS: порядок и циклы¶
В Module._load() для нового модуля:
- Создаётся
Module,module.exports = {}. - Запись кладётся в
Module._cache[filename]до evaluation. - Компиляция (обёртка).
- Выполнение — заполнение
module.exports.
Шаг 2 раньше шага 4. Циклический require находит частично заполненный кэш и возвращает текущий module.exports — не ждёт конца evaluation.
ESM иначе: ModuleJob появляется рано, но слоты реально неинициализированы до присваивания. Нет «пустого объекта-заглушки» — есть TDZ и явный ReferenceError.
Инвалидация ESM намеренно невозможна: после evaluated V8 не сбрасывает модуль; слоты — адреса памяти, на которые ссылаются другие модули после linking. Удаление из карты Node оставило бы zombie live bindings.
CJS-кэш — обычный объект: delete require.cache[key] работает для dev hot reload, но грубо и недостаточно для сложной изоляции.
Циклические зависимости¶
Два модуля, импортирующие друг друга, — цикл. Обе системы не валят процесс, но поведение разное: одна и та же структура может работать в CJS и падать в ESM.
Частичные exports в CJS¶
Классический пример — a.js и b.js с взаимным require.
1 2 3 4 5 | |
1 2 3 4 | |
node a.js:
Node грузит a.js: module.exports = {}, в кэш, evaluation. module.exports.x = 1 → кэш { x: 1 }. require('./b') — грузится b.js. В b.js первой строкой require('./a') — в кэше уже a с { x: 1 } без y. a.js приостановлен на require('./b').
b.js ставит value: 42, логирует b sees a: { x: 1 }, возвращается. a.js продолжается: y: 2, лог a sees b: { value: 42 }.
1 2 | |
b.js видел снимок exports a.js — только то, что успели присвоить до циклического require. Свойство y в тот момент недоступно.
Нюанс: локальная переменная a в b.js — ссылка на тот же объект module.exports. Отложенный доступ видит полную картину:
1 2 3 4 | |
После завершения a.js вызов getA() вернёт { x: 1, y: 2 }.
Опасный случай — полная замена module.exports в конце a.js. Новый объект в кэше, а b.js держит старый {} с только x. Поэтому в циклах чаще добавляют свойства к module.exports, а не переназначают объект целиком.
Живые привязки и TDZ в ESM¶
В ESM циклы проходят через live binding: не снимок объекта, а слот в scope экспортёра. Слот может быть неинициализирован.
1 2 3 4 | |
1 2 3 4 | |
Parse → граф и цикл. Instantiate → слоты связаны. Evaluation: если начать с a.mjs, на console.log(..., value) слот value из b.mjs ещё в TDZ:
1 | |
В CJS вы получаете объект (возможно наполовину). В ESM — либо значение присвоено, либо TDZ. Бинарно.
После присваивания все видят актуальное значение сразу — без устаревших копий. Если порядок evaluation удачный:
1 2 3 4 5 | |
1 2 3 4 5 | |
К моменту вызова logCount() count уже 10 — чтение в момент вызова, не в момент статического import.
Экспорт функций — частый обход циклов: тело функции выполняется позже, к тому времени привязки обычно инициализированы. export const snapshot = count на top-level в цикле — TDZ или «раннее» значение.
Обнаружение и разрыв циклов¶
Циклы часто симптом переплетённой ответственности. Практика:
Обнаружение. В ESM — ReferenceError при старте. В CJS цикл тихий: частичные exports, undefined позже. Инструменты: madge -circular src/, dpdm для TypeScript.
Вынести общее в shared.js без обратных импортов в a/b.
Инверсия зависимостей — callback/интерфейс в runtime вместо статического import.
Ленивый require (CJS) — require() внутри функции:
1 2 3 | |
Динамический import (ESM):
1 2 3 4 5 6 | |
Статический граф без цикла; import() в момент вызова, когда b.mjs уже evaluated или грузится отдельно.
Циклы формально допустимы в обеих системах, но код, зависящий от порядка evaluation, хрупок. Рефакторинг entry point или импортов ломает такое незаметно. Разорвать граф почти всегда выгоднее.
Связанное чтение¶
- Предыдущая: CommonJS и ES Modules в Node.js: require(), import и dual packages
- Далее: Error-first callbacks в Node.js: паттерн колбэка и нативный dispatch