«Скрытые» особенности языка
В данной статье рассматривается очень узконаправленная тема, с которой большинство разработчиков крайне редко сталкиваются на практике (и могут даже не подозревать о ее существовании).
Мы рекомендуем пропустить эту главу, если вы только начали изучать JavaScript.
Вспоминая базовую концепцию принципа достижимости из главы «Сбор мусора», можно отметить, что движок JavaScript гарантированно сохраняет в памяти значения, которые доступны или используются.
Например:
// пользовательская переменная содержит строгую ссылку на объект let user = { name: "Джон" }; // давайте перезапишем значение пользовательской переменной пользователь = ноль; // ссылка потеряется и объект будет удален из памяти
Или аналогичный, но чуть более сложный код с двумя сильными ссылками:
// пользовательская переменная содержит строгую ссылку на объект let user = { name: "Джон" }; // скопировали строгую ссылку на объект в переменную администратора пусть администратор = пользователь; // давайте перезапишем значение пользовательской переменной пользователь = ноль; // объект по-прежнему доступен через переменную администратора
Объект { name: "John" }
будет удален из памяти только в том случае, если на него не будет сильных ссылок (если мы также перезаписали значение переменной admin
).
В JavaScript есть концепция WeakRef
, которая в данном случае ведет себя немного по-другому.
Термины: «Сильная ссылка», «Слабая ссылка».
Сильная ссылка – это ссылка на объект или значение, которая предотвращает их удаление сборщиком мусора. Тем самым сохраняя в памяти объект или значение, на которое оно указывает.
Это означает, что объект или значение остается в памяти и не собирается сборщиком мусора до тех пор, пока на него есть активные сильные ссылки.
В JavaScript обычные ссылки на объекты являются сильными ссылками. Например:
// пользовательская переменная содержит сильную ссылку на этот объект let user = { name: "Джон" };
Слабая ссылка – это ссылка на объект или значение, которая не препятствует их удалению сборщиком мусора. Объект или значение может быть удален сборщиком мусора, если единственные оставшиеся ссылки на них являются слабыми ссылками.
Примечание предостережения
Прежде чем мы углубимся в это, стоит отметить, что правильное использование структур, обсуждаемых в этой статье, требует очень тщательного обдумывания, и их лучше избегать, если это возможно.
WeakRef
– это объект, который содержит слабую ссылку на другой объект, называемый target
или referent
.
Особенность WeakRef
в том, что он не мешает сборщику мусора удалить свой референт-объект. Другими словами, объект WeakRef
не поддерживает активность referent
объекта.
Теперь давайте возьмем переменную user
в качестве «референта» и создадим из нее слабую ссылку на переменную admin
. Чтобы создать слабую ссылку, вам нужно использовать конструктор WeakRef
, передав целевой объект (объект, на который вы хотите создать слабую ссылку).
В нашем случае — это user
переменная:
// пользовательская переменная содержит строгую ссылку на объект let user = { name: "Джон" }; // переменная администратора содержит слабую ссылку на объект пусть администратор = новый WeakRef (пользователь);
На диаграмме ниже показаны два типа ссылок: сильная ссылка с использованием переменной user
и слабая ссылка с использованием переменной admin
:
Затем в какой-то момент мы перестаём использовать user
переменную — она перезаписывается, выходит за пределы области видимости и т. д., сохраняя при этом экземпляр WeakRef
в переменной admin
:
// давайте перезапишем значение пользовательской переменной пользователь = ноль;
Слабой ссылки на объект недостаточно, чтобы сохранить его «живым». Когда единственные оставшиеся ссылки на референтный объект являются слабыми ссылками, сборщик мусора может уничтожить этот объект и использовать его память для чего-то другого.
Однако до тех пор, пока объект не будет фактически уничтожен, слабая ссылка может его возвращать, даже если более сильных ссылок на этот объект нет. То есть наш объект становится своеобразным «котом Шрёдингера» — мы не можем знать наверняка, «живой» он или «мертвый»:
На этом этапе, чтобы получить объект из экземпляра WeakRef
, мы будем использовать его метод deref()
.
Метод deref()
возвращает объект-референт, на который указывает WeakRef
, если объект все еще находится в памяти. Если объект был удален сборщиком мусора, метод deref()
вернет undefined
:
пусть ref = admin.deref(); если (ссылка) { // объект по-прежнему доступен: мы можем производить с ним любые манипуляции } еще { // объект собран сборщиком мусора }
WeakRef
обычно используется для создания кешей или ассоциативных массивов, в которых хранятся ресурсоемкие объекты. Это позволяет избежать предотвращения сбора этих объектов сборщиком мусора исключительно на основании их присутствия в кэше или ассоциативном массиве.
Один из основных примеров — ситуация, когда у нас есть множество объектов двоичного изображения (например, представленных как ArrayBuffer
или Blob
), и мы хотим связать имя или путь с каждым изображением. Существующие структуры данных не совсем подходят для этих целей:
Использование Map
для создания ассоциаций между именами и изображениями или наоборот сохранит объекты изображений в памяти, поскольку они присутствуют в Map
в виде ключей или значений.
WeakMap
также не подходит для этой цели: поскольку объекты, представленные в виде ключей WeakMap
, используют слабые ссылки и не защищены от удаления сборщиком мусора.
Но в этой ситуации нам нужна структура данных, которая бы использовала слабые ссылки в своих значениях.
Для этой цели мы можем использовать коллекцию Map
, значениями которой являются экземпляры WeakRef
, ссылающиеся на нужные нам большие объекты. Следовательно, мы не будем хранить эти большие и ненужные объекты в памяти дольше, чем положено.
В противном случае это способ получить объект изображения из кеша, если он все еще доступен. Если он был собран мусором, мы сгенерируем его заново или повторно загрузим.
Таким образом, в некоторых ситуациях используется меньше памяти.
Ниже приведен фрагмент кода, демонстрирующий технику использования WeakRef
.
Короче говоря, мы используем Map
со строковыми ключами и объектами WeakRef
в качестве их значений. Если объект WeakRef
не был собран сборщиком мусора, мы получаем его из кеша. В противном случае мы повторно скачиваем его и помещаем в кеш для дальнейшего возможного повторного использования:
функция fetchImg() { // абстрактная функция для загрузки изображений... } функция слабаяRefCache(fetchImg) { // (1) const imgCache = новая карта(); // (2) return (imgName) => { // (3) constcachedImg = imgCache.get(imgName); // (4) if (cachedImg?.deref()) { // (5) вернуть кэшированныйImg?.deref(); } const newImg = fetchImg(imgName); // (6) imgCache.set(imgName, new WeakRef(newImg)); // (7) вернуть новое изображение; }; } const getCachedImg = слабыйRefCache(fetchImg);
Давайте углубимся в подробности того, что здесь произошло:
weakRefCache
— это функция более высокого порядка, которая принимает в качестве аргумента другую функцию fetchImg
. В этом примере мы можем пренебречь подробным описанием функции fetchImg
, поскольку это может быть любая логика загрузки изображений.
imgCache
– это кеш изображений, в котором хранятся кешированные результаты функции fetchImg
в виде строковых ключей (имени изображения) и объектов WeakRef
в качестве их значений.
Возвращает анонимную функцию, которая принимает имя изображения в качестве аргумента. Этот аргумент будет использоваться в качестве ключа для кэшированного изображения.
Попытка получить закешированный результат из кеша, используя предоставленный ключ (имя изображения).
Если в кэше содержится значение для указанного ключа, а объект WeakRef
не был удален сборщиком мусора, верните кэшированный результат.
Если в кеше нет записи с запрошенным ключом или метод deref()
возвращает undefined
(это означает, что объект WeakRef
был собран мусором), функция fetchImg
загружает изображение повторно.
Поместите загруженное изображение в кеш как объект WeakRef
.
Теперь у нас есть коллекция Map
, где ключи — это имена изображений в виде строк, а значения — объекты WeakRef
, содержащие сами изображения.
Этот прием помогает избежать выделения большого объема памяти для ресурсоемких объектов, которые больше никто не использует. Это также экономит память и время в случае повторного использования кэшированных объектов.
Вот визуальное представление того, как выглядит этот код:
Но у этой реализации есть свои недостатки: со временем Map
будет заполнен строками в качестве ключей, указывающих на WeakRef
, чей референтный объект уже был собран мусором:
Один из способов решения этой проблемы — периодическая очистка кеша и удаление «мертвых» записей. Другой способ — использовать финализаторы, которые мы рассмотрим далее.
Еще один вариант использования WeakRef
— отслеживание объектов DOM.
Давайте представим себе сценарий, в котором какой-то сторонний код или библиотека взаимодействует с элементами на нашей странице, пока они существуют в DOM. Например, это может быть внешняя утилита для мониторинга и оповещения о состоянии системы (обычно так называемый «логгер» — программа, отправляющая информационные сообщения, называемые «логами»).
Интерактивный пример:
Результат
index.js
index.css
index.html
const startMessagesBtn = document.querySelector('.start-messages'); // (1) const closeWindowBtn = document.querySelector('.window__button'); // (2) const windowElementRef = new WeakRef(document.querySelector(".window__body")); // (3) startMessagesBtn.addEventListener('click', () => { // (4) startMessages (windowElementRef); startMessagesBtn.disabled = правда; }); closeWindowBtn.addEventListener('click', () => document.querySelector(".window__body").remove()); // (5) const startMessages = (элемент) => { const timerId = setInterval(() => { // (6) if (element.deref()) { // (7) const payload = document.createElement("p"); payload.textContent = `Сообщение: Состояние системы в порядке: ${new Date().toLocaleTimeString()}`; element.deref().append(полезная нагрузка); } еще { // (8) alert("Элемент удален."); // (9) ClearInterval (timerId); } }, 1000); };
.приложение { дисплей: гибкий; гибкое направление: столбец; разрыв: 16 пикселей; } .start-сообщения { ширина: подходящее содержимое; } .окно { ширина: 100%; граница: 2 пикселя, сплошная #464154; переполнение: скрыто; } .window__header { положение: липкое; отступ: 8 пикселей; дисплей: гибкий; оправдание-содержание: пространство между; выровнять-элементы: по центру; цвет фона: #736e7e; } .window__title { маржа: 0; размер шрифта: 24 пикселей; вес шрифта: 700; цвет: белый; межбуквенный интервал: 1 пиксель; } .window__button { отступ: 4 пикселя; фон: #4f495c; контур: нет; граница: 2 пикселя, сплошная #464154; цвет: белый; размер шрифта: 16 пикселей; курсор: указатель; } .window__body { высота: 250 пикселей; отступ: 16 пикселей; переполнение: прокрутка; цвет фона: #736e7e33; }
<!DOCTYPE HTML> <html lang="ru"> <голова> <мета-кодировка="utf-8"> <link rel="stylesheet" href="index.css"> <title>Регистратор WeakRef DOM</title> </голова> <тело> <div class="приложение"> <button class="start-messages">Начать отправку сообщений</button> <div class="окно"> <div class="window__header"> <p class="window__title">Сообщения:</p> <button class="window__button">Закрыть</button> </div> <div class="window__body"> Никаких сообщений. </div> </div> </div> <script type="module" src="index.js"></script> </тело> </html>
При нажатии кнопки «Начать отправку сообщений» в так называемом «окне отображения логов» (элемент с классом .window__body
) начинают появляться сообщения (логи).
Но, как только этот элемент будет удален из DOM, логгер должен перестать отправлять сообщения. Чтобы воспроизвести удаление этого элемента, достаточно нажать кнопку «Закрыть» в правом верхнем углу.
Чтобы не усложнять нам работу, и не уведомлять сторонний код каждый раз, когда наш DOM-элемент доступен, а когда его нет, достаточно будет создать слабую ссылку на него с помощью WeakRef
.
Как только элемент будет удален из DOM, логгер заметит это и перестанет отправлять сообщения.
Теперь давайте подробнее рассмотрим исходный код ( вкладка index.js
):
Получите DOM-элемент кнопки «Начать отправку сообщений».
Получите DOM-элемент кнопки «Закрыть».
Получите DOM-элемент окна отображения логов с помощью new WeakRef()
. Таким образом, переменная windowElementRef
содержит слабую ссылку на DOM-элемент.
Добавьте прослушиватель событий на кнопку «Начать отправку сообщений», отвечающий за запуск логгера при нажатии.
Добавьте прослушиватель событий на кнопку «Закрыть», отвечающий за закрытие окна отображения логов при нажатии.
Используйте setInterval
, чтобы каждую секунду отображать новое сообщение.
Если DOM-элемент окна отображения логов все еще доступен и хранится в памяти, создайте и отправьте новое сообщение.
Если метод deref()
возвращает undefined
, это означает, что DOM-элемент был удален из памяти. В этом случае логгер перестает отображать сообщения и очищает таймер.
alert
, который будет вызываться после удаления из памяти DOM-элемента окна отображения логов (т.е. после нажатия кнопки «Закрыть»). Обратите внимание, что удаление из памяти может произойти не сразу, поскольку оно зависит только от внутренних механизмов сборщика мусора.
Мы не можем контролировать этот процесс напрямую из кода. Однако, несмотря на это, у нас все еще есть возможность принудительно выполнить сбор мусора из браузера.
Например, в Google Chrome для этого нужно открыть инструменты разработчика ( Ctrl + Shift + J в Windows/Linux или Option + ⌘ + J в macOS), перейти на вкладку «Производительность» и нажать кнопку «Производительность». Кнопка со значком корзины – «Собрать мусор»:
Эта функциональность поддерживается в большинстве современных браузеров. После выполнения действий alert
сработает немедленно.
Теперь пришло время поговорить о финализаторах. Прежде чем двигаться дальше, давайте уточним терминологию:
Обратный вызов очистки (финализатор) – функция, которая выполняется, когда объект, зарегистрированный в FinalizationRegistry
, удаляется из памяти сборщиком мусора.
Его цель – предоставить возможность выполнять дополнительные операции, связанные с объектом, после его окончательного удаления из памяти.
Реестр (или FinalizationRegistry
) — специальный объект в JavaScript, который управляет регистрацией и отменой регистрации объектов, а также обратными вызовами их очистки.
Этот механизм позволяет зарегистрировать объект для отслеживания и связать с ним обратный вызов очистки. По сути, это структура, которая хранит информацию о зарегистрированных объектах и обратных вызовах их очистки, а затем автоматически вызывает эти обратные вызовы при удалении объектов из памяти.
Чтобы создать экземпляр FinalizationRegistry
, необходимо вызвать его конструктор, который принимает единственный аргумент — обратный вызов очистки (финализатор).
Синтаксис:
функция cleanupCallback(heldValue) { // очистка кода обратного вызова } константный реестр = новый FinalizationRegistry (cleanupCallback);
Здесь:
cleanupCallback
— обратный вызов очистки, который будет автоматически вызываться при удалении зарегистрированного объекта из памяти.
heldValue
— значение, которое передается в качестве аргумента обратного вызова очистки. Если heldValue
является объектом, в реестре сохраняется строгая ссылка на него.
registry
— экземпляр FinalizationRegistry
.
Методы FinalizationRegistry
:
register(target, heldValue [, unregisterToken])
– используется для регистрации объектов в реестре.
target
– объект, регистрируемый для слежения. Если target
представляет собой сбор мусора, обратный вызов очистки будет вызван с heldValue
в качестве аргумента.
Необязательный unregisterToken
— токен отмены регистрации. Его можно передать для отмены регистрации объекта до того, как сборщик мусора удалит его. Обычно target
объект используется как unregisterToken
, что является стандартной практикой.
unregister(unregisterToken)
– метод unregister
используется для отмены регистрации объекта в реестре. Он принимает один аргумент — unregisterToken
(токен отмены регистрации, полученный при регистрации объекта).
Теперь перейдем к простому примеру. Давайте воспользуемся уже известным объектом user
и создадим экземпляр FinalizationRegistry
:
let user = { name: "Джон" }; const реестр = новый FinalizationRegistry((heldValue) => { console.log(`${heldValue} собран сборщиком мусора.`); });
Затем мы зарегистрируем объект, который требует обратного вызова очистки, вызвав метод register
:
реестр.регистр(пользователь, имя пользователя.имя);
В реестре не сохраняется строгая ссылка на регистрируемый объект, поскольку это противоречит его назначению. Если бы в реестре сохранялась надежная ссылка, объект никогда не подвергался бы сборке мусора.
Если объект удаляется сборщиком мусора, в какой-то момент в будущем может быть вызван наш обратный вызов очистки с передачей ему heldValue
:
// Когда объект пользователя будет удален сборщиком мусора, в консоли будет выведено следующее сообщение: «Джона забрал сборщик мусора».
Также бывают ситуации, когда даже в реализациях, использующих обратный вызов очистки, существует вероятность того, что он не будет вызван.
Например:
Когда программа полностью прекращает свою работу (например, при закрытии вкладки в браузере).
Когда сам экземпляр FinalizationRegistry
больше не доступен для кода JavaScript. Если объект, создающий экземпляр FinalizationRegistry
выходит за пределы области действия или удаляется, обратные вызовы очистки, зарегистрированные в этом реестре, также могут не вызываться.
Возвращаясь к нашему примеру со слабым кэшем, мы можем заметить следующее:
Несмотря на то, что значения, заключенные в WeakRef
были собраны сборщиком мусора, все еще существует проблема «утечки памяти» в виде оставшихся ключей, значения которых были собраны сборщиком мусора.
Вот улучшенный пример кэширования с использованием FinalizationRegistry
:
функция fetchImg() { // абстрактная функция для загрузки изображений... } функция слабаяRefCache(fetchImg) { const imgCache = новая карта(); const реестр = новый FinalizationRegistry((imgName) => { // (1) constcachedImg = imgCache.get(imgName); if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName); }); return (imgName) => { constcachedImg = imgCache.get(imgName); если (cachedImg?.deref()) { вернуть кэшированныйImg?.deref(); } const newImg = fetchImg(imgName); imgCache.set(imgName, new WeakRef(newImg)); реестр.регистр(новыйImg, imgName); // (2) вернуть новое изображение; }; } const getCachedImg = слабыйRefCache(fetchImg);
Чтобы управлять очисткой «мертвых» записей кэша, когда связанные объекты WeakRef
собираются сборщиком мусора, мы создаем реестр очистки FinalizationRegistry
.
Важным моментом здесь является то, что в обратном вызове очистки следует проверять, была ли запись удалена сборщиком мусора, а не добавлена повторно, чтобы не удалить «живую» запись.
Как только новое значение (изображение) загружено и помещено в кеш, мы регистрируем его в реестре финализатора для отслеживания объекта WeakRef
.
Эта реализация содержит только реальные или «живые» пары ключ/значение. В этом случае каждый объект WeakRef
регистрируется в FinalizationRegistry
. А после того, как объекты будут очищены сборщиком мусора, обратный вызов очистки удалит все undefined
значения.
Вот визуальное представление обновленного кода:
Ключевым аспектом обновленной реализации является то, что финализаторы позволяют создавать параллельные процессы между «основной» программой и обратными вызовами очистки. В контексте JavaScript «основная» программа — это наш JavaScript-код, который запускается и выполняется в нашем приложении или на веб-странице.
Следовательно, с момента пометки объекта к удалению сборщиком мусора и до фактического выполнения обратного вызова очистки может пройти определенный промежуток времени. Важно понимать, что за этот промежуток времени основная программа может внести в объект любые изменения или даже вернуть его в память.
Вот почему в обратном вызове очистки мы должны проверить, была ли запись добавлена обратно в кеш основной программой, чтобы избежать удаления «живых» записей. Аналогично, при поиске ключа в кеше есть вероятность, что значение было удалено сборщиком мусора, но обратный вызов очистки еще не выполнен.
Такие ситуации требуют особого внимания, если вы работаете с FinalizationRegistry
.
Переходя от теории к практике, представьте себе реальный сценарий, когда пользователь синхронизирует свои фотографии на мобильном устройстве с каким-либо облачным сервисом (например, iCloud или Google Photos) и хочет просмотреть их с других устройств. Помимо основного функционала просмотра фотографий, такие сервисы предлагают массу дополнительных возможностей, например:
Редактирование фотографий и видеоэффекты.
Создание «воспоминаний» и альбомов.
Видеомонтаж из серии фотографий.
…и многое другое.
Здесь в качестве примера мы будем использовать достаточно примитивную реализацию такого сервиса. Основная задача — показать возможный сценарий совместного использования WeakRef
и FinalizationRegistry
в реальной жизни.
Вот как это выглядит:
С левой стороны находится облачная библиотека фотографий (они отображаются в виде миниатюр). Мы можем выбрать нужные нам изображения и создать коллаж, нажав кнопку «Создать коллаж» в правой части страницы. Затем полученный коллаж можно скачать как изображение.
Для увеличения скорости загрузки страниц разумно будет загружать и отображать миниатюры фотографий в сжатом качестве. Но, чтобы создать коллаж из выбранных фотографий, скачайте и используйте их в полноразмерном качестве.
Ниже мы видим, что собственный размер миниатюр составляет 240x240 пикселей. Размер выбран специально для увеличения скорости загрузки. Более того, нам не нужны полноразмерные фотографии в режиме предварительного просмотра.
Предположим, нам нужно создать коллаж из 4 фотографий: выбираем их, а затем нажимаем кнопку «Создать коллаж». На этом этапе уже известная нам функция weakRefCache
проверяет, есть ли в кеше нужное изображение. Если нет, он загружает его из облака и помещает в кеш для дальнейшего использования. Это происходит для каждого выбранного изображения:
Обращая внимание на вывод в консоли, вы можете увидеть, какая из фотографий была загружена из облака — об этом говорит FETCHED_IMAGE . Поскольку это первая попытка создания коллажа, это означает, что на этом этапе «слабый кэш» еще был пуст, а все фотографии были скачаны из облака и помещены в него.
Но, наряду с процессом загрузки изображений, происходит еще и процесс очистки памяти сборщиком мусора. Это означает, что хранящийся в кеше объект, на который мы ссылаемся по слабой ссылке, удаляется сборщиком мусора. И наш финализатор успешно выполняется, тем самым удаляя ключ, по которому изображение хранилось в кеше. CLEANED_IMAGE уведомляет нас об этом:
Далее мы понимаем, что получившийся коллаж нам не нравится, и решаем изменить одно из изображений и создать новое. Для этого достаточно снять выделение с ненужного изображения, выбрать другое и снова нажать кнопку «Создать коллаж»:
Но на этот раз не все изображения были скачаны из сети, а одно из них было взято из слабого кеша: об этом нам сообщает сообщение CACHED_IMAGE . Это означает, что на момент создания коллажа сборщик мусора еще не удалил наше изображение, и мы смело взяли его из кеша, тем самым уменьшив количество сетевых запросов и ускорив общее время процесса создания коллажа:
Давайте еще немного «поиграемся», заменив одно из изображений еще раз и создав новый коллаж:
На этот раз результат еще более впечатляющий. Из 4-х выбранных изображений 3 были взяты из слабого кэша, а скачать из сети пришлось только одно. Снижение нагрузки на сеть составило около 75%. Впечатляет, не так ли?
Конечно, важно помнить, что такое поведение не гарантировано и зависит от конкретной реализации и работы сборщика мусора.
Исходя из этого, сразу возникает вполне логичный вопрос: почему бы нам не использовать обычный кеш, где мы можем сами управлять его сущностями, а не полагаться на сборщик мусора? Всё верно, в подавляющем большинстве случаев нет необходимости использовать WeakRef
и FinalizationRegistry
.
Здесь мы просто продемонстрировали альтернативную реализацию аналогичного функционала, используя нетривиальный подход с интересными особенностями языка. Тем не менее, мы не можем полагаться на этот пример, если нам нужен постоянный и предсказуемый результат.
Вы можете открыть этот пример в песочнице.
WeakRef
— предназначен для создания слабых ссылок на объекты, позволяющих удалять их из памяти сборщиком мусора, если на них больше нет сильных ссылок. Это полезно для решения проблемы чрезмерного использования памяти и оптимизации использования системных ресурсов в приложениях.
FinalizationRegistry
– это инструмент для регистрации обратных вызовов, которые выполняются при уничтожении объектов, на которые больше нет строгих ссылок. Это позволяет освободить ресурсы, связанные с объектом, или выполнить другие необходимые операции перед удалением объекта из памяти.