Утечка памяти в Vue.js: история коварного бага

Утечка памяти в Vue.js: история коварного бага

Если вы разрабатываете приложение на Vue.js, то вам постоянно необходимо контролировать утечку памяти. Особенно это касается SPA-приложений с динамической подгрузкой компонентов. По архитектурному замыслу пользователи не должны обновлять браузер, соответственно очистка компонентов и сборка мусора должны выполняться самим приложением.

Сегодня разберем реальный кейс из практики e-commerce, где безобидный чекбокс "Показывать акции" стал причиной лавинообразной нагрузки на сервер.

Один мой старый знакомый обратился ко мне с одной очень интересной проблемой. У него есть довольно старый интернет-магазин, сделанный в связке Vue.js + Symfony и некоторые пользователи жаловались на некорректную работу сайта. Периодически в процессе работы интернет-магазин переставал показывать цены и реагировать на запросы пользователей. Проблему усложнял тот факт, что нельзя было воспроизвести данный баг, так как он проявлялся не у всех и только периодически.

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

Суть проблемы: зомби-таймеры

Сценарий использования:  

Пользователь страницы товара включает опцию "Отслеживать реальные скидки":

  1. Фронтенд начинает опрашивать /api/live-discounts каждые 30 секунд
  2. При снятии галочки компонент скрывается, но...
  3. Таймер продолжает работу!

Вот сам компонент, 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, созданные дочерним компонентом, что привело к утечке памяти.

 

Что у нас имеется в итоге:

  1. При переключении чекбокса создаются новые интервалы, которые постепенно накапливаются в памяти
  2. Старые интервалы продолжают слать запросы к `/api/live-discounts`
  3. Медленные ответы могут приходить в неправильном порядке, создавая race condition
  4. При постоянном переключении чекбокса - лавинообразный рост запросов к бэкенду

Как это всё выглядело с точки зрения пользователя:

  1. Пользователь включает "Track real-time discounts"
  2. Компонент стартует интервал №1
  3. Через 25 сек пользователь выключает чекбокс
  4. Интервал №1 продолжает работать
  5. Через 30 сек интервал №1 делает запрос
  6. Пользователь снова включает чекбокс
  7. Стартует интервал №2
  8. Теперь два параллельных интервала шлют запросы
  9. В конечном итоге при постоянных запросах к серверу, пользователь периодически получает ошибку от сервера, в результате чего, некоторые компоненты перестают получать нужную информацию для отображения.
  10. Сайт постепенно начинает выдавать всё больше и больше ошибок, пока пользователь полностью не перезагрузит страницу.

Самый простой вариант того, как можно исправить подобный баг:

// 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-валидации

Как можно обнаружить такой баг:

  1. Производить мониторинг количества активных соединений
  2. Анализировать логи сервера, фиксировать повторяющиеся запросы от одного клиента
  3. Производить нагрузочное тестирование компонента
  4. Чтобы выйти на след самой проблемы, пользоваться инструментами разработчика:
  • Вкладка Network - там можно быстро обнаружить дублирующиеся запросы
  • Performance monitor - для контроля роста используемой памяти
  • Vue DevTools - выявлять множественные экземпляры компонента

Будьте внимательны к утечкам памяти на стороне клиента. Подобный баг может привести к:

  • Увеличению стоимости облачной инфраструктуры на 20-40%
  • Деградации скорости ответа API на 300-500 мс
  • Ложным срабатываниям систем мониторинга
  • Неправильному отображению цен при конкуренции запросов

Вывод

Баг с "забытыми" таймерами — классический пример проблемы, которая:  

  • Легко создать при быстрой разработке 
  • Трудно обнаружить без должного мониторинга 
  • Дорого обходится при масштабировании

Главное правило: Любой ресурс, созданный в onMounted, должен иметь явный обработчик очистки в onUnmounted.

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js, Wordpress. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235
Email