При разработке веб-приложений не все задумываются о том, сколько памяти потребляет их код. О производительности наших сайтов мы вспоминаем гораздо чаще. К тому же не каждому разработчику интересно «экономить на спичках». Разве может наш код на языке JavaScript требовать много памяти? «Много» — это вообще сколько? 100 мегабайтов — это много?
Меня зовут Антон Непша. Я работаю в Сбере, разрабатываю сайт СберБанк Онлайн и веду Telegram-канал Антон Непша.js. Недавно я выступил на HolyJS с докладом о том, сколько ресурсов потребляют наши сайты, как эти ресурсы распределяются, где хранятся, и как связать информацию о них из снимка памяти с конкретным местом в своём коде.
Если смотреть видео вам удобнее, то доклад есть на YouTube и ВК Видео. В статье вас ждёт текстовый вариант и ссылки на используемые материалы:
Почему важно помнить про память
Есть много причин, которые заставляют нас возвращаться к вопросу о том, сколько памяти занимает приложение. Вот только некоторые из них:
Устройства, в которых мало памяти. Даже сегодня, в 2025 году, на маркетплейсах можно найти смартфоны до 15 тысяч рублей с 3 ГБ оперативной памяти на борту. Чтобы понять, много это или мало, скажу, что около 6 лет назад у меня был недорогой смартфон, в котором даже тогда оперативной памяти было больше — 4 ГБ.
Индустрии, в которых объём памяти критичнее. Здесь я имею в виду не только очевидный геймдев или 3D-визуализацию, в которых количество полигонов или данных о них может влиять на плавность анимаций, но и, например, мессенджеры или сайты с аудио/видео-контентом — любые сайты с большой длительностью сессии. Чем дольше открыта вкладка, тем больше памяти потенциально может «утечь» за время её существования. И тем выше шанс увидеть экран «Опаньки».
Работа сборщика мусора занимает время и влияет на плавность анимаций. Сборка мусора — это тоже процесс, который требует ресурсы на выполнение. И чем чаще срабатывает сборщик, тем сильнее это сказывается на частоте кадров. Да, разработчики V8 делают всё возможное, чтобы оптимизировать процесс сборки мусора: разбивают его на разные потоки, выполняют часть работы во время простоя (когда не выполняются другие задачи). Но всё это только подтверждает наличие проблемы. Подробнее о том, как именно разработчики Chromium трудятся над снижением этого влияния и каких результатов им удалось добиться, можно прочитать в этой статье.
-
В Chrome видно вес вкладки. Если навести курсор на вкладку в браузере Chrome, то можно увидеть, сколько мегабайтов оперативной памяти она использует.
Например, страница моего доклада на сайте конференции HolyJS занимает 95 МБ. Что обо мне подумали бы мои коллеги-разработчики, если бы здесь было не 95 МБ, а 995?
-
Chrome Memory Saver. Ещё одна предательская фича Chrome, которая раскрывает всем пользователям объём памяти нашей вкладки. Если долго не пользоваться ею, то браузер высвободит память, которая использовалась для её работы. Позднее, когда вы вернётесь на эту страницу, Chrome отобразит уведомление о том, сколько памяти он нам сэкономил.
Последние два пункта больше важны с точки зрения нашей профессиональной репутации, ведь теперь любой пользователь может легко увидеть, насколько оптимально используются ресурсы на наших сайтах.
Как понять, что памяти используется слишком много?
В примере выше на скриншоте Chrome Memory Saver есть шкала, которая оценивает, «много» или «мало» памяти было сэкономлено, пока вкладка была неактивна. Это удобный способ оценки, но, на мой взгляд, не самый исчерпывающий: может быть, вашему сайту действительно нужны эти 719 МБ для стабильной работы? Может, это онлайн игра, 3D-редактор или другой ресурсоёмкий продукт? Есть продукты, где одной сотней мегабайтов не обойтись из-за их специфики, и это совершенно нормально.
Для оценки того, «много» или «мало» памяти используется вашим сайтом, я бы предложил ориентироваться на то, насколько вашим пользователям комфортно и насколько плавно он работает:
Если вы или ваши тестировщики наблюдаете проблемы с плавностью анимаций или скоростью работы, особенно после длительной работы сайта, то — это отличный повод проверить оптимальность хранения ваших данных.
Частые жалобы пользователей на ошибку Out Of Memory — ещё один однозначный показатель, что память расходуется неоптимально.
Отдельно я бы выделил те ситуации, когда у пользователей начинают тормозить другие сайты и приложения только из-за того, что у них открыта вкладка с вашим сайтом.
Последний пункт разработчику сложнее всего обнаружить. Но как клиент в такую ситуацию я попадал: один из сайтов, посвящённых игре World of Warcraft, сильно влиял на FPS в самой игре: если сайт был открыт, в игре появлялись заметные фризы. Это и послужило причиной подготовки моего доклада — пришлось углубляться во все возможные способы выяснить причину этих проблем.
Для того, чтобы объёмы потребляемой памяти на наших сайтах не доходили без необходимости до слишком высоких значений, полезно будет разобраться, как работает память в браузере.
Куча (Heap)
Данные и переменные, требуемые для исполнения JavaScript и работы вкладки браузера, хранятся в куче (Heap). Она, в свою очередь, делится на подпространства (Spaces), предназначенные для хранения данных с определённым типом, объёмом или сроком жизни. Эти пространства создаются одновременно с созданием самой кучи, в функции Heap::SetUpSpaces.
Список подпространств кучи V8 можно найти в enum AllocationSpace, и далее по исходному коду можно найти их описание и применение. Остановлюсь на нескольких из них:
Read-only space — пространство для хранения неудаляемых и неизменяемых данных. В их число входят значения тех переменных, без которых невозможно исполнение JavaScript. Например, такие значения, как false или true.
New space — сюда входят так называемые «молодые» переменные. Сборщик мусора в V8 разделяет данные на «поколения», предполагая, что те объекты, которые были созданы недавно, перестанут быть нужными с большей вероятностью, чем те, которые «живут уже давно». Поэтому, к данным «молодого» и «старого» поколения применяются разные алгоритмы сборки мусора. В New Space содержатся только данные «молодого» поколения, а за сборку мусора в этом пространстве отвечает Minor Garbage Collector или Scavenger. Для его работы пространство New space делится на два полупространства: From space и To space.
Old space. Если данные «молодого поколения» переживают несколько циклов сборки мусора в New Space, то они становятся данными «старого» поколения и переносятся в пространство Old Space. Здесь сборка мусора происходит с помощью другого сборщика — Major Garbage Collector.
Code space — пространство для хранения исполняемого кода на языке JavaScript.
Large object space. В это пространство помещаются те данные, размер которых превышает определённый лимит. Этот лимит определяется переменной kMaxRegularHeapObjectSize и в зависимости от среды исполнения и параметров запуска Chrome может иметь значение в 128, 256 или 512 Кб.
New large object space — пространство для данных «молодого» поколения, превышающих допустимый лимит размера. Интересный факт: экспериментально мне так и не удалось подтвердить, что в это пространство вообще записываются какие-либо данные. Судя по журналу, оно всегда остаётся пустым. Могу только добавить, что в исходном коде сборщика мусора Scavenger в v8 есть упоминание New Large Object Space с комментарием, исходя из которого можно сделать вывод, что для New Large Object Space тоже предусмотрены (или были предусмотрены) полупространства From space и To space.
Old large object space — пространство для хранения больших переменных «старого» поколения.
Code large object space — пространство для хранения больших объёмов исполняемого кода.
Максимальный объём кучи
В функции Heap::HeapSizeFromPhysicalMemory задаётся максимально возможный объём всей кучи. Так, для 64-битных ОС (кроме Android) с объёмом доступной физической памяти более 15 ГБ максимальный объём Heap составит 4 ГБ. Он не будет выделен в памяти сразу. Это значение — лимит, по достижении которого пользователь увидит экран «Aw, Snap!» или «Опаньки» с ошибкой Out Of Memory.
Лимитом можно управлять с помощью флагов. Например, с помощью --max-heap-size можно задать своё собственное ограничение размера Кучи (в мегабайтах), а флагами --max-old-space-size и --max-semi-space-size можно задать лимиты размеров Old space и полупространств для New space соответственно.
Обратите внимание:
Флаг --max-semi-space-size задаёт размер одного полупространства. New space состоит из двух полупространств. Соответственно, размер New space будет вдвое больше того значения, которое вы передадите в этот флаг.
Флаги --max-semi-space-size, --max-old-space-size и --max-heap-size не получится использовать одновременно. Судя по исходникам, сейчас флаги --max-semi-space-size и --max-heap-size – взаимоисключающие. Условия их применения лучше всего смотреть в Heap::ConfigureHeap, поскольку исходный код немного отличается от того, что написано в комментариях к самим флагам (в описании говорится, что приоритет отдаётся --max-semi-space-size и --max-old-space-size, хотя по коду в Heap::ConfigureHeap видно, что это не так: в действительности первым анализируется --max-semi-space-size, а при его отсутствии — --max-heap-size. А флаг --max-old-space-size в этой функции берётся во внимание только в том случае, когда присутствует --max-heap-size и отсутствует --max-semi-space-size).
Сборка мусора
Выше я упоминал о существовании разных механизмов сборки мусора. Подробнее их описал Александр Зайцев в своём докладе с HolyJS 2024 Spring. Я же остановлюсь на них вкратце:
-
Minor Garbage Collector или Scavenger. Этот сборщик мусора реализует алгоритм Cheney, и работает с двумя полупространствами — From space и To space. Доступная память делится на два полупространства, данные записываются только в одно из них и только до тех пор, пока полупространство не будет заполнено. Как только полупространство заполняется, запускается сборка мусора. Сборщик определяет, какие данные всё ещё доступны из корней GC, а какие — нет. Данные можно представить в виде графа, где вершиной будет корень GC, ветвями — ссылки на эти данные, а узлами — сами данные.
Корнями GC можно считать глобальную область видимости JS, DOM-дерево или контекст отладчика. На иллюстрации выше узел 1 — корень, узлы 2-8 доступны, а узлы 9 и 10 — недоступны.
После того, как сборщик мусора определил, какие объекты являются доступными, эти объекты переносятся из From space в To space. Затем очищаются все те данные, которые остались во From space. Новые данные будут заполнять уже полупространство To space до тех пор, пока оно не заполнится и цикл не повторится снова.
Данные, которые пережили два таких цикла, переносятся из New space в Old space. Major Garbage Collector или Mark-Compact. Этот сборщик мусора работает в Old space. Вместо полупространств он имеет под капотом другие механизмы для оптимизации занимаемого в памяти места — это может быть расширение объёма Кучи с созданием дополнительных хранилищ для Old space, нечто вроде «дефрагментации» (когда все данные эффективнее складываются в памяти, чтобы избежать пустых пространств), либо другие алгоритмы. Ключевое, что нужно знать об этом сборщике для дальнейшего чтения статьи — он тоже работает с тем, чтобы определять доступные и недоступные данные по графу ссылок от корня GC.
В инструментах разработчика Chrome во вкладке Performance можно увидеть, как часто собирается мусор в ваших сценариях, достаточно нажать на «запись» и выполнить на сайте те действия, которые вас интересуют. После этого в нижней части страницы в разделах Bottom Up или Call Tree можно ввести в фильтр буквы «GC» (аббревиатура от Garbage Collector) и увидеть как Minor GC, так и Major GC:
Бывают случаи, когда даже Minor GC выполняется довольно часто — на иллюстрации ниже показано, как общее время выполнения всех вызовов Minor GC занимает более 4% от всего времени исполнения JavaScript в сценарии.
Флаги
Другой способ увидеть статистику по сборкам мусора — запустить браузер Chrome с флагами. Это делается либо через консоль, либо через добавление параметров запуска в свойствах ярлыка браузера Chrome, если вы работаете с Windows. Проблема у Windows в том, что при использовании параметров запуска мы не увидим журнал в консоли. Поэтому для просмотра журнала сборки мусора удобнее работать на UNIX-подобных системах.
Для запуска браузера через консоль на macOS введите:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --incognito --disable-extensions --js-flags="--trace-gc"
Рассмотрим подробнее каждый из параметров:
--incognito — флаг для запуска нового окна браузера в режиме инкогнито, чтобы исключить влияние посторонних вкладок или кеша. Поскольку в журнал будут писаться все сборки мусора со всех вкладок и всех открытых инструментов разработчика, это будет просто мешать, поэтому важно изолировать свою среду. С этой же целью можно использовать флаг --profile-directory="ПАПКА ПРОФИЛЯ", и использовать для тестирования отдельный чистый профиль пользователя без сторонних вкладок и расширений браузера.
--disable-extensions — этим флагом мы выключаем расширения браузера, которые тоже могут искажать вывод журнала в консоли.
--js-flags — сюда внутри кавычек через пробел будут передаваться те флаги, которые влияют на работу движка v8.
--trace-gc
Этот флаг, переданный внутрь --js-flags="", отвечает как раз за журналирование сборщика мусора. С этим флагом при каждой сборке вы будете видеть в консоли сообщения следующего формата:
[90121:0x10c00a0c000] 47711 ms: Scavenge 15.5 (17.3) -> 14.8 (17.3) MB, pooled: 1 MB, 0.36 / 0.00 ms (average mu = 0.997, current mu = 0.976) task;
Из записи видно, какой тип сборки мусора был выполнен (в данном случае слово Scavenge говорит о том, что мы имеем дело с Minor Garbage Collector. Другой тип сборщика мусора был бы отмечен в консоли словами Mark-Compact). Указывается также объём данных до и после сборки мусора и среднее время выполнения задания.
--trace-gc-verbose
Передав этот флаг, мы увидим гораздо больше статистики по сборкам мусора:
Здесь, помимо общего объёма, времени выполнения и типа сборщика мусора, видно ещё и влияние сборщика на каждое из пространств. Например, на скриншоте выше я выделил цветом, сколько КБ занимает New space и New large object space (в графе used), а также сколько памяти выделено для этих пространств (committed). Здесь как раз и видно, что New large object space всегда занимает 0 КБ, и повлиять на это значение мне никак не удалось. Другой интересный момент: значение Read-only space тоже оставалось всегда одним и тем же — 407 КБ.
По сборщику Mark-Compact статистика выглядит так же — видны изменения в Old space, Code space, Large object space и Trusted space.
--expose-gc
Проблема журналирования сборки мусора состоит в том, что прежде чем увидеть результат, сборку мусора приходится ждать. Бывает, она запускается быстро, но иногда приходится ожидать по 10-15 секунд. Соответственно, это усложняет нам возможность проверить, как именно тот или иной конкретный сценарий на сайте влияет на потребление памяти.
На помощь приходит флаг --expose-gc. При запуске браузера с ним становится доступен вызов сборки мусора через window.gc() прямо из JavaScript. Можно повесить эту функцию на любую кнопку на сайте и вызывать сборку мусора вручную в любой момент.
--max-heap-size
Выше я упомянул, что этим флагом можно ограничить максимальный объём Heap. Ниже приведён пример журнала, когда размер Heap ограничен 50 мегабайтами и выделенный объём памяти уже приближается к заданному лимиту:
В строке Memory Allocator видно, сколько памяти уже используется кучей (used), и сколько ещё доступно (available). Сборка мусора в такой ситуации будет проводиться всё чаще, а при достижении лимита Кучи мы увидим тот самый экран «Опаньки»:
В нашем синтетическом случае мы не говорим о том, что произошла утечка, ведь мы искусственно ограничили размер Кучи и выделенное место быстро закончилось, что закономерно. Веб-приложения могут занимать и 200, и 500, и более мегабайтов, если этого требуют нюансы реализации. Один только объём ещё не говорит об утечке. Её мы можем констатировать тогда, когда увидим изменение объёма занимаемой памяти в динамике, и когда поймём, что неиспользуемые нашей программой данные не просто не очищаются, но и множатся в количестве.
Утечки памяти в React и немного про Array Buffer
Для визуализации утечки очень хорошо подходит пример из статьи Kevin Schiener – Sneaky Memory Leaks. В ней автор объявляет класс BigObject с полем data, которое содержит Uint8Array весом в 10 Мб. Экземпляр этого BigObject будет попадать в замыкание, а благодаря его большому весу и уникальному названию эту утечку будет довольно просто увидеть в инструментах разработчика.
class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10)
}
const App = ({ item }) => {
const [countA, setCountA] = useState(0)
const [countB, setCountB] = useState(0)
const bigData = new BigObject()
const handleClickA = useCallback(() => {
setCountA(countA + 1)
}, [countA])
const handleClickB = useCallback(() => {
setCountB(countB + 1)
}, [countB])
const handleClickBoth = () => {
handleClickA()
handleClickB()
console.log(bigData)
}
return (
<>
<button onClick={handleClickA}>Increment A</button>
<button onClick={handleClickB}>Increment B</button>
<button onClick={handleClickBoth}>Increment Both</button>
<p>
A: {countA}, B: {countB}
</p>
</>
)
}
Если в этом компоненте попеременно нажимать кнопки Increment A и Increment B, то при каждой новой отрисовке будут создаваться новые экземпляры BigObject. При этом экземпляры из предыдущих рендеров всё ещё будут находиться в замыканиях у того useCallback, чьи зависимости не изменились с предыдущей отрисовки, и который не был пересоздан во время нового рендера. Таким образом можно воссоздать цепочку из бесконечных замыканий и разогнать вес вкладки до любого размера.
Более подробно о том, почему так происходит, можно почитать в оригинальной статье. Для нас же важнее выводы, которые делает автор:
Компоненты должны быть небольшими: сложные компоненты с большим количеством хуков и логики могут влиять на количество памяти, используемое нашим приложением.
Используем кастомные хуки — сложную логику лучше хранить там, а не в самом компоненте
Чрезмерная мемоизация может навредить
Этот пример, конечно, касается только React, в том же Vue гораздо сложнее наткнуться на похожую проблему.
Но в этом примере интересен и другой момент. Обратите внимание, что для простоты иллюстрирования утечки памяти автор использует Uint8Array. А теперь предлагаю посмотреть, как такая утечка памяти будет выглядеть в журналах Chrome с флагом --trace-gc-verbose:
По журналу видно, что максимальный размер Heap ограничен 50 МБ (в пункте Memory allocator мы видим 2952 КБ в used и 48248 КБ в available). При этом ниже, напротив External memory reported и Backing store memory, мы видим ещё 8 ГБ данных. Эта память уже выделена, в ней хранятся те самые наши подтекающие Uint8Array. При этом их размер (8 ГБ) значительно превышает максимальный объём Heap (заданный флагом --trace-gc-verbose=50), а ошибки Out of Memory мы так и не увидели — сайт продолжает работать.
И он будет продолжать работать до тех пор, пока из-за роста объёма Backing store рано или поздно мы не получим другую ошибку — Uncaught RangeError: ArrayBuffer allocation failed. Ошибку эту можно обработать с помощью try catch и отобразить любой кастомный экран с какими-нибудь рекомендациями для пользователя. Возникает эта ошибка не тогда, когда закончится место в Heap, а когда операционная система вообще откажет браузеру в возможности выделять дополнительный объём. Например, когда на устройстве вообще закончится свободная физическая память. Такое поведение характерно для многих типов данных, так или иначе использующих ArrayBuffer. Экспериментально мне удалось подтвердить, что это характерно для AudioBuffer, VideoBuffer, и для web-кеша — изображений, файлов .js и .css и т.д.
Как обнаружить утечку
Один из самых простых способов отслеживать потребление памяти — диспетчер задач Chrome.
В нём можно настроить отображение колонок и отслеживать разные показатели по каждой вкладке, но на иллюстрации выше я вывел два наиболее важных с точки зрения памяти:
Память JavaScript — показывает объём Heap по каждой вкладке. Значение в скобках показывает объём тех данных, которые не будут очищены сборкой мусора. Если это значение постоянно растёт, это может говорить об утечке.
Объём потребляемой памяти — показывает объём памяти устройства, которое задействовано для работы вкладки. Частично данные из ArrayBuffer будут отображаться здесь. Частично, поскольку для Web-кеша в этой таблице есть свои соответствующие колонки. Помимо этого, здесь же, в колонке «объём потребляемой памяти» хранятся DOM-узлы. Неоптимальная работа с DOM-деревом тоже может быть причиной утечки и ошибки Out Of Memory.
Когда текут DOM-узлы
На мой взгляд, самым ярким примером утечки DOM-узлов является сайт YouTube. Давайте вместе с вами измерим его производительность. Для этого зайдём на YouTube, откроем вкладку Performance в браузере и начнём замер производительности с включенным чекбоксом «Memory».
Для измерения нам достаточно провести любой простой сценарий. Можете зайти на страницу любого канала и несколько раз переключить там вкладки «Главная» и «Видео». Достаточно будет 2-3 раза переключиться между этими вкладками на странице, чтобы получить внятный результат:
Если разработчики YouTube ещё ничего не поправили, то после замера производительности мы увидим следующий график:
На иллюстрации видно, что и размер кучи (синий график) и количество узлов DOM-дерева (зелёный график) постоянно растут. Не очищает эти данные даже ручная сборка мусора, которую в инструментах разработчика можно запустить нажатием на кнопку с иконкой веника.
Увы, график покажет только общую картину, но подробностей в нём будет не так много. Тем не менее, саму проблему он подсвечивает вполне однозначно, и теперь мы можем продолжить своё расследование утекающих DOM-узлов, используя уже другие инструменты.
Heap Snapshot
Во вкладке Memory есть инструмент для снятия снимков памяти. Нужно выбрать основной поток исполнения JS на сайте (не сервис-воркеры, а именно Main-тред) и нажать кнопку Take Snapshot.
Снимок покажет, какие именно данные хранятся в памяти в конкретный момент времени, поэтому для того, чтобы увидеть динамику, нужно будет сделать несколько снимков и сравнить их друг с другом. Для этого можно использовать так называемую «технику трёх снапшотов». В случае с нашими протекающими DOM-узлами на YouTube она выглядит так:
Делаем первый снимок в самом начале исследования
Несколько раз переключаем на странице вкладки «Главная» и «Видео».
Делаем второй снимок
Снова переключаем вкладки на странице несколько раз
Делаем третий снимок
После этих измерений я увидел следующую картину:
В левой части экрана перечислены сами снимки. Видно, что каждый последующий снимок становится тяжелее предыдущего на ~60 МБ — это довольно сильный прирост.
В правой части экрана показана таблица с содержимым выбранного снимка. В колонке Constructor указано название конструктора, с помощью которого созданы те или иные данные. В колонке Retained Size показывается тот объём памяти, который высвободился бы, если бы эти данные были удалены сборщиком мусора. Именно по колонке Retained Size удобнее всего смотреть влияние того или иного конструктора на объём памяти.
Если внимательнее посмотреть на колонку Constructor, то мы увидим, что многие из них в нашем снимке начинаются со слова Detached. Этим ключевым словом как раз и отмечаются те DOM-узлы, которые уже не участвуют в отрисовке интерфейса, и их уже нет в самом DOM-дереве, но данные о них всё ещё хранятся в памяти.
Посмотреть на все эти DOM-узлы можно несколькими способами:
-
Клик на All Objects позволит вызвать контекстное меню, в котором есть отдельный пункт «Objects retained by detached DOM nodes». Выбрав его, мы отфильтруем таблицу и отобразим только те элементы, которые хранятся в памяти из-за того, что сборщик мусора не может очистить эти DOM-узлы.
-
Начиная со 130 версии браузера Chrome во вкладке Memory появился новый тип профилирования: вместо снимка памяти можно сразу получить список таких detached-элементов:
Узлы DOM-дерева могут быть пропущены сборщиком мусора по разным причинам. Работая с DOM-деревом, разработчики могут записывать в переменные результаты выполнения методов document.querySelector, document.getElementById,document.getElementsByClassName и других. Важно проследить, чтобы эти переменные не оставались в замыкании других переменных или функций, которые не очищаются сборкой мусора, не использовались в неочищенных setInterval или setTimeout, и не сохранялись в глобальную область видимости.
Да, в современной frontend-разработке на том же React почти не приходится прибегать к методам из document. Но даже если вы не используете document нигде в своей кодовой базе, не используете глобальные переменные и не забываете вызывать clearInterval и clearTimeout, то не все библиотеки и инструменты могут похвастаться тем же: например, Google Tag Manager, инструмент для сбора метрик и аналитики, вызывает утечки памяти в случае, если его неверно настроить для работы с Single Page Application. А в React 17 DOM-узлы утекают, если при размонтировании компонента не вызывать очистку данных в useRef.
Как понять, что течёт код на React
Если уж даже современные frontend-библиотеки вызывают утечки памяти, то как можно проверить свой код на React? И как найти связь между содержимым снимка и тем, что я пишу в своём коде на React?
Ответить на этот вопрос нам поможет FiberNode. С его помощью React определяет, какой из компонентов должен быть перерисован, а какой можно оставить без изменений. Специальная структура FiberNode хранит все необходимые данные о компонентах, и эти данные легко можно отыскать в снимке Кучи по ключевому слову Fiber:
Теперь мы сразу можем оценить объём занимаемых нашим React-приложением данных, а также количество FiberNode в Куче. И объём, и количество этих данных мы легко можем оценить в динамике, используя «технику трёх снапшотов».
Если окажется, что какой-то из FiberNode занимает слишком много места, то по составу его полей можно легко понять, для какого компонента была создана эта структура, и где кроется причина утечки. Например, в полях type или elementType может храниться само название нашего компонента, такое же, как мы объявили его в коде.
На иллюстрации выше название CardDelivery() в поле elementType — это как раз то название, которое я сам дал своему компоненту.
Иногда поля type и elementType могут содержать менее информативные данные, например, «<div>». В этом случае можно прибегнуть к утиной типизации и определить компонент по другим ключевым полям:
pendingProps, memoizedProps — как несложно догадаться из названия, в этих полях содержатся данные о props компонента. По их составу вы легко сможете понять, о каком компоненте идёт речь.
child, sibling — по этим полям можно определить, какой компонент является дочерним по отношению к текущему, а какой — смежным. Если у какого-то компонента есть дочерний компонент, то в поле child у FiberNode этого компонента будет храниться как раз FiberNode дочернего компонента.
memoizedState — в этом поле хранятся хуки, используемые в ваших компонентах. Опять же, по составу данных и по зависимостям этих хуков вы сможете понять, о каком компоненте идёт речь.
Более подробно состав Fiber можно посмотреть в исходниках React.
Есть, правда, одна проблема — в production-среде, вероятнее всего, само название FiberNode не сохранится. Вместо него будет что-то минифицированное, как на иллюстрации ниже:
В моём случае вместо FiberNode я вижу конструктор с названием iw, но по составу полей внутри него я понимаю, что это и есть FiberNode — вряд ли какая-то ещё библиотека на сайте использует такую структуру.
Как я понял, что смотреть нужно именно в iw? Ведь разных конструкторов могут быть сотни. Как среди них найти нужный? В этом нам поможет __REACT_DEVTOOLS_GLOBAL_HOOK__: вызвав его в консоли мы получим объект, внутри которого в поле rendererInterfaces можно найти упоминание того самого конструктора, который нам нужен:
Как понять, что течёт код на Vue
Следуя похожей логике, можно изучить и то, как потребляется память в других популярных frontend-экосистемах. Например, из того, что я нашёл в исходников Vue, наиболее похожим на FiberNode по смыслу является VNode. VNode тоже хранится в снимке, тоже содержит данные о компонентах, и по его содержимому тоже можно понять, с каким именно компонентом мы имеем дело.
Рассмотрим некоторые его поля:
__v_isVNode — булев флаг, благодаря которому мы понимаем, что имеем дело с VNode;
-
type — может быть как строкой, например «div» или «span», так и другой VNode или полноценным объектом Component, в котором будет вообще вся информация о компоненте, включая название (__name), передаваемые параметры (props) и даже путь до файла (__file):
props — у VNode тоже могут быть пропсы, и передаваемые параметры можно посмотреть и здесь;
el, anchor, target — ряд полей, которые будут содержать связь с DOM-деревом;
memo — данные, которые передаются внутрь v-memo.
С остальными полями VNode можно ознакомиться в исходниках Vue.
Чтобы найти в снимке все VNode, достаточно отфильтровать конструкторы по строке __v_isVNode — начиная со 130 версии Chrome объекты со схожими полями группируются, и по полю __v_isVNode можно будет легко отыскать группу тех объектов, которые это поле в себе содержат.
Как автоматизировать?
Чтобы не анализировать все эти данные вручную, рекомендую воспользоваться инструментом memlab.
Во-первых, он позволяет описывать свои тестовые сценарии в синтаксисе, напоминающем Cypress или Playwright — получится что-то вроде e2e-теста, но нацеленного на работу с памятью. Концептуально там используется всё та же техника трёх снимков, и в финале такого тестового прогона в консоль будет выведено сравнение этих снимков между собой, а также будут подсвечены потенциальные виновники утечек памяти:
Во-вторых, помимо работы с тестовыми сценариями, memlab предоставляет API для анализа снимков. Описание можно найти в документации.
Например, с помощью функции getFullHeapFromFile можно подгрузить файл .heapsnapshot и получить доступ к списоку всех узлов в графе этого снапшота. А далее — делаем с ними всё, что захотим. Например, можно пройтись по ним в цикле, найти все ноды, в названии которых упоминается FiberNode, и вывести, например, 3 самых тяжелых компонента в вашем React-приложении. Я даже подготовил gist на github с примером того, как это можно сделать.
Итоги
При подготовке этого материала важным откровением для меня стало то, что неоптимальная работа с ресурсами на моём сайте может ухудшить пользовательский опыт на чужих сайтах. И наоборот, если клиент зайдёт на мой сайт, а в соседней вкладке у него будет открыто что-то очень ресурсоёмкое, то и на моём сайте пользовательский опыт пострадает.
Именно поэтому я решил собрать воедино всю информацию об имеющихся на данный момент инструментах для работы с памятью. Мне хотелось, чтобы у каждого разработчика было не просто представление о том, что это за инструменты, но и о том, как с их помощью проанализировать используемые библиотеки, и как интерпретировать показания инструментов разработчика, чтобы быстрее найти проблемное место в своём коде.
В своём Telegram-канале я опубликовал отдельный пост со ссылками на все статьи, материалы и исходники, которые я использовал в работе.