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

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

let bigData = {
  id: 42,
  data: new Array(1000000).fill('Some data') // Генерим большой объект
}

function handler(data) {
  return function() {
    console.log(data.id) // Используем только одно свойство объекта

    debugger // Точка остановы чтобы увидеть что хранится в памяти на этот момент
  }
}

const runHandler = handler(bigData)  

bigData = null // Теперь bigData доступен только через замыкание

runHandler()

В примере выше, внутренняя функция использует только одно свойство id объекта bigData. Но замыкание захватит объект целиком, заблокировав его для сборщика мусора. В этом можно убедиться, если запустить этот код, и проверить замыкания через DevTool когда его работа встанет на паузу, на из-за точки остановы на 10-й строке (как это сделать я описал в прошлой статье):

Утечка памяти в замыкании
Утечка памяти в замыкании

Замыкания и функция eval()

Напомню что за использовать eval(), фронтендер должен быть пожизненно сослан в подвал верстать таблицами письма для рассылок. На то есть множество причин, работа замыканий в eval() это одна из них:

function greeting() {
  let hello = 'Привет'
  let userName = 'Васятка'

  setTimeout(function () {
    debugger

    return eval('console.log("Привет это Васятка!")')
  }, 1000)
}

greeting()

Если запустить этот код и потом отправиться проверять замыкания в DevTool, то результат будет следующим:

Утечка памяти из-за функции eval()
Утечка памяти из-за функции eval()

То есть, не смотря на то что внутренняя функция не обращается, к внешним переменным hello и userName, они присутствуют в замыкании. Так происходит потому что, движок JS не знает какой код запускается в eval() и добавляет все переменные внешней функции в замыкание, на всякий случай. Это классическая утечка памяти, которая скажется на производительности самым печальным образом. Вот почему eval() нет места в хорошем коде.

Способы борьбы с утечками памяти в замыканиях

Сброс замыканий

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

function runCalc(buttonId) {
  let bigData = new Array(1000000).fill('Something') // Генерим большой объект с данным
  const button = document.getElementById(buttonId)

  button.addEventListener('click', function onClick() {
    console.log('Обработано элементов:', bigData.length)

    bigData = null // После использования данные больше не нужны, стираем их
  })
}

runCalc('myElem')

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

Удаление неиспользуемых обработчиков событий

В предыдущей статье, мы узнали что, обработчики событий это самый частый случай замыкания в JavaScript. Утечка памяти в них может возникнуть когда элемент на который обработчик повешен, был удалён из кода:

function clickSetup(btn, message) {
  const bigData = new Array(1000000).fill(message)

  btn.addEventListener('click', () => {
    console.log(message, bigData.length)
    btn.remove() // Удаляем из DOM
  })
}

const elem = document.getElementById('myElem')

clickSetup(elem, 'Клик!')
elem.click() // Имитируем клик по элементу

setTimeout(() => {
  console.log(elem) // Удалённый элемент всё ещё существует
  elem.click() // Обработчик всё ещё работает!
}, 3000)

В примере выше, обработчик клика удаляет элемент на которой он повешен, а в замыкании возникшем из-за обработчика, зависает большой объект bigData. Далее мы имитируем клик по элементу, чтобы его удалить и потом в таймере, спустя 3 секунды, проверяем что несмотря на то что элемент был удалён из DOM, ссылка на него всё ещё хранится в переменной elem и при новом клике обработчик уверенно продолжает, выводить длину массива bigData, оставшегося лежать в замыкании.

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

function clickSetup(btn, message) {
  const bigData = new Array(1000000).fill(message)

  function handler() { // Делаем обработчик именнованным, чтобы его можно удалить
    console.log(message, bigData.length)
    btn.remove()
    btn.removeEventListener('click', handler) // Удаляем обработчик, в след за элементом
  }

  btn.addEventListener('click', handler)
}

let elem = document.getElementById('myElem')

clickSetup(elem, 'Клик!')
elem.click()

setTimeout(() => {
  console.log(elem) // Элемент всё ещё есть
  elem.click() // А вот обработчика событий уже нет, поэтому ничего не происходит
}, 3000)

WeakMap

WeakMap - это особый объект JavaScript, представляющий собой коллекцию пар ключ/значение. Он обладает рядом уникальных особенностей, подробнее о которых ниже.

Особенности WeakMap

Ключи объекты

Ключами в WeakMap могут быть только объекты, что крайне необычно, согласны? Сразу возникает вопрос: а как тогда получить значению по ключу-объекту? Не набирать-же его в коде целиком? Очень просто, нужно передать специально обученному методу get() ссылку на ключ-объект:

const weak = new WeakMap()
const key = {} // Создаём объект-ключ

weak.set(key, 'Здесь был Васятка!') // Сохраняем значение по этому ключу
console.log(weak.get(key))
const anotherKey = {} // Создаём ещё один объект-ключ
console.log(weak.get(anotherKey)) // undefined

В примере выше мы привязали значение, к пустому объекту, хранящемуся в переменной key. Потом чтобы получить значение передали эту переменную, в метод get(), который вернул ассоциированное с полученным объектом значение. Затем мы создали ещё один пустой объект anotherKey, выглядящий в коде точно так-же как и key и снова передали его в метод get(). Но в этот раз, он вернул undefined, потому что не смотря на идентичность в коде, в anotherKey хранится ссылка на другой объект, для которого в WeakMap не имеется значений.

Слабые ссылки

Слабые ссылки - это ссылки не препятствующие сборке мусора, если на них ссылается только сам объект WeakMap. То есть если ключ нигде не используется в коде, он может быть удалён сборщиком мусора, при этом не тронув другие ключи объекта WeakMap:

const weak = new WeakMap()

let userKey1 = {id: 1}
let userKey2 = {id: 2}
let userKey3 = {id: 3}

weak.set(userKey1, 'Васятка')
weak.set(userKey2, 'Пётр')
weak.set(userKey3, 'Василий')
// Удаляем сильные ссылки на userKey1 и userKey2
userKey1 = null
userKey2 = null

В примере выше мы сохранили в WeakMap, три значения. Потом обнулили ссылки на 2 первых ключа-объекта. Это приведёт к тому что, сборщик мусора автоматически удалит и привязанные к ним данные. Ведь и в правду, зачем их хранить если без ключей-объектом нам их уже не получить?

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

Нет свойства size и итераторов

Отсутствие свойства size, обусловлено самой природой WeakMap, который хранит данные, исключительно по слабым ссылками. Соответственно любое значение может быть удалено сборщиком мусора, в любой момент. В таких условиях пытаться узнать длину WeakMap, это всё равно что бросать монетку. Максимум можно узнать это мгновенную длину, которая может устареть уже к следующей строке кода. Всё это делает size бесполезной и даже опасной фичей, поэтому её решили просто нет:

console.log(weak.size) // undefined

По этой-же причине у WeakMap отсутствуют методы итераторы (forEach, keys()values()entries()), так как в промежутке между итерациями часть ключей уже может быть удалена сборщиком мусора, что делает поведение WeakMap в цикле непредсказуемым. Попытка вызвать метод итератор, приведёт к ошибке:

weak.forEach((item) => { // Uncaught TypeError: weak.forEach is not a function
    console.log(item)
})

В WeakMap доступны только самые базовые методы для работы с данными:

weak.set(userKey, 'Васятка') // Добавление элемента
weak.get(userKey) // Получение значения: 'Васятка'
weak.has(userKey) // Проверка наличия: true
weak.delete(userKey) // Удаление элемента
weak.has(userKey) // false

WeakMap и утечки памяти в замыкании

После небольшого ликбеза о WeakMap, мы можем вернуть к наши баранам замыканиям:

function createProcessor() {
  const weakMap = new WeakMap()

  return function process(obj) {
    weakMap.set(obj, obj.value)

    debugger

    return result
  }
}

const processor = createProcessor()

let bigData = {
  value: 21,
  hugeArray: new Array(1000000).fill('*')
}

console.log(processor(bigData))

bigData = null // Удаляем единственную сильную ссылку на объект
let bigData2 = { // Новый большой объект
  value: 42,
  hugeArray: new Array(1000000).fill('1')
}

console.log(processor(bigData2))

В примере выше создаётся объект объект с большим данными bigData который потом добавляется в WeakMap(), являющийся частью замыкания функции process(). Затем объект bigData обнуляется, а вместе с ним удаляется единственная сильная ссылка на объект. После мы создаём новый объект bigData2, и тоже добавляем его в WeakMap(). Если бы не WeakMap(), ненужный объект bigData, завис бы в замыкании, создав утечку памяти.

Наглядно увидеть работу данного механизма, можно создав точку остановы в функции process() и выполнив код по строчн в том-же DevTool через специально обученную кнопку:

Пошаговое выполнение кода
Пошаговое выполнение кода

Добравшись до строчки с обнулением bigData:

bigData = null

Нужно в инструментах разработчика нужно будет перейти на вкладку Память (бурж. Memory):

Вкладка Память в инструментах разработчика
Вкладка Память в инструментах разработчика

И тут принудительно вызвать сборщика мусора, кликнув по специальной кнопке:

Принудительный сброс мусора
Принудительный сброс мусора

Данный мув, с принудительным вызовом сборщика нужен для чистоты эксперимента, так как сам он может не успеть отработать, до следующего вызова функции processor(), а нам нужна предсказуемость. После очистки памяти продолжаем построчно выполнять код, кликая по кнопке следующего шага в режиме отладки (или нажимая на горячую клавишу F10). Как только снова окажемся внутри функции processor(), вернёмся обратно на вкладку Источники (бурж. Sources). Там увидим что внутри WeakMap(), сохранённой в замыкания, находится только один последний объект bigData2, с значением 42:

Результат работы сборщика мусора и WeakMap()
Результат работы сборщика мусора и WeakMap()

А первый объект bigData, со значением 21, был успешно удалён сборщиком мусора, во время принудительного вызова. Это доказывает что WeakMap(), в отличии от других объектов в JavaScript, не препятствует сборке мусора, элементов утративших сильные ссылки, тем самым не допуская утечек памяти. Можно повторить эксперимент, но уже без принудительного вызова сборщика мусора. Теперь он с большой вероятностью не успеет отработать ко второму вызову функции processor() и мы сможем застать в замыкании первый объект bigData со значением 21:

Демонстрация утечки памяти, возникающей без принудительного вызова сборщика мусора
Демонстрация утечки памяти, возникающей без принудительного вызова сборщика мусора

Что только доказывает влияние сборщика мусора, на работу WeakMap().

WeakSet

Если WeakMap() это аналог коллекции Map(), не препятствующий работе сборщика мусора, то как нетрудно догадаться WeakSet(), является аналогом коллекции уникальных значений Set().

Все особенности WeakMap() разобранные выше:

  1. Работает только с объектами для создания слабых ссылок;

  2. Нет свойства size;

  3. Нет методов итераторов.

В равной степени характерны и для WeakSet(). А вот работа с данными уже заметно отличаются:

const weakSet = new WeakSet()

let user = {name: 'Васятка'}

weakSet.add(user) // Добавляем новый элемент
weakSet.has(user) // true
weakSet.delete(user)
weakSet.has(user) // false

Внимательный читатель, сейчас задастся вопросом: а где метод геттер? WeakSet() хранит объекты, но без ключей или ещё каких ассоциаций как в WeakMap(). Соответственно и получить эти данные из WeakSet() невозможно. У него есть только метод has(), говорящий есть ли переданный объект в коллекции или нет.

WeakSet() это крайне специфическая структура данных, единственное применение которой: проверить принадлежит объект к множеству или нет. Например при помощи него, можно помечать обработанные объекты, избегая затрат ресурсов, на дублирующий анализ:

function createProcessUser() {
    const processedSet = new WeakSet()

    return function processUser(user) {
        debugger

        if (processedSet.has(user)) {
            console.log(`WeakSet: Пользователь ${user.name} уже обработан!`)

            return false
        }

        console.log(`WeakSet: Обрабатываем пользователя ${user.name}`)
        processedSet.add(user)

        return true
    }
}  

let user1 = { name: 'Васятка', role: 'admin' }
let user2 = { name: 'Пётр', role: 'buyer' }

const userProcessor = createProcessUser()

userProcessor(user1) // Обрабатываем пользователя Васятка
userProcessor(user2) // Обрабатываем пользователя Пётр
userProcessor(user1) // Пользователь Васятка уже обработан!

user1 = null // Сбрасываем одного из пользователей

userProcessor(user2) // Пользователь Пётр уже обработан!

В примере выше, WeakSet() используется, в замыкании, а потом потом обнуляется пользователь user1. Как и в примере с WeakMap(), открыв инструменты разработчика в браузере и в режиме пошагового дебаггинга, дойдя до строчки с обнулением пользователя:

user1 = null

Нужно принудительно вызвать сборщик мусора и оказавшись в последний раз внутри функции processUser(), убедиться что объект хранившийся в переменной user1, пропал и из WeakSet():

Результат работы сборщика мусора с WeakSet()
Результат работы сборщика мусора с WeakSet()

Вывод

Современные движки JavaScript хороши в оптимизации кода и позволяют в большинстве случаев, фронтендерам даже и не подозревать о существовании утечек памяти, да и о существовании какой-то там памяти в принципе. Но это не означает что Вы 100%, не нарвётесь на подобные проблемы. Поэтому для разработчика важно нюансы функционирования замыканий, чтобы при необходимости самостоятельно управлять их жизненным циклом. В данной статье мы рассмотрели массу пограничных случаев, когда могут возникнуть утечки в памяти из-за замыканий и то как этому можно противодействовать. Производительность и оптимизированность кода, всегда была, есть и будет зоной ответственности разработчика, написавшего его.