Тихий вбивця: витоки пам’яті в React і як їх зупинити

pic

Фото: Chris Yang на Unsplash

Привіт, всім! Сьогодні я розповім про важливий аспект підтримки продуктивності та надійності в React додатках — витоки пам'яті.

Витоки пам'яті виникають, коли додаток утримує пам'ять, яка більше не потрібна. З часом це призводить до збільшення використання пам'яті, сповільнення продуктивності і навіть аварійних завершень роботи.

У цій статті ми розглянемо основні причини виникнення витоків пам'яті в React додатках, як їх ідентифікувати і, що найголовніше, як їх запобігти та виправити. Давайте почнемо!

Причини витоків пам'яті

Щоб впоратись із витоками пам'яті, спершу потрібно зрозуміти, звідки вони беруться в React додатках. Ми розглянемо симптоми та рішення кожної проблеми.

Прослуховувачі подій (Event Listeners)

Проблема:

Розпочнемо з прослуховувачів подій (Event Listeners). Їх часто додають до елементів у компонентах React за допомогою таких методів, як addEventListener.

Проблема виникає, коли ми забуваємо видалити ці слухачі при демонтажі компонента. Прослуховувач подій продовжує посилатися на компонент, що перешкоджає його збору сміття.

Наприклад, якщо ми прикріплюємо кілька обробників подій до елемента, але ніколи їх не видаляємо, то в результаті ми отримаємо багато зайвих обробників.

pic

Невиконані прослуховувачі подій

Рішення:

Ми можемо очистити прослуховувачі подій за допомогою хука useEffect у функціональних компонентах. Ось як це виглядатиме:

  • Додати прослуховувач при монтуванні компонента.
  • Повернути функцію очищення для видалення прослуховувача при демонтажі компонента.
useEffect(() => {  
 const handleResize = () => {  
 console.log('Window resized');  
 };  

 // Додаємо прослуховувач подій  
 window.addEventListener('resize', handleResize);  

 // Функція очищення для видалення прослуховувача  
 return () => {  
 window.removeEventListener('resize', handleResize);  
 };  
}, []);

API з підпискою (Subscription-Based APIs)

Проблема:

Інша велика причина витоків пам'яті — це API з підпискою (Subscription-Based APIs), такі як WebSockets, спостережувані об'єкти (observables) або потоки даних.

Уявіть чат-додаток, що використовує WebSocket для отримання повідомлень. Якщо WebSocket залишається активним після того, як користувач покидає чат, це може утримувати посилання на демонстрований компонент, що призводить до витоків.

Рішення:

Щоб цього уникнути, завжди відписуйтесь або закривайте ці з'єднання в функції очищення useEffect або методі життєвого циклу componentWillUnmount у класових компонентах.

Функціональні компоненти

useEffect(() => {  
 // Відкриваємо з'єднання WebSocket  
 const socket = new WebSocket('wss://example.com/socket');  

 // Обробка вхідних повідомлень  
 socket.onmessage = (event) => {  
 console.log('Message from server:', event.data);  
 };  

 // Функція очищення для закриття з'єднання WebSocket  
 return () => {  
 console.log('Closing WebSocket connection...');  
 if (socket.readyState === WebSocket.OPEN) {  
 socket.close();  
 }  
 };  
}, []);

Класові компоненти

componentDidMount() {  
 // Відкриваємо з'єднання WebSocket  
 this.socket = new WebSocket('wss://example.com/socket');  

 // Обробка вхідних повідомлень  
 this.socket.onmessage = (event) => {  
 console.log('Message from server:', event.data);  
 };  
}  

componentWillUnmount() {  
 // Закриваємо з'єднання WebSocket  
 console.log('Closing WebSocket connection...');  
 if (this.socket && this.socket.readyState === WebSocket.OPEN) {  
 this.socket.close();  
 }  
}

API виклики (API Calls)

Проблема:

Далі поговоримо про API виклики.

Коли запит до API ініціюється, компонент може демонтуватися до того, як запит завершиться.
Якщо стан оновлюється після того, як компонент вже демонтувався, React попередить вас таким повідомленням:
‘Не можна виконати оновлення стану React на демонтажованому компоненті.’

Це не просто попередження — воно вказує на те, що незавершені API запити можуть утримувати посилання на демонтажовані компоненти, що призводить до витоків.

Рішення:

Найкращий спосіб вирішити це — використовувати AbortController для скасування поточних запитів, коли компонент демонтується. Це гарантує правильне завершення операції fetch.

useEffect(() => {  
 const controller = new AbortController();  
 const signal = controller.signal;  

 const fetchData = async () => {  
 try {  
 const response = await fetch('https://api.example.com/data', { signal });  
 if (!response.ok) {  
 throw new Error('Network response was not ok');  
 }  
 const result = await response.json();  
 setData(result);  
 } catch (err) {  
 if (err.name === 'AbortError') {  
 console.log('Fetch aborted');  
 } else {  
 setError(err.message);  
 }  
 }  
 };  

 fetchData();  

 return () => {  
 controller.abort(); // Скасувати запит, якщо компонент демонтується  
 };  
}, []);

Таймери та Інтервали (Timers and Intervals)

Проблема:

Таймери та інтервали, такі як setTimeout і setInterval, є ще однією поширеною причиною витоків пам'яті.

Таймери можуть призводити до витоків пам'яті, якщо вони продовжують працювати після того, як компонент демонтується. Це трапляється тому, що таймер утримує посилання на компонент, що перешкоджає його збору сміття. React не очищає ці таймери автоматично при демонтажі компонента, тому це завдання розробника — керувати ними.

Рішення:

Кожного разу, коли ви використовуєте таймер, очищайте його в функції очищення useEffect або в методі життєвого циклу componentWillUnmount. Це можна зробити за допомогою clearTimeout або clearInterval.

useEffect(() => {  
 const timer = setTimeout(() => {  
 setMessage('Timer finished!');  
 }, 3000); // 3 секунди  

 return () => {  
 clearTimeout(timer); // Очищаємо таймер при демонтажі  
 };  
}, []);

Посилання на демонтажовані компоненти (References to Unmounted Components)

Проблема:

Іноді посилання на демонтажовані компоненти залишаються через замикання (closures), асинхронні задачі або DOM елементи.

Наприклад, асинхронна операція може завершитись після того, як компонент вже демонтується, але якщо вона спробує оновити стан, це спричинить проблеми.

pic

Оновлення стану React

Рішення:

Використовуйте функцію очищення useEffect, щоб уникнути оновлення стану після демонтажу.

useEffect(() => {  
 let isMounted = true; // Відстежуємо, чи компонент змонтовано  

 const fetchData = async () => {  
 try {  
 const response = await fetch('https://api.example.com/data');  
 const result = await response.json();  
 if (isMounted) {  
 setData(result); // Оновлюємо стан тільки якщо компонент змонтовано  
 setLoading(false);  
 }  
 } catch (error) {  
 if (isMounted) {  
 console.error(error);  
 setLoading(false);  
 }  
 }  
 };  

 fetchData();  

 return () => {  
 isMounted = false; // Очищення: позначаємо як демонтажований  
 };  
}, []);

Невірні масиви залежностей у хуках (Improper Dependency Arrays in Hooks)

Проблема:

Інша ситуація пов'язана з невірними масивами залежностей у хуках.
Відсутні залежності можуть спричинити непередбачувану поведінку хуків, таких як useEffect або useCallback, утримуючи застарілі посилання.

useEffect(() => {  
 console.log(`Count: ${count}`); // Використовує count, але не оголошує його як залежність  
}, []); // Відсутній `count`

Рішення:

Зберігайте точність масивів залежностей.

useEffect(() => {  
 console.log(`Count: ${count}`);  
}, [count]); // Оголошує `count` як залежність

Великі дані в стані без очищення (Large Data in States Without Cleanup)

Проблема:

Нарешті, зберігання великих даних, таких як зображення або файли, в стані компонента без їх очищення також може призвести до високого використання пам'яті.

Рішення:

Уникайте зберігання великих даних у стані — розгляньте можливість використання refs або контексту замість цього.

Ідентифікація причин витоків пам'яті (Identify Causes of Leaks)

Ідентифікація витоків пам'яті вимагає використання правильних інструментів. Одним із найефективніших інструментів є React Developer Tools.

React Developer Tools — це розширення для Chrome DevTools для відкритої бібліотеки React JavaScript. Воно дозволяє вам перевіряти ієрархії компонентів React у Chrome Developer Tools.

Це розширення для браузера інтегрується з DevTools і додає дві нові вкладки:

  • Компоненти (Components)
  • Профайлер (Profiler)

Вкладка Components

Вкладка Компоненти (Components) показує кореневі компоненти React, які були змонтовані на сторінці, а також підкомпоненти, які вони відобразили.

pic

Вкладка Компоненти в React DevTools

Вкладка Profiler

Вкладка Профайлер (Profiler) дозволяє вам відслідковувати рендери компонентів і виявляти непотрібні повторні рендери або компоненти, які залишаються в пам'яті.

pic

Вкладка Профайлер в React DevTools

  1. Почніть профілювання
    Перейдіть на вкладку Профайлер у React Developer Tools. Натисніть кнопку "Record" (значок кола), щоб почати запис активності вашого додатка.
  2. Взаємодійте з вашим додатком
    Виконуйте дії в додатку, які монтують, оновлюють або демонтують компоненти (наприклад, переміщення між сторінками або перемикання елементів UI).
  3. Зупиніть профілювання
    Натисніть кнопку "Stop" (значок квадрата), щоб зупинити запис.

Аналізуйте результати Профайлера (Analyze Profiler Output)

pic

Аналіз вкладки Профайлера в React DevTools

  1. Виявлення непотрібних рендерів
    У ранжованій діаграмі:
  2. Чорні/червоні області вказують на дорогі рендери.
  3. Шукайте компоненти, які рендеряться часто без змін у їхніх props/state.
  4. Виявлення компонентів, які залишаються після демонтажу
    Якщо компонент був демонтажований, але все ще з'являється у наступних знімках, це може вказувати на витік пам'яті.

Висновок (Conclusion)

Дякую за увагу! Сьогодні ми розглянули причини витоків пам'яті в React, як їх виявляти за допомогою інструментів, таких як Профайлер React, і як впроваджувати ефективні рішення.

Перекладено з: The Silent Killer: React Memory Leaks and How to Stop Them

Leave a Reply

Your email address will not be published. Required fields are marked *