Если вы разрабатываете приложение на Vue.js, то вам постоянно необходимо контролировать утечку памяти. Особенно это касается SPA-приложений с динамической подгрузкой компонентов. По архитектурному замыслу пользователи не должны обновлять браузер, соответственно очистка компонентов и сборка мусора должны выполняться самим приложением.
Сегодня разберем реальный кейс из практики e-commerce, где безобидный чекбокс "Показывать акции" стал причиной лавинообразной нагрузки на сервер.
Один мой старый знакомый обратился ко мне с одной очень интересной проблемой. У него есть довольно старый интернет-магазин, сделанный в связке Vue.js + Symfony и некоторые пользователи жаловались на некорректную работу сайта. Периодически в процессе работы интернет-магазин переставал показывать цены и реагировать на запросы пользователей. Проблему усложнял тот факт, что нельзя было воспроизвести данный баг, так как он проявлялся не у всех и только периодически.
Тем не менее, в процессе анализа поведения пользователей, а также личной беседы с ними, мне удалось получить ценную информацию, что помогло выявить проблемный компонент и исправить ошибку. В частности, один из пользователей заявил, что он переходил по карточкам товара, нажимал на чекбокс отслеживания скидок, потом отключал его. И мне тогда всё стало понятно.
Суть проблемы: зомби-таймеры
Сценарий использования:
Пользователь страницы товара включает опцию "Отслеживать реальные скидки":
- Фронтенд начинает опрашивать /api/live-discounts каждые 30 секунд
- При снятии галочки компонент скрывается, но...
- Таймер продолжает работу!
Вот сам компонент, PriceTracker.component.vue (родительский компонент), в котором была обнаружена ошибка:
<template>
<div class="product-page">
<h2>{{ product.name }}</h2>
<p>Base Price: {{ formatCurrency(product.basePrice) }}</p>
<label class="real-time-toggle">
<input type="checkbox" v-model="enableRealTimeUpdates" />
Track real-time discounts
</label>
<RealTimeDiscounts
v-if="enableRealTimeUpdates"
:product-id="product.id"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import RealTimeDiscounts from './RealTimeDiscounts.vue';
import { useProductStore } from '@/stores/product';
import { formatCurrency } from '@/utils/currency';
const productStore = useProductStore();
const product = productStore.currentProduct;
const enableRealTimeUpdates = ref(false);
</script>
Здесь с виду ничего необычного. У нас есть чекбокс, который при активации загружает уже дочерний компонент:
RealTimeDiscounts.component.vue:
<template>
<div class="discount-widget">
<div v-if="loading" class="loading">Updating prices...</div>
<div v-else class="discounts">
<p class="discount">Current Discount: {{ currentDiscount }}%</p>
<p class="final-price">Final Price: {{ formatCurrency(finalPrice) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useDiscountApi } from '@/api/discount';
import { formatCurrency } from '@/utils/currency';
const props = defineProps<{
productId: string;
}>();
const { fetchLiveDiscount } = useDiscountApi();
const currentDiscount = ref(0);
const loading = ref(false);
let updateInterval: number | undefined;
const startPriceUpdates = () => {
updateInterval = window.setInterval(async () => {
loading.value = true;
try {
const discount = await fetchLiveDiscount(props.productId);
currentDiscount.value = Math.max(
currentDiscount.value,
discount.value
);
} catch (error) {
console.error('Discount update failed:', error);
} finally {
loading.value = false;
}
}, 30000); // Каждые 30 секунд
};
onMounted(startPriceUpdates);
const finalPrice = computed(() => {
return product.basePrice * (1 - currentDiscount.value / 100);
});
</script>
В данном примере мы загружаем компонент, который осуществляет запрос по API, а затем используем кнопку show/hide с директивой v-if для добавления и удаления ее из виртуального DOM. Проблема в этом примере заключается в том, что директива v-if удаляет родительский элемент из DOM, но мы не очистили дополнительные фрагменты DOM, созданные дочерним компонентом, что привело к утечке памяти.
Что у нас имеется в итоге:
- При переключении чекбокса создаются новые интервалы, которые постепенно накапливаются в памяти
- Старые интервалы продолжают слать запросы к `/api/live-discounts`
- Медленные ответы могут приходить в неправильном порядке, создавая race condition
- При постоянном переключении чекбокса - лавинообразный рост запросов к бэкенду
Как это всё выглядело с точки зрения пользователя:
- Пользователь включает "Track real-time discounts"
- Компонент стартует интервал №1
- Через 25 сек пользователь выключает чекбокс
- Интервал №1 продолжает работать
- Через 30 сек интервал №1 делает запрос
- Пользователь снова включает чекбокс
- Стартует интервал №2
- Теперь два параллельных интервала шлют запросы
- В конечном итоге при постоянных запросах к серверу, пользователь периодически получает ошибку от сервера, в результате чего, некоторые компоненты перестают получать нужную информацию для отображения.
- Сайт постепенно начинает выдавать всё больше и больше ошибок, пока пользователь полностью не перезагрузит страницу.
Самый простой вариант того, как можно исправить подобный баг:
// RealTimeDiscounts.component.vue
import { onUnmounted } from 'vue';
const abortController = new AbortController();
const startPriceUpdates = () => {
updateInterval = window.setInterval(async () => {
if (abortController.signal.aborted) return;
try {
const discount = await fetchLiveDiscount(
props.productId,
{ signal: abortController.signal }
);
currentDiscount.value = discount.value;
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Discount update failed:', error);
}
}
}, 30000);
};
onUnmounted(() => {
clearInterval(updateInterval);
abortController.abort();
});
import { debounce } from 'lodash-es';
const updateDiscount = debounce((newValue: number) => {
currentDiscount.value = newValue;
}, 1000);
Дополнительно, нам потребуется провести рефакторинг, чтобы не только исправить баг, но и повысить производительность в целом.
1. Экспоненциальный бекофф при ошибках:
let retryCount = 0;
const baseDelay = 30000;
const startPriceUpdates = () => {
updateInterval = window.setInterval(async () => {
try {
await fetchDiscount();
retryCount = 0;
} catch (error) {
retryCount++;
const delay = baseDelay * Math.pow(2, retryCount);
setTimeout(startPriceUpdates, delay);
return;
}
}, baseDelay);
};
2. Почему бы не вести статистику запросов:
const metrics = {
requests: 0,
errors: 0,
lastUpdate: null as Date | null
};
setInterval(() => {
if (metrics.requests > 0) {
sendMetricsToServer(metrics);
}
}, 300000);
3. Также нам потребуется провести рефакторинг серверного API:
- Реализовать кэширование результатов
- Использовать WebSockets вместо polling
- Добавить rate-limiting для `/api/live-discounts`
- Ввести версионирование цен с помощью ETag-валидации
Как можно обнаружить такой баг:
- Производить мониторинг количества активных соединений
- Анализировать логи сервера, фиксировать повторяющиеся запросы от одного клиента
- Производить нагрузочное тестирование компонента
- Чтобы выйти на след самой проблемы, пользоваться инструментами разработчика:
- Вкладка Network - там можно быстро обнаружить дублирующиеся запросы
- Performance monitor - для контроля роста используемой памяти
- Vue DevTools - выявлять множественные экземпляры компонента
Будьте внимательны к утечкам памяти на стороне клиента. Подобный баг может привести к:
- Увеличению стоимости облачной инфраструктуры на 20-40%
- Деградации скорости ответа API на 300-500 мс
- Ложным срабатываниям систем мониторинга
- Неправильному отображению цен при конкуренции запросов
Вывод
Баг с "забытыми" таймерами — классический пример проблемы, которая:
- Легко создать при быстрой разработке
- Трудно обнаружить без должного мониторинга
- Дорого обходится при масштабировании
Главное правило: Любой ресурс, созданный в onMounted, должен иметь явный обработчик очистки в onUnmounted.