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

Кэш модулей: идентичность, синглтоны и дубликаты копий

Модуль Node — синглтон, ключом которого служит разрешённый абсолютный путь: первый require выполняет его один раз, а каждый последующий возвращает тот же объект. Две точки установки одного пакета дают два ключа — два экземпляра — и instanceof и общее состояние тихо ломаются.

NODE Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Ты выкатываешь клиент фича-флагов как модуль, который экспортирует один общий EventEmitter — каждая часть приложения подписывается на flags.on("change"), и месяцами всё просто работает. Потом ты добавляешь плагин, живущий в своём пакете и тянущий клиент флагов через транзитивную зависимость, и внезапно половина приложения никогда не видит изменения флагов. Ни ошибки, ни падения. Ты добавляешь логирование и обнаруживаешь, что эмиттеров два: слушатели приложения сидят на одном экземпляре, а emit плагина срабатывает на другом. Ничто в твоём коде не создавало второй — это сделал npm, установив клиент флагов по двум путям. Твой «синглтон» был синглтоном на разрешённый путь, а путей у тебя оказалось два.

Кэш ключуется разрешённым абсолютным путём

Когда ты вызываешь require("./config"), Node сначала разрешает спецификатор в единственный абсолютный путь — скажем, /srv/app/config.js — и использует этот путь как ключ в require.cache. В первый раз случается промах кэша: Node читает файл, оборачивает его в функцию, выполняет один раз и сохраняет получившийся объект module (его exports — это то, что ты получаешь) под этим ключом. Каждый последующий require(), разрешающийся в тот же путь, — это попадание в кэш: Node полностью пропускает выполнение и отдаёт ту же ссылку на объект. Холодное выполнение парсит и запускает файл (миллисекунды для реального модуля, который открывает пул или читает конфиг); попадание — это поиск по карте, микросекунды, часто разница в 100–1000×.

// config.js — evaluated exactly once
console.log("config evaluating");        // prints on the FIRST require only
module.exports = { db: "postgres://...", loadedAt: Date.now() };

// app.js
const a = require("./config");
const b = require("./config");
console.log(a === b);                     // true — same object reference
console.log(a.loadedAt === b.loadedAt);   // true — body never re-ran

// require.cache is keyed by the resolved absolute path
console.log(Object.keys(require.cache));  // [ '/srv/app/app.js', '/srv/app/config.js' ]
const id = require.resolve("./config");   // '/srv/app/config.js'
console.log(require.cache[id].exports === a); // true

Этот единственный механизм и есть причина, по которой модуль Node ведёт себя как синглтон. Если ты разместишь пул БД, объект конфига или шину EventEmitter на уровне модуля и экспортируешь её, кэш гарантирует, что каждый импортирующий разделяет единственный экземпляр — на каждый разрешённый путь существует ровно один объект module.exports, созданный при первой загрузке и переиспользуемый вечно. Ты не писал паттерн синглтона; кэш модулей и есть паттерн синглтона. Идентичность экземпляра, которую это даёт, — вся причина, по которой require("./pool") из сорока файлов разделяют один пул соединений, вместо того чтобы открыть сорок.

ESM тоже кэширует — но «отимпортировать» нельзя

ES-модули получают ту же гарантию однократности через другую структуру: карту модулей, ключуемую разрешённым URL модуля (file:///srv/app/config.mjs). Тело ES-модуля выполняется ровно один раз при первом импорте, и каждый последующий import этого URL возвращает то же пространство имён модуля. Критичное для синьора отличие — это аварийный выход, которого не существует: поддерживаемого способа инвалидировать кэш ESM нет. CommonJS позволяет залезть в require.cache и delete-нуть запись; у карты модулей ESM нет публичного API, чтобы выселить URL. «Отимпортировать» модуль нельзя.

// Единственный способ заставить ESM перевычислиться — сменить КЛЮЧ, то есть URL.
// Dev-трюк: cache-busting строка запроса делает URL другим:
const fresh = await import(`./plugin.mjs?t=${Date.now()}`); // NEW key → re-evaluates

Это работает, потому что ./plugin.mjs?t=1 и ./plugin.mjs?t=2 — это разные URL, так что каждый становится свежей записью в карте модулей. Но это утечка памяти, а не механизм перезагрузки: каждая отдельная строка запроса добавляет в карту модулей постоянную запись (модули никогда не собираются из неё сборщиком мусора), так что вотчер, который переимпортирует при каждом изменении файла, накапливает тысячи осиротевших экземпляров модулей и неуклонно наращивает RSS. Это допустимо в одноразовом dev-цикле; это никогда не продакшен-хотрелоад. Перезагрузка в продакшене означает новый процесс под супервизором, а не растущую карту модулей.

Почему это работает

Почему идентичность привязана к пути, а не к имени пакета? Потому что разрешение производит путь, и только путь однозначен. require("lodash") из /srv/app может разрешиться в /srv/app/node_modules/lodash/index.js, тогда как тот же вызов изнутри /srv/app/node_modules/some-plugin может разрешиться в /srv/app/node_modules/some-plugin/node_modules/lodash/index.jsвложенную копию, установленную из-за конфликта версий. То же имя, два пути, два ключа кэша, два полностью независимых экземпляра модуля. У кэша нет понятия «пакет lodash»; он знает только пути к файлам, так что идентичность пакета и идентичность модуля — не одно и то же в тот момент, когда npm кладёт пакет более чем в одно место.

Режим отказа: две копии, которые не знают друг о друге

Вот где кэш превращается в продакшен-инцидент. Когда две версии — или две точки установки даже одной и той же версии — пакета разрешаются в разные пути, ты получаешь два отдельных экземпляра модуля, у каждого свой module.exports, свои классы, своё состояние на уровне модуля. Последствия коварны, потому что ничто не падает:

  • instanceof тихо возвращает false через границу. class Token копии A и class Token копии B — это разные функциональные объекты с разными прототипами, так что Token, созданный A, проваливает проверку value instanceof Token относительно класса B. Твоя валидация просто отвергает валидные объекты или принимает невалидные, без всякой ошибки.
  • Две шины событий. Как в завязке: EventEmitter копии A и копии B никогда не видят emit друг друга. Подписчики одного глухи к другому.
  • Два «синглтона». Два объекта конфига, два реестра, два пула соединений — ты задаёшь значение на одном и читаешь undefined с другого.
  • React «Invalid hook call». React держит свой диспетчер в состоянии на уровне модуля; две копии React означают, что твой компонент рендерится против одной копии, а хуки разрешаются против другой, и React бросает Hooks can only be called inside a component / you might have more than one copy of React.
  • Расщеплённые реестры Symbol. Библиотека, использующая приватный для модуля Symbol как бренд, не может распознать объекты, помеченные её другой копией.
// node_modules/lib/index.js (copy A)        and a nested copy B at
// node_modules/plugin/node_modules/lib/index.js — SAME source, two paths
class Token {}
module.exports = { Token, make: () => new Token() };

// app pulls copy A; plugin pulls copy B
const a = require("lib");                 // resolves to copy A's path
const t = require("plugin").makeToken();  // built by copy B's Token
console.log(t instanceof a.Token);        // false — different class objects, no error

Диагностика механична, как только ты знаешь причину: npm ls <pkg> печатает дерево зависимостей и показывает, когда пакет появляется в двух версиях/местах; require.resolve("pkg") из двух точек вызова показывает два разных абсолютных пути — это и есть улика. Исправления нацелены на дедупликацию: npm dedupe сплющивает совместимые копии в одну, overrides (npm/pnpm) или resolutions (Yarn) форсируют единственную версию, объявление общей библиотеки как peerDependency (чтобы хост предоставлял одну копию, вместо того чтобы каждый пакет тащил свою) — стандартное исправление для экосистем плагинов, а сборщики предлагают явный dedupe/alias, чтобы схлопнуть копии в сборке. Цифры важны: две копии библиотеки в 300 КБ не просто рискуют корректностью — они примерно удваивают вес этой библиотеки в бандле и в резидентной памяти, потому что каждый путь — это отдельный модуль, который загружается и остаётся загруженным.

Выбери лучший вариант

Плагин в собственном пакете должен использовать ТУ ЖЕ общую шину EventEmitter, которую экспортирует хост-приложение, но npm устанавливает библиотеку шины по двум путям, так что плагин получает второй экземпляр. Какое исправление долговечно?

Хак хотрелоада delete require.cache[id]

Поскольку CommonJS выставляет require.cache как записываемый объект, заманчивый трюк — delete require.cache[require.resolve("./handler")], чтобы следующий require переисполнил файл, — хотрелоад для бедных. Он опасен, и все причины — это причины идентичности. Удаление записи кэша не меняет ничего, что уже держит старый exports: замыкание, зарегистрированный маршрут, слушатель события или другой модуль, захвативший const h = require("./handler"), по-прежнему указывает на старый объект. Так что теперь у тебя старый код работает для всего, что загрузилось до удаления, и новый код для всего, что после, — две версии живут одновременно, с несовпадениями instanceof между ними. Каждая перезагрузка ещё и утекает памятью: осиротевший старый экземпляр модуля (и всё, на что он ссылался) остаётся достижимым через эти устаревшие замыкания и никогда не собирается, так что куча долгоживущего вотчера растёт от перезагрузки к перезагрузке. И она тихо ломает гарантию синглтона — всю причину, по которой ты использовал модуль, — чеканя второй module.exports, пока первый ещё в ходу. Настоящий хотрелоад (тот эргономичный, что в dev-серверах) работает, снося и пересоздавая новый процесс или воркер, и никогда не мутируя кэш модулей одного процесса на месте.

Викторина

Класс, созданный одной копией пакета, проваливает `value instanceof Lib.Token` при проверке против класса другой копии — без всякой ошибки. Почему?

Викторина

Dev-инструмент переимпортирует ES-модуль при каждом сохранении файла через `import('./m.js?t=' + Date.now())`. Какова цена в долгом запуске?

Вспомните перед уходом
  1. 01
    Две части приложения получают «синглтон» EventEmitter из одного пакета, но события, эмитнутые одной, невидимы для другой. Пройди от симптома к корневой причине и исправлению.
  2. 02
    Почему `delete require.cache[id]` — опасный способ хотрелоада, и чем настоящая перезагрузка отличается?
Итог

Модуль Node — синглтон, ключом которого служит его разрешённый абсолютный путь: первый require() выполняет файл один раз и кэширует получившийся module.exports под этим путём в require.cache, а каждый последующий require() того же пути — попадание в кэш, возвращающее ту же ссылку на объект за микросекунды, вместо того чтобы перезапускать тело, — именно поэтому пул, конфиг или EventEmitter на уровне модуля общий повсюду. ESM даёт ту же гарантию однократности через карту модулей, ключуемую разрешённым URL, но без поддерживаемой инвалидации: «отимпортировать» нельзя, а dev-трюк ?t=… переисполняет лишь чеканя новый URL-ключ, утекая записями карты модулей, которые никогда не собираются. Плата за бесплатные синглтоны — сцепление с идентичностью пути, а режим отказа — дубликаты копий: когда один пакет разрешается в два пути установки, ты получаешь два экземпляра, так что instanceof тихо возвращает false, две шины событий никогда не видят друг друга, два «синглтон»-конфига расходятся, React бросает Invalid hook call, а библиотека в 300 КБ удваивается в бандле и RSS — диагностируется через npm ls / require.resolve, показывающие два пути, и исправляется через npm dedupe, overrides/resolutions или peerDependency, чтобы пакет разрешался в один путь. Наконец, delete require.cache[id] — опасный хак перезагрузки, потому что устаревшие замыкания держат старый exports (две версии живут одновременно, идентичность сломана), а осиротевшие экземпляры утекают; настоящая перезагрузка пересоздаёт процесс, а не мутирует кэш на месте.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.