Привет, Хабр!

Сегодня мы рассмотрим тему управления памятью в JavaScript — и речь пойдет не о классическом сборщике мусора, а о возможностях с WeakRef и FinalizationRegistry. Эти инструменты помогают работать со слабыми ссылками и асинхронной финализацией объектов, открывая дорогу к более тонкой работе с памятью.

Если вам надоело, что объекты висят в памяти дольше, чем нужно, и хочется управлять ресурсами без лишних утечек — эта статья для вас. Начнем!

Обзор синтаксиса WeakRef и FinalizationRegistry

WeakRef

Итак, начнем с WeakRef. Это довольно свежая фича JavaScript (ES2021), которая позволяет создать "слабую" ссылку на объект, что означает: если этот объект больше нигде не используется, GC его может удалить, не дожидаясь, пока все ссылки будут явно сброшены.

Создание слабой ссылки — это супер просто:

const weakRef = new WeakRef(targetObject);

Где targetObject — это любой объект, на который ты хочешь создать слабую ссылку.

Но на этом все не заканчивается, потому что WeakRef открывает важный нюанс: слабая ссылка не защищает объект от удаления сборщиком мусора. Т.е ты можешь создать ссылку, но GC не пощадит твой объект, если тот больше не нужен. Поэтому WeakRef идеален для кейсов вроде кэширования, когда ты хочешь держать объект, пока он используется, но если он не нужен — его стоит обсвободить.

Чтобы достать объект из слабой ссылки, есть метод deref():

const obj = weakRef.deref();

Метод deref() вернет объект, если тот еще существует. Если сборщик мусора уже прибрал его к рукам, то deref() вернет undefined. Это и есть главный прикол слабых ссылок — нельзя быть увереным, что объект еще в памяти.

Вот пример:

let targetObject = { name: "Weak object" };
let weakRef = new WeakRef(targetObject);

// В какой-то момент...
targetObject = null; // Теперь объект доступен только через WeakRef

let obj = weakRef.deref();
if (obj) {
  console.log(`Объект все еще существует: ${obj.name}`);
} else {
  console.log("Объект был удален сборщиком мусора.");
}

Подводные камни

  1. Проблема с синхронизацией: Если вы подумали, что можно быть суперумным и создать много слабых ссылок на один объект, имейте в виду: после удаления объекта WeakRef не дает гарантии, что его всегда можно будет восстановить. Поэтому при кэшировании или других задачах нужно проверять, что объект все еще существует после вызова deref().

  2. Использование с осторожностью: WeakRef может быть полезен, но не злоупотребляй им.

FinalizationRegistry

Теперь поговорим про FinalizationRegistry. Это реестр, который позволяет отслеживать, когда объекты становятся недоступными и освобождать ресурсы асинхронно, через коллбеки.

Сначала создадим реестр:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Объект был финализирован: ${heldValue}`);
});

Здесь коллбек heldValue будет вызван, когда объект станет недоступным для кода, и JavaScript его удалит. Это очень хорошо для освобождения внешних ресурсов, таких как файловые дескрипторы или сокеты.

Теперь, чтобы зарегистрировать объект в этом реестре:

registry.register(targetObject, heldValue, unregisterToken);
  • targetObject: сам объект, который ты отслеживаешь.

  • heldValue: значение, которое будет передано в коллбек, когда объект будет удален.

  • unregisterToken: это опциональный параметр, который позволяет отписаться от отслеживания объекта.

Вот как это выглядит на практике:

let targetObject = { name: "Tracked object" };
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Объект был финализирован: ${heldValue}`);
});

// Регистрируем объект
registry.register(targetObject, "Мой объект");

// Где-то в коде...
targetObject = null; // Теперь объект доступен только через реестр

Когда объект будет удален сборщиком мусора, коллбек вызовется с переданным значением heldValue.

Если нужно отменить регистрацию объекта, то просто вызываешь:

registry.unregister(unregisterToken);

Этот unregisterToken — просто уникальный идентификатор для каждого объекта, который ты передал при регистрации.

Коллбек финализации работает асинхронно, так что будь готов, что освобождение ресурсов может произойти в неопределенное время.

Применение

WeakRef для кэширования

WeakRef — это идеальный инструмент для создания кэша, который автоматически освобождает память, если объект больше не нужен. Представь, что ты делаешь веб-приложение, которое загружает кучу данных из API, но не хочешь, чтобы эти данные висели в памяти вечно. Кэш с WeakRef позволит тебе держать объект в памяти, но при этом не даст ему захламлять память, если он больше не используется.

Пример:

class Cache {
  constructor() {
    this.cache = new Map();
  }

  set(key, value) {
    // Создаем слабую ссылку на объект
    this.cache.set(key, new WeakRef(value));
  }

  get(key) {
    const weakRef = this.cache.get(key);
    if (weakRef) {
      // Получаем объект из слабой ссылки
      const obj = weakRef.deref();
      if (obj) {
        console.log(`Объект по ключу "${key}" найден в кэше.`);
        return obj;
      } else {
        console.log(`Объект по ключу "${key}" был удален сборщиком мусора.`);
        this.cache.delete(key); // Очищаем кэш, если объект был удален
      }
    } else {
      console.log(`Ключ "${key}" не найден в кэше.`);
    }
    return null;
  }
}

// Пример использования:
const cache = new Cache();
let userData = { name: "Alice", age: 30 };

cache.set("user_1", userData);

// Принудительно освобождаем объект
userData = null;

// Пробуем получить объект через кэш
setTimeout(() => {
  const cachedData = cache.get("user_1");
  if (cachedData) {
    console.log(`Данные из кэша: ${cachedData.name}, ${cachedData.age}`);
  } else {
    console.log("Данные были удалены сборщиком мусора.");
  }
}, 1000);

Мы создаем кэш, который хранит слабые ссылки на объекты. Если объект больше не нужен, GC удалит его из памяти, а наш кэш сам обновится. При следующей попытке обращения мы сможем понять, был ли объект удален, и при необходимости подгрузить его заново.

Обработка DOM элементов с WeakRef

Еще один классный случай для WeakRef — это работа с DOM элементами, которые могут появляться и исчезать. Допусти, нужно создать приложение SPA, где некоторые компоненты могут быть временно удалены из DOM. Используя слабые ссылки, можно кэшировать информацию о DOM элементах, не опасаясь, что они останутся в памяти после удаления из документа.

Пример:

class DomCache {
  constructor() {
    this.domElements = new Map();
  }

  setElement(id, element) {
    this.domElements.set(id, new WeakRef(element));
  }

  getElement(id) {
    const weakRef = this.domElements.get(id);
    if (weakRef) {
      const element = weakRef.deref();
      if (element) {
        console.log(`Элемент с ID "${id}" найден в кэше.`);
        return element;
      } else {
        console.log(`Элемент с ID "${id}" был удален сборщиком мусора.`);
        this.domElements.delete(id); // Удаляем из кэша
      }
    } else {
      console.log(`Элемент с ID "${id}" не найден.`);
    }
    return null;
  }
}

// Пример использования:
const domCache = new DomCache();
const divElement = document.createElement("div");
divElement.id = "myDiv";
document.body.appendChild(divElement);

domCache.setElement("myDiv", divElement);

// Удаляем элемент из DOM
document.body.removeChild(divElement);

// Пробуем получить элемент через WeakRef
setTimeout(() => {
  const cachedElement = domCache.getElement("myDiv");
  if (cachedElement) {
    console.log("Элемент найден и все еще существует.");
  } else {
    console.log("Элемент был удален сборщиком мусора.");
  }
}, 1000);

Храним ссылку на DOM элемент в кэше, используя WeakRef. Когда элемент удаляется из DOM, он также может быть удален сборщиком мусора, и мы сможем это отследить.

Освобождение ресурсов с FinalizationRegistry

Теперь переходим к реестрам. FinalizationRegistry идеально подходит для задач, где нужно освободить ресурсы: закрытие файлов, соединений или выполнение других операций, когда объект становится недоступным.

Пример:

class FileManager {
  constructor() {
    this.registry = new FinalizationRegistry((fileName) => {
      console.log(`Освобождаем ресурсы для файла: ${fileName}`);
    });
  }

  openFile(fileName) {
    const fileObject = { name: fileName };
    this.registry.register(fileObject, fileName);
    return fileObject;
  }
}

// Пример использования:
const fileManager = new FileManager();
let file = fileManager.openFile("myfile.txt");

// Освобождаем ссылку на файл
file = null;

// Когда сборщик мусора удалит объект, вызовется коллбек и освободит ресурсы.

Создали файл, зарегистрировали его в FinalizationRegistry, а когда объект стал недоступным, система освободила связанные с ним ресурсы.

Очистка кэша с FinalizationRegistry

Один из моих любимых сценариев — это очистка кэша после удаления объекта.

Пример:

class ObjectCache {
  constructor() {
    this.cache = new Map();
    this.registry = new FinalizationRegistry((key) => {
      console.log(`Объект с ключом "${key}" был удален. Очищаем кэш.`);
      this.cache.delete(key);
    });
  }

  setObject(key, obj) {
    this.cache.set(key, obj);
    this.registry.register(obj, key);
  }

  getObject(key) {
    return this.cache.get(key);
  }
}

// Пример использования:
const cache = new ObjectCache();
let obj = { name: "Cache me if you can" };

cache.setObject("obj_1", obj);

// Освобождаем ссылку
obj = null;

// Когда объект будет удален сборщиком мусора, кэш будет автоматически очищен.

Создали кэш и зарегистрировали объекты в FinalizationRegistry. Когда объект становится недоступным, реестр заботится о том, чтобы удалить его из кэша.

Заключение

Вот так обстоят дела с WeakRef и FinalizationRegistry. Если использовать их грамотно, можно существенно улучшить управление памятью и избежать утечек в сложных приложениях. Кэширование, работа с DOM элементами, освобождение файловых дескрипторов и сетевых соединений — и не только, все это теперь находится под твоим контролем.

Ну что, пора применить эти штуки в продакшене?


В завершение скажу пару слов об открытом уроке, посвящённом созданию RestFull API с NestJs, который пройдет 24 сентября. В результате участия в нём научитесь создавать масштабированое API при помощи современных фреймворков. Если интересно — записывайтесь по ссылке.

Комментарии (9)


  1. ruslan_astratov
    19.09.2024 17:14

    Штука, безусловно, интересная

    А есть реальные кейсы использования?

    имхо, я пока вижу применение WeakRef лишь в сценарии, когда, например, у нас есть сокет-соединение, по которому на фронт часто сыпятся большие объёмы данных


    1. Vest
      19.09.2024 17:14

      Бедный фронт, которому сыпятся большие объёмы данных :(


      1. ruslan_astratov
        19.09.2024 17:14

        Раз в год и палка стреляет :)

        Не всегда, но изредка бывают моменты, когда на фронте есть сложные структуры данных


        1. Vest
          19.09.2024 17:14

          Сложные или большие? А то вы меня специально запутать хотите.


  1. dom1n1k
    19.09.2024 17:14

    Ещё когда вышли WeakSet и WeakMap, я не смог придумать им реального применения, хоть и пытался. Теоретически идея понятна, но какую из неё можно извлечь пользу в реальности, мягко говоря, неочевидно. С WeakRef та же история. В чем смысл? Ссылку на исходный объект автор все равно зануляет вручную. После неё осталась слабая ссылка, которая может быть ещё существует, а может уже и нет. Зачем нужен этот объект шрёдингера? Тут нет какого-то чудесного механизма, который работает сам собой и при этом всегда корректно. Всё равно вокруг этой лабуды будет ворох ручных обработок и проверок. В общем, не осилил я.


    1. RegIon
      19.09.2024 17:14

      GC срабатывает не сразу. По этой причине можно вернуть ссылку на объект с WeakRef до срабатывания GC, конечно оно натянуто, в основном WeakRef и FinalizationRegister юзается совместно.

      Более верный пример:

      const ptr = wasmRuntime.allocateWasmObject();
      const jsWrapper = new JSWrapper( ptr);
      const registry = new FinalizationRegistry(( ptr) => {
        wasmRuntime.free(ptr); // free wasm memory
      });
      registry.register( jsWrapper, ptr );
      
      return jsWrapper;


      1. dom1n1k
        19.09.2024 17:14
        +1

        Я понимаю, что GC работает не сразу, и какое-то время ссылка еще поживёт. Я ж говорю - теоретически механизм понятен. Непонятно, какое в этом практическое удобство? Если я могу лишь рассчитывать, что ссылка вероятно жива, но гарантий нет - поэтому постоянно нужно это проверять и, при необходимости, загружать данные снова. Мне кажется, проще управлять кэшем самостоятельно - когда надо загрузил, когда надо очистил. Не полагаясь на какие-то малопредсказуемые явления природы.


        1. RegIon
          19.09.2024 17:14

          Нуу тут момент в том, что ты не всегда можешь из кеша удалять объект, пока он где-то ещё используется в другом месте.

          Он может быть структурно сложный, например imagwBitmap или BLOB требуют явного вызова close, чтобы удалить из памяти, и если это сделать когда кто-то его будет использоваться - ссылка останется, а данные будут битые.

          Нет механизма отслеживания ссылок явно. Допустим у тебя был фетч каких-либо данных для 303737 табличек. Как узнать что все таблички закрыты? Каждая табличка тогда должна об этом сообщить. А вдруг какой-то Вася забудет сделать анмаунт колбек, в котором будет нотификация что пора удалить кеш в эффекте в каком-то фреймворке React подобном - вот и повисла память ( что всегда и случается на самом деле ).

          Самостоятельно кешем как раз очень геморойно управлять, почти все реализации кешей в JS текут.


  1. RegIon
    19.09.2024 17:14
    +5

    Пример с Map невалиден, нельзя хранить объект в Map или WeakMap, так как это явная ссылка.

    WeakMap хранит «мягко» только ключи, это значит что если GC доберется до ключа, то запись будет удалена ( по этому нельзя там иметь примитивы )

    Не рассказано что это было придумано для биндингов в wasm/webgpu, так как там неуправляемая память, и нужно знать когда JS объект был удален из памяти чтобы сделать очистку объекта на стороне wasm, webgpu или webgl, так как потерять реф на текстуру в webgl можно наизи и никто ее не улалит ( она может использоваться в рендер процессе )