Назад в Центр знаний
Отпечатки

WeakMap и WeakSet: что они держат и что теряют

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

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser поддерживает повтор авторизованной same-origin фикстуры под объявленным профилем и сохраняет ограниченную запись о том, что страница сохранила и когда, но он не может перечислить содержимое слабой коллекции, не может прочитать запись, ключ которой страница больше не держит, и не может вернуть выброшенную запись обратно в значение. Этот обзор выполняет один зонд на одиннадцати измеренных строках и фиксирует, какие типы ключей принимает WeakMap, что его собственные методы делают с примитивным ключом, удерживает ли сильный Map тот же ключ живым, что добавляют WeakRef и FinalizationRegistry, и что делает слабая коллекция при сериализации.

Кратко

  • WeakMap принимает объекты, функции, массивы и незарегистрированные символы и отклоняет null, строки и числа с TypeError, поэтому зонд печатает accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError.
  • Ни у WeakMap, ни у WeakSet нет size, keys, values, entries или forEach, поэтому зонд находит none для обоих.
  • Сильный Map и WeakMap могут одновременно держать один и тот же ключ, и удаление внешней ссылки оставляет сильную запись на месте с size=1.
  • delete, а затем get возвращает undefined, и последующий set на том же ключе работает, поэтому слабая запись заменяема, а не израсходована.
  • set бросает исключение на примитивном ключе, а get, has и delete возвращают промах, поэтому путь чтения может молчать о плохом ключе.
  • WeakRef.deref() возвращает цель, пока она еще достижима, а после второй запрошенной сборки возвращает undefined, и это сигнал сборки, для которого реестр не нужен.
  • FinalizationRegistry добавляет callback с удерживаемым значением, а PerformanceObserver на entryTypes: ['gc'] тоже сообщает о каждой сборке, так что ни один из них не единственный наблюдатель.
  • Ключ, зарегистрированный через Symbol.for(), отклоняется, поэтому правило символов покрывает Symbol() и известные символы, но не реестр.
  • WeakMap сериализуется в {} через JSON.stringify и бросает DOMException через structuredClone.

Содержание

Панель WeakMap: объект, функция, массив и символ принимаются, null, строка и число бросают TypeError. Вторая панель: нет size и перечисления. Третья: сильный Map держит запись после удаления внешней ссылки. Четвертая: JSON.stringify дает пустой объект, structuredClone бросает DOMException

Что на самом деле хранит слабая коллекция

WeakMap держит связь между ключом и значением, не удерживая ключ живым. Значение достижимо только пока ключ достижим откуда-то еще, и как только на ключ больше ничто не указывает, вся запись становится доступной для сборки. Зонд проверяет две половины этого утверждения по отдельности, потому что они отказывают по-разному.

node=v26.9.0
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
no-enumeration -> weakmap=none weakset=none
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
delete-then-read -> undefined
reset-after-delete -> second
weakref-deref-before -> true
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
missing-object-key -> undefined
weakset-identity -> frozen=true copy=false
serialization -> json=ok:{} structuredClone=throws:DOMException

Первая строка о ключе, вторая о коллекции, а остальные о том, что происходит вокруг них. Чтение вывода в таком порядке самый быстрый способ держать три вещи раздельно, потому что свойства, которых ждут от Map, размазаны по всем трем ответам, и ни одно из них не держится так, как ждет пользователь Map.

Практическое следствие в том, что WeakMap это не кэш с ограничением размера, а соседний вопрос о том, какие значения переживают копирование, разбирает structuredClone против JSON, чего эта коллекция не умеет вовсе. Это место, куда прикрепляют данные к объекту, который уже держат, с гарантией, что привязка исчезнет вместе с объектом. Страница, что хранит состояние по элементу в WeakMap вместо свойства на элементе, не получает кода очистки, и страница, что хранит состояние по запросу в WeakMap с ключом объектом запроса, получает то же. Чего она не получает, так это способа спросить, сколько там хранится, поэтому следующие два раздела о том, что можно и нельзя наблюдать.

Какие ключи принимает WeakMap

Ключ должен быть чем-то, что среда может держать слабо. Зонд пробует семь типов ключей и фиксирует, какие выживают set:

weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError

Объекты, функции и массивы очевидны. Символы интересный случай, потому что язык изменился: незарегистрированный символ стал допустимым слабым ключом, чтобы запись с ключом символом могла собираться так же, как запись с ключом объектом. Зонд подтверждает это в этой среде, и читателю на более старом движке стоит считать строку с символом зависящей от версии, а не универсальной. Исключение это реестр: отдельный зонд ставит Symbol(), Symbol.for() и Symbol.iterator как ключи и печатает Symbol()=accepted, Symbol.for()=TypeError и Symbol.iterator=accepted, поэтому зарегистрированный символ не является слабым ключом, хотя незарегистрированный является.

null отклоняется, потому что это не объект, а строки и числа отклоняются по той же причине: это примитивы, и у примитива нет идентичности, которую сборщик мог бы наблюдать. Вызывающий код, что делает ключом WeakMap строковый id, поэтому строит то, что не может работать, и отказ приходит во время set, а не позже, что более дружелюбная половина проблемы.

Менее дружелюбная половина в том, что методы чтения отказывают не так же. Зонд вызывает каждый метод со строкой text:

primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError

Бросает только set. get возвращает undefined, has возвращает false и delete возвращает false, и все это те же ответы, что дает допустимый, но отсутствующий ключ. Путь чтения, что принимает id от вызывающего кода, поэтому не может отличить «этот ключ недопустим» от «у этого ключа нет записи», и путь записи единственное место, где ошибка становится видимой. Помощник, что нормализует каждый id в объект перед обращением к map, закрывает этот разрыв, а помощник, что этого не делает, оставляет тихий промах в продакшене.

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

weakset-identity -> frozen=true copy=false

Замороженный оригинал член, а копия нет, хотя оба сериализуются одинаково. Это то же правило, которому следуют Map и Set, и его стоит назвать, потому что вызывающий код, что делает ключом «объект с id 1», а не «этот объект», получает промах, который выглядит как ошибка в коллекции.

Почему ничего нельзя посчитать или перечислить

Слабая коллекция не может сообщить собственное содержимое, потому что сообщение о нем было бы именно тем, что удерживает его живым. Зонд спрашивает каждую коллекцию, есть ли у нее методы, к которым потянется пользователь Map или Set:

no-enumeration -> weakmap=none weakset=none

Ни у одной коллекции нет size, keys, values, entries или forEach. Это не отсутствующая функция, это определение. Свойство size должно было бы считать записи, которые сборщик волен выбросить, поэтому число менялось бы без запуска какого-либо кода, а итератор keys() отдал бы вызывающему коду сильную ссылку на каждый ключ, который он выдал.

Практическая цена в том, что страница не может сообщить «эта слабая map держит двенадцать записей» в диагностике. Что она может сообщить, так это число объектов, которые она все еще держит сильно, а это другое число и часто более полезное: если страница держит список элементов, к которым прикрепляла данные, этот список сильная сторона, и слабую сторону можно только вывести из него.

Цена также видна в тестировании. Тест, что хочет доказать, что запись собрана, не может утверждать что-либо о коллекции, потому что утверждать не о чем. Он должен утверждать о наблюдаемой половине, а это WeakRef.deref(), возвращающий undefined, и для этого нужно, чтобы сборщик уже запустился. Следующий раздел охватывает пару API, что существуют именно для этой цели, а раздел после него охватывает то единственное, что тест может проверить вообще без сборки.

Есть третья цена, что видна в аудите, а не в коде. Рецензент, что хочет знать, течет ли страница, должен рассуждать только о сильной стороне, потому что слабую нельзя перечислить. У вопроса «сколько записей держит эта map» нет ответа, а у вопроса, у которого ответ есть, «сколько объектов страница все еще держит, которые могли бы быть ключами». Страница, что держит реестр открытых панелей, список ожидающих запросов или массив смонтированных элементов, держит ровно столько потенциальных ключей, и слабые записи, прикрепленные к ним, живут столько же, сколько эти списки. Чтение профиля памяти поэтому вопрос поиска сильных списков, а не поиска слабых map.

Что сильная ссылка делает с тем же ключом

Слабая запись исчезает только когда ничто другое не держит ключ. Зонд кладет один и тот же ключ в сильный Map и в WeakMap одновременно, затем удаляет внешнюю ссылку:

same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1

Слабый поиск все еще отвечает, пока переменная держит ключ, это ожидаемая половина. Вторая строка та, что важна: после переназначения переменной сильная map все еще сообщает одну запись. Слабая map тоже не забыла значение, потому что сильная map все еще держит ключ, поэтому чтение слабой map по этому ключу по-прежнему возвращает value. Сильная сторона это то, что держит пару живой, а слабая последовала за ней.

Это взаимодействие самый частый способ, которым страница случайно побеждает собственную слабую коллекцию. Модуль, что хранит метаданные в WeakMap и также толкает каждый ключ в массив для панели отладки, сделал каждую запись постоянно достижимой, и панель отладки причина того, что ничего никогда не собирается. Массив не обязан использоваться, он обязан только существовать и быть достижимым из живой области видимости.

Обратная ошибка это предположение, что delete это то, как запись уходит. Зонд удаляет запись и затем снова использует тот же ключ:

delete-then-read -> undefined
reset-after-delete -> second

delete удаляет связь и возвращает boolean, а сам ключ не тронут, поэтому последующий set на том же ключе работает нормально. Вызывающий код, что ждет, что удаленный ключ станет непригодным, как одноразовый токен, будет удивлен: коллекция забывает значение, а не ключ.

Третья ошибка это ожидание, что слабая коллекция ограничит память. Она ничего не ограничивает, потому что у нее нет размера, который можно ограничить. Страница, что течет памятью через WeakMap, течет через значения, а не ключи: значение, что указывает обратно на свой ключ или на большой буфер, держит этот буфер живым ровно столько, сколько живет ключ. Зонд этого не видит, потому что зонд не держит такого цикла, и это причина, по которой обзор памяти должен смотреть на значения так же внимательно, как на ключи.

Четвертая ошибка тоньше и видна в одностраничных приложениях. Страница, что делает ключом WeakMap объект маршрута, создает новый ключ при каждой навигации, поэтому map накапливает одну запись на каждую навигацию, у которой нет другой ссылки, и сборщик волен выбросить их все. Это ровно задуманное поведение, и это также причина, по которой счетчик, построенный на той же map, никогда не уменьшается: счетчик это сильная сторона. Страница, что хочет измерять текучесть навигации, должна считать навигации, а не записи.

Последний структурный пункт в том, что слабым ключом может быть прокси, объект из другого realm или замороженная запись, и все три ведут себя одинаково для удержания. Сборщик отслеживает достижимость, а не форму, поэтому единственное, что страница может сделать, чтобы изменить исход, это добавить или убрать сильную ссылку где-то еще. Это делает слабую коллекцию ведомой: ее поведение полностью определяется кодом, что никогда ее не упоминает.

Что добавляют WeakRef и FinalizationRegistry

WeakRef и FinalizationRegistry наблюдаемая половина слабого удержания, и зонд использует оба:

weakref-deref-before -> true

new WeakRef(target) не держит target живым, и deref() возвращает цель, пока что-то другое все еще держит ее. Зонд сначала проверяет положительный случай. Отрицательный случай происходит только после запуска сборщика, и скрипт может запросить этот запуск: v8.setFlagsFromString('--expose-gc'), за которым следует vm.runInNewContext('gc'), дает работающую функцию gc в обычном процессе Node, где globalThis.gc это undefined.

Запрошенного запуска все еще недостаточно, чтобы увидеть undefined в том же job, и одного запрошенного запуска недостаточно и между job. Второй зонд бросает три цели, вызывает gc, сразу читает каждую ссылку, затем снова вызывает gc через оборот и читает снова:

deref-shape -> same_job=true after_second_gc=false | same_job=true after_second_gc=false | same_job=true after_second_gc=false

Все ссылки еще отвечали в job, который запросил сборку, и все ответили undefined только после второго запрошенного запуска. Первый запуск помечает объекты как собираемые; ссылка отвечает до тех пор, пока более поздняя сборка действительно их не заберет, поэтому тест, который утверждает undefined сразу после одного вызова gc, падает по причине, не связанной с удержанием. Тот же зонд показывает callback FinalizationRegistry, срабатывающий после запрошенного запуска, поэтому и callback реестра, и проверка deref() нужны в тесте, который хочет доказать сборку.

FinalizationRegistry форма с callback: страница регистрирует цель с удерживаемым значением, и реестр вызывает callback с этим удерживаемым значением после того, как цель собрана. Callback не промис и не запускается по расписанию, которым управляет страница. Он может запуститься намного позже или не запуститься вовсе до выгрузки страницы, поэтому страница, что зависит от него для корректности, имеет ошибку корректности, а не производительности.

Пара полезна для диагностики, а не для очистки, а порядок, в котором в итоге выполняется диагностический callback, это вопрос планирования, разобранный в микрозадачи и таймеры. Страница, что хочет знать, действительно ли ее кэш элементов уменьшается, может зарегистрировать каждый ключ со счетчиком и логировать callback, что превращает «я думаю, это собрано» в число. Страница, что хочет освободить ресурс, использует явный close, dispose или AbortController, потому что они запускаются тогда, когда говорит страница, а не тогда, когда решает сборщик.

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

Чего слабая коллекция не переживает

Слабая коллекция это не данные. Ее нельзя скопировать, залогировать или отправить, и зонд показывает оба отказа:

serialization -> json=ok:{} structuredClone=throws:DOMException

JSON.stringify не бросает, он производит {}, потому что у WeakMap нет собственных перечисляемых свойств. Это более опасное из двух, потому что вывод валидный JSON, и вызывающий код, что его логирует, видит пустой объект, а не ошибку. Запись, что говорит «кэш был пуст», поэтому может быть произведена полным кэшем, и ничто в выводе не различает эти два случая.

structuredClone вместо этого бросает DOMException, и это честный ответ: у слабой коллекции нет семантики клонирования, потому что клон должен был бы решить, будет ли копия слабой тем же образом. Страница, что передает WeakMap через границу worker или через postMessage, получает это исключение, а не молчаливый пустой объект, и это режим отказа, для которого стоит проектировать.

Тот же предел применяется к простому объекту с WeakMap внутри. JSON.stringify обходит собственные свойства, находит слабую коллекцию и производит {} для этого поля, пока остальной объект сериализуется нормально. Единственная коллекция зонда простой случай; реальный объект данных обычно имеет коллекцию вложенной, и пустое поле легко пропустить в длинной строке лога.

Запишите показания удержания

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

  1. Среду, потому что набор допустимых типов ключей изменился, когда символы стали допустимыми слабыми ключами.
  2. Типы ключей, что пробовались, потому что TypeError во время set и тихий промах во время get разные результаты.
  3. Сильные ссылки, что страница все еще держит, потому что любая из них держит каждую запись живой.
  4. Запрашивали ли запуск сборщика, потому что deref(), возвращающий значение, ничего не доказывает о сборке.
  5. Путь сериализации, потому что JSON.stringify возвращает пустой объект, а structuredClone бросает.

Публичные источники

Практический вывод

Используйте слабую коллекцию, чтобы прикрепить данные к объекту, который уже держите, и не ждите от нее ничего другого: ни размера, ни перечисления, ни сериализации, ни очистки, что запускается по вашему расписанию. Нормализуйте ключи в объекты до того, как они попадут в map, чтобы недопустимый ключ отказывал при записи, а не исчезал при чтении. Аудируйте сильную сторону страницы, потому что один удержанный массив побеждает каждую слабую запись, что он перечисляет. Когда нужен выпуск ресурса, вызывайте явный путь dispose, а WeakRef и FinalizationRegistry держите для диагностики. О том, что выживает при копии данных, которые вы пытались сохранить, читайте structuredClone против JSON; о порядке, в котором запускаются callback, что касаются этих записей, читайте микрозадачи и таймеры.

#WeakMap#WeakSet#WeakRef#FinalizationRegistry#structuredClone

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.