Олег Марков
Уничтожение компонента destroyed - как правильно очищать ресурсы и подписки
Введение
Этап уничтожения компонента, который во многих фреймворках обозначается как destroyed, часто воспринимается как второстепенная деталь жизненного цикла. Однако именно здесь решается, будет ли приложение работать стабильно и без утечек памяти через несколько часов или дней после запуска.
Давайте разберемся, что такое уничтожение компонента, зачем вообще нужен этап destroyed, что обычно происходит в этот момент и как правильно реализовать очистку ресурсов, подписок и обработчиков событий. Я буду опираться на общие принципы и при этом показывать примеры на популярных подходах (Vue, Angular, React-подход), чтобы вы могли легко перенести идеи в свой стек.
Жизненный цикл компонента и место destroyed
Что такое жизненный цикл компонента
Практически любой компонент в современном фронтенд-фреймворке проходит несколько стадий:
- Создание
- Инициализация и монтирование в DOM
- Обновление при изменении данных
- Уничтожение (демонтирование)
Стадия destroyed — это финальная точка жизни конкретного экземпляра компонента. На этом этапе:
- компонент удаляется из DOM
- от него отписываются родительские структуры (например, виртуальный DOM или система маршрутизации)
- должны освобождаться все занятые им ресурсы
Важно понимать: браузер сам умеет освобождать память через сборщик мусора, но только если до объекта нет «живых» ссылок. Неправильно реализованный destroyed может оставлять скрытые ссылки (через подписки, глобальные объекты, таймеры), что приводит к утечкам.
Типичные причины уничтожения компонента
Компонент уничтожается не только при закрытии вкладки браузера. Это может быть:
- переход на другой маршрут (страницу)
- скрытие компонента по условию (например, через v-if / *ngIf)
- удаление элемента из списка (например, удаление карточки товара)
- смена родительского компонента или перестроение дерева
Каждый раз, когда компонент перестает быть частью актуального состояния интерфейса, фреймворк должен его уничтожить. На этом этапе и вызывается логика destroyed или ее аналоги.
Что должно происходить при уничтожении компонента
Общий список задач для destroyed
Смотрите, я перечислю типичные задачи, которые нужно выполнить в destroyed:
Отписаться от событий:
- DOM-события (addEventListener → removeEventListener)
- события через EventEmitter / шину событий
- кастомные глобальные слушатели (window, document, WebSocket и т. д.)
Очистить таймеры и интервалы:
- clearTimeout
- clearInterval
- отмена requestAnimationFrame
- отмена любых «отложенных» операций
Отменить подписки на потоки данных:
- RxJS подписки
- WebSocket-соединения (или хотя бы слушателей)
- подписки на стор (Redux, Vuex, Pinia и т. д.), если они могут удерживать компонент
Отключить наблюдателей:
- MutationObserver
- ResizeObserver
- IntersectionObserver
Освободить внешние ресурсы:
- уничтожить экземпляры карт (например, карты Google или Leaflet)
- закрыть соединения с IndexedDB, если они создавались на компоненте
- остановить медиапотоки (getUserMedia и т. п.)
Очистить все, что компонент сам «навешивал» на глобальное состояние:
- классы на body
- блокировки прокрутки
- глобальные стили, добавленные динамически
Когда вы проектируете компонент, удобный принцип — все, что вы создаете и «подвешиваете» в mounted/created/onInit, должно быть симметрично убрано в destroyed/OnDestroy/unmount.
Примеры на основе реальных сценариев
Пример с DOM-событиями
Представьте компонент, который слушает прокрутку окна:
export default {
name: 'ScrollWatcher',
data() {
return {
scrollY: 0, // Текущее положение скролла
}
},
methods: {
handleScroll() {
// Обновляем положение скролла при каждом событии
this.scrollY = window.scrollY
},
},
mounted() {
// Здесь мы вешаем обработчик скролла
window.addEventListener('scroll', this.handleScroll)
},
beforeDestroy() {
// Здесь мы снимаем обработчик скролла
window.removeEventListener('scroll', this.handleScroll)
},
}
В этом примере:
- в mounted компонент подписывается на событие scroll
- в beforeDestroy (или destroyed, в зависимости от версии фреймворка) мы обязаны снять обработчик
Если этого не сделать, window будет продолжать вызывать handleScroll, а замыкание будет удерживать ссылку на экземпляр компонента. Это и есть классический пример утечки памяти.
Пример с таймерами
Давайте разберемся на примере компонента, который периодически обновляет данные:
export default {
name: 'AutoRefresher',
data() {
return {
timerId: null, // Сюда мы сохраним идентификатор таймера
value: null, // Здесь будем хранить последние данные
}
},
methods: {
fetchData() {
// Здесь можно сделать запрос к серверу
// Для примера просто увеличим значение
this.value = (this.value || 0) + 1
},
},
mounted() {
// Запускаем интервал и сохраняем его идентификатор
this.timerId = setInterval(() => {
this.fetchData()
}, 5000)
},
beforeDestroy() {
// Здесь мы обязательно очищаем интервал
if (this.timerId !== null) {
clearInterval(this.timerId)
this.timerId = null
}
},
}
Если не вызвать clearInterval, функция будет продолжать выполняться, даже когда компонент уже не нужен пользователю. Более того, ссылка на компонент через замыкание сохранится и опять же не даст сборщику мусора освободить память.
Пример с подпиской на поток (RxJS / WebSocket)
Предположим, у вас есть сервис, который возвращает поток данных. Покажу вам простой пример, похожий на использование RxJS:
// Здесь мы описываем условный сервис, который отдает поток
class DataService {
subscribe(callback: (value: number) => void) {
const intervalId = setInterval(() => {
// Вызываем колбэк раз в секунду
callback(Math.random())
}, 1000)
return {
unsubscribe() {
// При отписке очищаем интервал
clearInterval(intervalId)
},
}
}
}
// Создаем экземпляр сервиса
const dataService = new DataService()
// Это наш компонент
class StreamComponent {
private subscription: { unsubscribe: () => void } | null = null
public lastValue: number | null = null
mount() {
// Подписываемся на поток данных
this.subscription = dataService.subscribe((value) => {
// Обновляем состояние при каждом новом значении
this.lastValue = value
})
}
destroy() {
// При уничтожении компонента отписываемся
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
}
}
Здесь ключевой момент — наличие метода destroy, который симметричен mount. Все, что вы подписали в mount, должно быть отписано в destroy.
Подходы к destroyed в разных фреймворках
Vue.js: destroyed и beforeDestroy
В Vue 2 жизненный цикл компонента включает хуки:
- beforeDestroy — вызывается перед началом процесса уничтожения
- destroyed — вызывается после того, как:
- все реактивные связи разорваны
- все дочерние компоненты уничтожены
- все слушатели событий компонента сняты (те, что были навешаны самим Vue)
Стандартный шаблон выглядит так:
export default {
name: 'ExampleComponent',
mounted() {
// Здесь мы подписываемся, создаем таймеры и т. д.
},
beforeDestroy() {
// Здесь мы вручную чистим все, что сами создали
// Например, снимаем слушатели, сбрасываем таймеры
},
destroyed() {
// Здесь компонент уже полностью уничтожен
// Обычно сюда редко что-то добавляют
},
}
Обратите внимание:
- Vue автоматически чистит свои собственные слушатели (те, которые вы определили через v-on в шаблоне)
- но не трогает то, что вы навесили на window, document, внешние библиотеки или сторы
Поэтому, если вы где-то в коде делали addEventListener вручную или создавали сторонний объект (например, карту), вам нужно убрать это вручную именно в beforeDestroy/destroyed.
В Vue 3 хуки переименованы (beforeUnmount / unmounted), но идея та же. То, что вы делаете в onMounted, должно быть очищено в onBeforeUnmount или onUnmounted.
Angular: интерфейс OnDestroy
В Angular есть явный интерфейс OnDestroy с методом ngOnDestroy. Давайте посмотрим пример:
import { Component, OnDestroy, OnInit } from '@angular/core'
import { Subscription } from 'rxjs'
import { DataService } from './data.service'
@Component({
selector: 'app-example',
template: `
<div>Последнее значение - {{ value }}</div>
`,
})
export class ExampleComponent implements OnInit, OnDestroy {
value: number | null = null
private subscription: Subscription | null = null
constructor(private dataService: DataService) {}
ngOnInit() {
// Подписываемся на поток данных из сервиса
this.subscription = this.dataService.getStream().subscribe((v) => {
this.value = v
})
}
ngOnDestroy() {
// Отписываемся при уничтожении компонента
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
}
}
Здесь pattern тот же:
- все подписки, которые вы делаете в ngOnInit, должны быть отписаны в ngOnDestroy
- аналогично — любые setInterval, addEventListener и другие ресурсы
Angular сам уничтожает шаблон, дочерние компоненты и внутренние структуры, но не знает о «внешних» ресурсах, созданных вами.
React-подход: очистка в useEffect
В React нет хука с названием destroyed, но идея та же. Очистка выполняется в функции, которую вы возвращаете из useEffect. Теперь вы увидите, как это выглядит в коде:
import { useEffect, useState } from 'react'
function ScrollWatcher() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
// Вешаем обработчик скролла при монтировании
const handleScroll = () => {
setScrollY(window.scrollY)
}
window.addEventListener('scroll', handleScroll)
// Возвращаем функцию очистки
return () => {
// Здесь мы снимаем обработчик при размонтировании компонента
window.removeEventListener('scroll', handleScroll)
}
}, [])
return <div>Позиция скролла - {scrollY}</div>
}
Функция, возвращаемая из useEffect, по сути и есть аналог destroyed:
- вызывается при размонтировании компонента
- вызывается при пересоздании эффекта (если зависимости изменились)
Принцип не меняется: все, что вы подписали, нужно отписать в функции очистки.
Паттерны и практики для безопасного destroyed
Правило симметрии: что создал — то и уничтожь
Удобно придерживаться простого правила: для каждой операции в mounted/onInit/useEffect должно быть зеркальное действие в destroyed/onDestroy/cleanup.
Например:
- addEventListener → removeEventListener
- setInterval → clearInterval
- subscribe → unsubscribe
- observe → disconnect или unobserve
- open → close
Если вы добавляете новый кусок кода в mounted и при этом не можете сразу ответить, что будет в destroyed, — это сигнал, что очищение не продумано.
Централизация очистки
Иногда компонент делает много действий в разных методах (подписки, таймеры, наблюдатели). В таких случаях удобно:
- хранить все ресурсы в одной структуре (например, массив подписок или объект с ссылками)
- реализовать один метод cleanup, который вызывается в destroyed
Давайте посмотрим, что происходит в следующем примере:
export default {
name: 'ComplexComponent',
data() {
return {
timerId: null, // Идентификатор таймера
scrollHandler: null, // Ссылка на обработчик скролла
subscriptions: [], // Массив подписок на внешние потоки
}
},
methods: {
setupTimer() {
// Создаем интервал и сохраняем его
this.timerId = setInterval(() => {
// Здесь можно делать периодическую работу
}, 1000)
},
setupScroll() {
// Сохраняем обработчик, чтобы потом снять
this.scrollHandler = () => {
// Реакция на скролл
}
window.addEventListener('scroll', this.scrollHandler)
},
setupSubscriptions() {
// Подписываемся на условный поток данных
const sub = someStream.subscribe((value) => {
// Обрабатываем данные
})
// Сохраняем подписку, чтобы потом отписаться
this.subscriptions.push(sub)
},
cleanup() {
// Чистим таймер
if (this.timerId !== null) {
clearInterval(this.timerId)
this.timerId = null
}
// Снимаем обработчик скролла
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler)
this.scrollHandler = null
}
// Отписываемся от всех потоков
this.subscriptions.forEach((sub) => sub.unsubscribe())
this.subscriptions = []
},
},
mounted() {
// Настраиваем все ресурсы при монтировании
this.setupTimer()
this.setupScroll()
this.setupSubscriptions()
},
beforeDestroy() {
// Вызываем единый метод очистки
this.cleanup()
},
}
Такой подход облегчает поддержку:
- при добавлении нового ресурса вы просто дописываете его регистрацию и очистку в одном месте
- в хуке уничтожения вызываете только один метод
Защита от многократной очистки
Иногда destroyed может быть вызван повторно (например, при ошибках или особенностях среды). Поэтому безопасно делать методы очистки идемпотентными — то есть вызываемыми без побочных эффектов несколько раз.
В примерах выше:
- мы проверяем, что timerId не null перед clearInterval
- обнуляем ссылки после очистки (timerId = null, scrollHandler = null)
- очищаем массив subscriptions и можем спокойно вызвать cleanup еще раз — он просто не найдет, что очищать
Такая защита снижает вероятность странных ошибок, когда один и тот же ресурс пытаются освободить дважды.
Освобождение ссылок в замыканиях
Частая скрытая проблема — замыкания, которые держат ссылки на компонент. Например, в обработчиках setTimeout, промисах или колбэках событий вы можете использовать this или переменные из области компонента.
Чтобы помочь сборщику мусора:
- по возможности обнуляйте ссылки на крупные объекты в destroyed
- не храните в глобальных структурах (например, в window или в статических массивах) ссылки на экземпляры компонентов
- не передавайте this в долго живущие структуры без необходимости
Например, если вы добавили текущий компонент в некий глобальный список для отладки, обязательно удалите его оттуда в destroyed.
Типичные ошибки при реализации destroyed
Забыли отписаться от подписки
Сценарий: вы подписались на поток данных (например, новости, уведомления), перешли на другую страницу, а поток продолжает «стрелять» и вызывать код компонента, которого уже «как бы» нет.
Последствия:
- утечки памяти
- ошибки, когда код пытается изменить уже уничтоженный компонент
- лишняя нагрузка на сеть и CPU
Как решить:
- всегда хранить подписку в поле компонента
- всегда отписываться в destroyed/onDestroy
- использовать вспомогательные утилиты (например, takeUntil в RxJS)
Навешивание DOM-событий в цикле без снятия
Иногда разработчики привязывают события внутри циклов, не отслеживая созданные обработчики. При уничтожении компонента снять их уже сложно, потому что нет ссылок на функции.
Рекомендация:
- всегда сохраняйте ссылку на обработчик, если его нужно снимать
- избегайте анонимных функций, когда вы планируете удалять слушатель
Пример плохого подхода:
mounted() {
// Плохо - будет сложно снять
window.addEventListener('resize', () => {
// Обработчик ресайза
})
}
Здесь вы не сможете удалить именно этот обработчик, потому что у вас нет ссылки на переданную функцию.
Лучше так:
data() {
return {
resizeHandler: null, // Здесь будет ссылка на обработчик
}
},
mounted() {
// Сохраняем обработчик, чтобы потом использовать ту же самую функцию
this.resizeHandler = () => {
// Обработка изменения размера
}
window.addEventListener('resize', this.resizeHandler)
},
beforeDestroy() {
// Снимаем обработчик
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler)
this.resizeHandler = null
}
},
Надежда только на фреймворк
Фреймворк действительно многое делает за вас: отписывает «свои» слушатели, удаляет DOM, разрушает дерево компонентов. Но он не знает о:
- дополнительных слушателях на window/document
- внешних библиотеках (картографические движки, графики)
- сторонних потоках данных
Если просто положиться на фреймворк, почти гарантированно появятся утечки в сложном приложении.
Лучший подход — четко разделять:
- ресурсы, за которые отвечает сам фреймворк
- ресурсы, за которые отвечаете вы, через destroyed/onDestroy/unmount
Проверка корректности destroyed на практике
Простая проверка на утечки
Покажу вам, как можно проверить свою реализацию destroyed:
- Откройте DevTools в браузере
- Перейдите на вкладку Performance или Memory (зависит от браузера)
- Несколько раз:
- откройте страницу с компонентом
- выполните действия, которые используют его ресурсы (скролл, ввод, запросы)
- перейдите на другую страницу, чтобы компонент уничтожился
- Сделайте снимок памяти (heap snapshot) и посмотрите, остаются ли объекты вашего компонента в памяти после нескольких циклов
Если количество экземпляров компонента растет после каждого цикла открытия/закрытия — вероятно, где-то забыта очистка.
Логирование в destroyed
На первых этапах удобно добавлять логи в destroyed/onDestroy:
beforeDestroy() {
console.log('Component ExampleComponent is being destroyed')
// Здесь же вызываем очистку ресурсов
this.cleanup()
}
Так вы увидите в консоли, вызывается ли вообще хук уничтожения в тех случаях, когда вы этого ожидаете.
Если destroyed не вызывается, возможно:
- компонент не удаляется, а только скрывается (например, через CSS или v-show)
- вы используете кэширование компонентов (keep-alive), и в этом случае логика другая
- компонент обернут в особую конструкцию, меняющую поведение жизненного цикла
Заключение
Этап destroyed (или его аналоги в разных фреймворках) — это не просто техническая деталь, а ключевой механизм для контроля ресурсов и стабильности приложения. Правильно реализованная очистка:
- предотвращает утечки памяти
- убирает «фантомное» выполнение кода уже несуществующих компонентов
- снижает нагрузку на сеть и процессор
- делает поведение интерфейса предсказуемым при навигации и долгой работе
Основная идея, которую важно усвоить: все, что вы создаете и «подписываете» при инициализации компонента, должно быть симметрично снято и очищено при его уничтожении. Используйте правило симметрии, централизованную очистку и идемпотентные методы cleanup — и вам будет намного проще сопровождать даже сложные компоненты.
Частозадаваемые технические вопросы и ответы
Как правильно работать с уничтожением компонента, если используется кэширование (например, keep-alive в Vue)
При кэшировании компонент не уничтожается, а только «отключается». В Vue для этого есть хуки deactivated и activated. Логику, связанную с подписками и таймерами, которые не должны работать в фоновом режиме, выносите в deactivated (останавливать) и activated (возобновлять). В destroyed вы оставляете только финальную очистку, которая нужна при полном удалении компонента из кэша.
Что делать, если подписок очень много и сложно не забыть их очистить
Используйте обертку или утилиту. Например, создайте класс SubscriptionManager, у которого есть методы add и destroyAll. В компоненте вместо ручного хранения подписок добавляйте их через manager.add, а в destroyed вызывайте manager.destroyAll. Так вы снижаете риск забыть отписаться.
Как обрабатывать промисы, которые могут завершиться уже после уничтожения компонента
Добавляйте флаг isDestroyed или используйте токен отмены. В destroyed устанавливайте флаг, а в then или обработчиках промиса проверяйте его и не изменяйте состояние, если компонент уже уничтожен. В некоторых библиотеках есть встроенные механизмы отмены запросов (AbortController для fetch), используйте их и вызывайте abort в destroyed.
Нужно ли явно обнулять все поля компонента в destroyed
Как правило, это не обязательно для корректного освобождения памяти, если нет внешних ссылок. Но обнуление крупных или чувствительных данных (например, больших массивов, бинарных буферов) может помочь сборщику мусора быстрее освободить память. Обнуляйте только то, что действительно может дать выигрыш и не запутывает логику.
Как поступать с глобальными объектами, созданными в компоненте (например, графики или карты)
Если библиотека предоставляет метод destroy или dispose, обязательно вызывайте его в destroyed. Если нет — смотрите документацию и ищите рекомендации по очистке. В крайнем случае удаляйте DOM-элементы, к которым привязан объект, и убирайте все ссылки на него из компонента и глобальных переменных, чтобы сборщик мусора смог его освободить.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев