12k
All articles

Как JavaScript наконец научился убирать за собой

Явное управление ресурсами JavaScript с using, await using, DisposableStack и SuppressedError для детерминированной очистки файлов, блокировок, сокетов и подключений.

OpenReplay Team
OpenReplay Team
Как JavaScript наконец научился убирать за собой

Новая возможность JavaScript — Explicit Resource Management — объявления using и await using, основанные на Symbol.dispose и Symbol.asyncDispose, — предоставляет языку детерминированную, привязанную к области видимости очистку не связанных с памятью ресурсов: файловых дескрипторов, сокетов, блокировок и соединений с базами данных. Это не сборка мусора. Сборщик мусора освобождает память по собственному недетерминированному расписанию и никогда не закрывал ваши файлы, не снимал блокировки и не отписывал обработчики событий. Именно с такими ресурсами работает using — предсказуемо, в момент выхода из области видимости.

Если вы вручную писали блоки try/finally для закрытия соединений и снятия блокировок, эта возможность заменяет весь этот шаблонный код одним ключевым словом. В статье объясняется, как работает using, в чём разница между синхронным и асинхронным вариантами, как создавать собственные disposable-объекты, как координировать несколько ресурсов с помощью DisposableStack, как адаптировать библиотеки без встроенной поддержки и как ошибки при освобождении ресурсов передаются через новый тип SuppressedError. (WeakRef и FinalizationRegistry — это инструменты, смежные со сборщиком мусора, для работы с памятью; данный механизм принципиально иной.)

Ключевые выводы

  • Объявление using вызывает метод [Symbol.dispose]() объекта при выходе из охватывающего блока; await using вызывает [Symbol.asyncDispose]() и ожидает его выполнения, так что асинхронная очистка завершается до закрытия области видимости.
  • Когда несколько ресурсов находятся в одной области видимости, они освобождаются в обратном порядке объявления: зависимый ресурс уничтожается раньше того, от которого он зависит.
  • Explicit Resource Management — это завершённое предложение TC39, стандартизированное в ES2026, реализованное нативно в Chrome 134 (V8 13.8), Firefox 141 и Node.js 24 (V8 13.6); Safari пока отстаёт — символ Symbol.dispose появился в десктопном Safari 26.4, но ещё не в Safari для iOS.
  • DisposableStack.prototype.move() переносит зарегистрированные ресурсы в новый стек и помечает исходный как освобождённый без фактического уничтожения ресурсов — это паттерн для безопасной передачи частично созданных ресурсов за пределы конструктора.
  • Если при освобождении ресурса возникает исключение, когда код уже находится в состоянии ошибки, JavaScript оборачивает оба в SuppressedError, чтобы ни исходный сбой, ни ошибка очистки не были потеряны.

Проблема очистки, которую try/finally так и не решил

Каждый открытый ресурс — файловый дескриптор, потоковый reader, соединение с базой данных, блокировка — должен быть закрыт, и единственным языковым инструментом для гарантии этого был try/finally. Он работает, но многословен и плохо масштабируется. Рассмотрим reader для Web Streams: если в процессе чтения возникает ошибка и вы забываете вызвать releaseLock() до её распространения, поток остаётся заблокированным. Решение — обернуть цикл чтения в блок try и поместить reader.releaseLock() в finally, чтобы блокировка всегда снималась.

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

Как работает ключевое слово using

Ключевое слово using объявляет привязку с областью видимости блока, которую нельзя переназначить (как const): её значение должно быть null, undefined или объектом с методом [Symbol.dispose](); когда переменная выходит из области видимости, этот метод запускается автоматически. Используйте using для синхронной очистки. Используйте await using, когда завершение работы возвращает промис — это объявление переменных с областью видимости блока, которые освобождаются асинхронно: значение должно иметь метод [Symbol.asyncDispose]() или [Symbol.dispose](), и этот метод вызывается и ожидается при выходе переменной из области видимости. Поскольку await using также принимает обычные синхронные disposable-объекты, его можно использовать всегда, когда вы не знаете, какой именно тип перед вами.

import fs from "node:fs/promises";

async function readConfig() {
  await using file = await fs.open("config.json", "r");
  const { buffer } = await file.read();
  return buffer.toString();
  // file[Symbol.asyncDispose]() вызывается и ожидается здесь, на каждом пути выхода
}

Объекты FileHandle в Node.js уже реализуют протокол асинхронного disposable, поэтому это работает без ручной обёртки. Обратите внимание на два оператора await: await fs.open() ожидает получения ресурса и разворачивает промис в FileHandle, тогда как await using ожидает освобождения при выходе переменной из области видимости.

Правило порядка особенно важно, когда ресурсы зависят друг от друга. Если область видимости содержит несколько объявлений using или await using, все обработчики освобождения выполняются последовательно в обратном порядке объявления, независимо от типа объявления, и все гарантированно запускаются — как в блоке finally.

await using db = await openConnection();      // освобождается вторым
await using tx = await db.beginTransaction(); // освобождается первым
// tx завершается до db, поэтому соединение ещё живо при финализации транзакции

О контексте применения: using допустим внутри любого блока, тела функции, блока статической инициализации, заголовка for и на верхнем уровне модуля — но не на верхнем уровне скрипта, поскольку области видимости скриптов сохраняются и обработчик освобождения никогда не сработает. await using на верхнем уровне доступен только в модулях, так как зависит от top-level await. Правила в соответствии со спецификацией задокументированы в справочнике MDN по using.

Создание собственного disposable-объекта

Любой объект становится disposable, если реализует [Symbol.dispose]() для синхронной очистки или [Symbol.asyncDispose]() для асинхронной — никакого базового класса или шага регистрации не требуется. Общеизвестный символ и есть контракт: объект является disposable, если у него есть метод [Symbol.dispose](), а объявление using ищет этот символ на инициализаторе, чтобы определить метод для вызова при выходе переменной из области видимости.

Вот обёртка для соединения с базой данных, которую мы будем использовать далее по всей статье:

class DatabaseConnection {
  #client;
  constructor(client) {
    this.#client = client;
  }
  query(sql, params) {
    return this.#client.query(sql, params);
  }
  async [Symbol.asyncDispose]() {
    await this.#client.end();
  }
}

async function getUser(pool, id) {
  await using db = new DatabaseConnection(await pool.acquire());
  return await db.query("SELECT * FROM users WHERE id = $1", [id]);
  // db[Symbol.asyncDispose]() выполняется здесь — клиент освобождается на каждом пути
}

Синхронный обработчик освобождения имеет ту же структуру, но с [Symbol.dispose](). Синхронный обработчик не должен возвращать промис, поскольку промисы, возвращаемые [Symbol.dispose](), не ожидаются await using; для объявления асинхронных disposable-объектов используйте Symbol.asyncDispose.

Координация ресурсов с помощью DisposableStack

Когда один объект владеет несколькими ресурсами — или когда вы получаете ресурсы в цикле — DisposableStack и AsyncDisposableStack собирают их и освобождают всю группу сразу в обратном порядке. Обе структуры предоставляют методы use(), adopt() и defer() для добавления ресурсов или действий по освобождению, а также методы dispose() или asyncDispose() для запуска очистки. Они сами несут [Symbol.dispose]() / [Symbol.asyncDispose](), поэтому работают с using и await using.

Три метода регистрации охватывают разные сценарии:

МетодРегистрируетКогда использовать
use(resource)Ресурс, у которого уже есть символ disposeОбъект сам является disposable
adopt(value, onDispose)Не-disposable значение плюс колбэк очисткиУ ресурса есть метод close/abort, но нет символа
defer(onDispose)Отдельный колбэк очистки без ресурсаНужно выполнить произвольную очистку (например, clearInterval)
function startWorker() {
  using stack = new DisposableStack();
  const handle = setInterval(poll, 5000);
  stack.defer(() => clearInterval(handle));
  stack.adopt(openSocket(), (s) => s.close());
  // обе очистки выполняются в обратном порядке при выходе из блока
}

Неочевидная мощь кроется в move(), который решает реальную проблему безопасности при конструировании. Иногда области видимости функции недостаточно — класс или объект владеет несколькими ресурсами, которые должны быть сгруппированы как при using, но в виде поля класса или замыкания; именно для этого и предназначены DisposableStack и AsyncDisposableStack. DisposableStack.prototype.move() переносит все зарегистрированные ресурсы в новый стек и помечает исходный как освобождённый без фактического освобождения ресурсов — это позволяет локально накапливать ресурсы и передавать их наружу только при полном успехе конструирования:

function openResources() {
  using cleanup = new DisposableStack();
  const a = cleanup.use(openA());
  const b = cleanup.use(openB()); // если выбросит исключение, `a` освобождается автоматически
  const moved = cleanup.move();   // успех: владение передаётся наружу, ничего не освобождается
  return moved;                   // вызывающий код теперь отвечает за освобождение обоих ресурсов
}

Если openB() выбрасывает исключение, блок завершается и cleanup освобождает a — утечки нет. Если всё проходит успешно, move() передаёт ресурсы вызывающему коду в целости. Семантика move() задокументирована в материале V8 Explicit Resource Management.

Адаптация библиотек без поддержки освобождения ресурсов

Чтобы использовать using с библиотекой, не нужно ждать, пока она добавит поддержку Symbol.dispose — прикрепите символ самостоятельно с помощью Object.assign. Клиент библиотеки, у которого есть метод close() или end(), но нет символа dispose, становится disposable в одну строку. Присвоение метода Symbol.asyncDispose клиенту позволяет использовать его в объявлениях await using и с AsyncDisposableStack#use(), а если вы впоследствии обновитесь до версии библиотеки с нативной поддержкой протокола, вы получите ошибку, напоминающую об удалении заглушки.

const client = await MongoClient.connect(url);
Object.assign(client, {
  async [Symbol.asyncDispose]() {
    await client.close();
  },
});

async function run() {
  await using db = client; // теперь освобождается корректно
  // ...
}

Заглушка через Object.assign — это мост для текущей экосистемы: большинство сторонних клиентов ещё не добавили символы dispose, и этот подход позволяет подключить их уже сегодня.

Применение на фронтенде и в Node

На клиентской стороне утечки возникают с обработчиками событий, экземплярами IntersectionObserver/ResizeObserver, navigator.locks, заблокированными Web Streams и транзакциями IndexedDB — всем тем, что требует явного шага освобождения, который легко пропустить на пути ошибки. Оборачивание их в disposable-объект привязывает время жизни к области видимости. Канонический пример команды V8 — reader потока: объявление using над объектом, чей [Symbol.dispose]() вызывает reader.releaseLock(), полностью избавляет от необходимости помнить об освобождении.

Осиротевшие наблюдатели и неснятые блокировки — это именно те утечки, которые не видны при беглом локальном тестировании. Осиротевший observer или неснятая блокировка ничего не стоят на странице, которую вы перезагружаете каждые несколько секунд, но за долгую сессию в одностраничном приложении они накапливаются в постепенный рост потребления памяти и задержки взаимодействия — медленно нарастающая деградация, которую полная запись сессии выявляет там, где разовая перезагрузка ничего не воспроизводит. Ограничение времени жизни таких ресурсов с помощью using — это структурное решение для целого класса подобных ошибок.

На серверной стороне объекты FileHandle из fs/promises в Node.js и многие типы соединений уже поддерживают этот механизм, а пользовательские обёртки вроде DatabaseConnection выше покрывают остальное.

Обработка ошибок при освобождении ресурсов с помощью SuppressedError

Освобождение ресурса может завершиться с ошибкой, причём когда код уже находится в состоянии ошибки — без определённого поведения одна ошибка молча перезаписала бы другую. JavaScript решает эту проблему с помощью SuppressedError. Все ошибки, выброшенные в процессе освобождения, включая исходную ошибку, вызвавшую выход из области видимости, агрегируются в один SuppressedError — каждое предшествующее исключение как свойство suppressed, а последующее как свойство error — и он выбрасывается после завершения всей очистки.

Таким образом, если ваш блок выбрасывает исключение, а затем обработчик освобождения тоже выбрасывает, вы перехватываете единственный SuppressedError, чей .error — это ошибка освобождения, а .suppressed — исходная. Когда несколько обработчиков освобождения выбрасывают исключения, они вкладываются друг в друга, так что каждый сбой доступен:

try {
  using a = makeDisposableThatThrowsOnDispose("a");
  using b = makeDisposableThatThrowsOnDispose("b");
  throw new Error("body failed");
} catch (e) {
  // e — это SuppressedError; b освобождается первым, a — последним, поэтому
  // e.error — ошибка освобождения a, а e.suppressed вкладывает остальное
  // (ошибку освобождения b, затем исходную "body failed")
}

SuppressedError — это то, что делает using надёжным инструментом в коде с активной обработкой ошибок.

Текущая поддержка: уже доступно, не «скоро»

Explicit Resource Management — это завершённое предложение TC39, стандартизированное как часть ES2026, а не эксперимент на стадии Stage 3, требующий повсеместной транспиляции. По состоянию на середину 2026 года картина такова:

Среда выполненияСтатусПримечания
Chrome / Edge / OperaРеализованоusing/await using начиная с Chromium 134 (V8 13.8); символ Symbol.dispose присутствует с Chrome 125
Node.jsРеализованоНативные using/await using в Node.js 24 (V8 13.6); символы — с версии 18.18.0
FirefoxРеализованоНачиная с Firefox 141 (текущая стабильная версия 152)
SafariЧастичноСимвол Symbol.dispose появился в десктопном Safari 26.4; Safari для iOS пока не поддерживает
TypeScriptРеализованоСинтаксис using с версии 5.2; текущая стабильная версия 6.0

Стоит отметить одну ловушку: наличие символа Symbol.dispose не означает, что движок умеет разбирать using. Символ, как правило, появляется раньше объявления — Node добавил общеизвестные символы ещё в ветке v18 (18.18.0), но нативный синтаксис объявления появился только в Node 24, а Chrome добавил символ примерно в v125, но не разбирал using до версии 134. Таким образом, на старом движке можно обращаться к Symbol.dispose и при этом получать синтаксическую ошибку или TypeError «not disposable» — таблица поддержки Symbol.dispose (по ссылке выше) опережает реальную поддержку using. Полная поддержка using — это вопрос версии движка, а не наличия полифилла.

Для Safari и устаревших целевых сред используйте транспиляцию. TypeScript требует установить целевую версию компиляции ES2022 или ниже и настроить lib так, чтобы включить "esnext" или "esnext.disposable" (TypeScript поддерживает синтаксис начиная с версии 5.2); глобальные объекты можно полифиллировать с помощью core-js или пакета disposablestack.

Механический, подверженный ошибкам код очистки, который вы писали вручную, теперь имеет языковой примитив: добавьте [Symbol.dispose] или [Symbol.asyncDispose] к любому объекту, требующему завершения работы, объявите его с помощью using или await using, и среда выполнения гарантирует освобождение в правильном порядке на каждом пути выхода. Начните с обёртки одного ресурса, который вы чаще всего забываете закрыть — соединения, блокировки, reader — и доверьте остальное области видимости.

Часто задаваемые вопросы

В чём разница между using и await using?

Объявление using вызывает синхронный метод Symbol.dispose объекта при выходе из блока, тогда как await using вызывает Symbol.asyncDispose и ожидает его выполнения, так что асинхронная очистка завершается до закрытия области видимости. Используйте using, когда очистка синхронная, и await using, когда завершение работы возвращает промис. Поскольку await using также принимает обычные синхронные disposable-объекты, его можно использовать всегда, когда вы не уверены, какой тип обработчика несёт объект.

Почему using выбрасывает ошибку 'not disposable' в Node 18, хотя Symbol.dispose существует?

Наличие символа Symbol.dispose не означает, что движок умеет разбирать объявление using. Node добавил общеизвестные символы Symbol.dispose и Symbol.asyncDispose начиная с версии 18.18.0, но полный синтаксис объявлений using и await using стал нативным только в Node 24, поставляемом с V8 13.6. На более старых версиях Node можно обращаться к символу и при этом получать синтаксическую ошибку или TypeError на встроенных дескрипторах. Полная поддержка using — это вопрос версии движка, а не наличия полифилла.

Заменяет ли Explicit Resource Management сборку мусора?

Нет. Сборщик мусора освобождает память по собственному недетерминированному расписанию и никогда не закрывал файлы, не снимал блокировки и не отписывал обработчики событий. Explicit Resource Management детерминированно управляет такими не связанными с памятью ресурсами, выполняя их очистку в момент выхода из области видимости. Два механизма решают разные задачи. WeakRef и FinalizationRegistry — это инструменты, смежные со сборщиком мусора, для работы с памятью, тогда как using и await using обеспечивают привязанное к области видимости освобождение файловых дескрипторов, сокетов, блокировок и соединений.

Как использовать ключевое слово using с библиотекой, у которой нет метода dispose?

Прикрепите символ самостоятельно с помощью Object.assign. Клиент, у которого есть метод close или end, но нет символа dispose, становится disposable в одну строку: достаточно присвоить асинхронный метод Symbol.asyncDispose, вызывающий существующий метод close. После этого объект можно объявить с помощью await using или зарегистрировать вызовом use на AsyncDisposableStack. Если вы впоследствии обновитесь до версии библиотеки с нативной реализацией протокола, переприсвоение вызовет ошибку, напоминающую об удалении заглушки.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.