Головна / Статті / Пошук витоків пам’яті в JavaScript: досяжність, утримувачі та очищення

Пошук витоків пам’яті в JavaScript: досяжність, утримувачі та очищення

Дізнайтеся, чому JavaScript із механізмом збирання сміття все ще має витоки пам’яті, які повсякденні патерни зберігають пам’ять, та як знайти причину за допомогою знімків купи та ланцюгів утримувачів.

4420 слів

Веб-додаток, який працює миттєво о 9 ранку, але сповільнюється о 5 вечора, є одним із найпоширеніших симптомів витоку пам’яті: кліки відповідають з запізненням, прокрутка втрачає плавність, анімації «трясуться», кількість використаної пам’яті перевищує гігабайт, а перезавантаження сторінки знову все налагоджує. Такі витоки не створюють винятків, не порушують тестів та проходять перевірки CI без проблем, оскільки вони проявляються лише тоді, коли додаток залишається відкритим протягом годин. У цьому посібнику пояснюється, чому навіть мови з механізмом збирання сміття все одно мають витоки, розглядаються типові сценарії, що спричиняють більшість реальних витоків у практиці, а також наводиться послідовна процедура в Chrome DevTools для пошуку саме того об’єкта, який утримує пам’ять у активному стані.

Збирання сміття звільняє те, що недоступне, а не те, що не використовується

Оскільки JavaScript ніколи не просить вас викликати malloc() чи free(), легко повірити, що система виконання повністю керує пам’яттю. Однак це уявлення є лише частково правильним. Колектор не звільняє об’єкт, коли ваш код закінчив з ним працювати; він звільняє об’єкт тоді, коли до нього більше неможливо дістатися. Якщо якась забута посилання все ще вказує на об’єкт, движок не може зрозуміти, що цей об’єкт є зайвим. З точки зору системи виконання, все, до чого можна дістатися, ще може бути потрібним.

Саме ця прогалина між станами „більше не використовується“ та „більше не доступне“ є причиною деяких з найскладніших проблем з продуктивністю у сучасному веб-розробленні.

Сценарій: панель керування, яка уповільнювалася щопополудня

Цей прорив став можливим завдяки Менеджеру завдань Chrome. Об’єм пам’яті цього вкладки на початку ранку становив приблизно 150 МБ, а до кінця дня сягнув майже 1,4 ГБ. Кожен переход, кожне діалогове вікно, кожне оновлення віджета залишали певну кількість пам’яті. Кожне виділення пам’яті було незначним; проте тисячі таких операцій мали значний вплив. Процес відображення контенту, затримки мережі та швидкість виконання програми були у нормі. Програма просто ніколи не звільняла об’єкти, які їй більше не були потрібні.

Як двигуни вирішують, що залишити у пам’яті

Створення об’єкта в JavaScript зовсім не вимагає особливих процедур:

const user = {
  id: 101,
  name: "Emma"
};

Коли більше ніщо не посилається на user, двигун може вільно звільнити цей об’єкт. Цікаво те, як він це вирішує. V8 (Chrome та Node.js), SpiderMonkey (Firefox) та JavaScriptCore (Safari) відстежують граф об’єктів, пов’язаних через посилання. Кореневі об’єкти, такі як глобальний об’єкт, знаходяться на вершині, а все, що створює ваше додаток, залежить від них: інстанція додатку, маршрутизатор, сховище даних, дерева компонентів та будь-які глобальні змінні.

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

Уявімо, що ви завантажуєте великий список записів:

const employees = fetchEmployees();

Через деякий час інтерфейс більше не відображає ці дані, але інший об’єкт все ще посилається на них:

cache.employees = employees;

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

Мислення через посилання замість об’єктів

Коли ви перестаєте зосереджуватися на об’єктах та починаєте розглядати посилання, які на них вказують, розуміння причин витоків стає набагато простішим. Розгляньмо функцію, яка створює об’єкт та повертає його:

function createUser() {
  const user = {
    name: "Alice"
  };
return user;
}
const employee = createUser();

Після її виконання одне посилання пов’язує змінну з об’єктом:

employee
   │
   ▼
{ name: "Alice" }

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

employee = null;

Одна корекція, якщо ви спробуєте це зробити самі: у попередньому фрагменту змінна employee була оголошена за допомогою const, тому її переатрибування викликає помилку TypeError. Оголошуйте її за допомогою let, якщо плануєте згодом видалити посилання на неї. Суть залишається такою ж: саме видалення останнього посилання дозволяє об’єкту бути зібраним.

Тепер трохи змініть приклад, щоб функція зберігала створені нею об’єкти у масиві на рівні модуля:

const users = [];
function createUser() {
  const user = {
    name: "Alice"
  };  users.push(user);
}

Після того, як createUser() повертається, кожен об’єкт користувача все ще має посилання з боку масиву users, а сам масив є доступним з верхнього рівня:

Window
 │
 ▼
users
 │
 ├── User 1
 ├── User 2
 ├── User 3
 └── User 4

Допоки users є доступним, доступні й усі елементи в ньому. Саме тому витоки зазвичай зростають поступово: жоден окремий об’єкт не є великим, але тисячі малих об’єктів накопичуються протягом годин чи днів.

Витоки накопичуються по одній взаємодії

Вираз „витік пам’яті“ зазвичай асоціюється з одним величезним об’єктом, який споживає сотні мегабайт. Насправді ж цей процес майже завжди є невеликим та повторюваним.

Уявіть собі, що закриття діалогового вікна налаштувань залишає позаду приблизно 20 КБ. Це саме по собі є незначною кількістю. Але користувач, який відкриває та закриває це вікно 500 разів протягом робочого дня, вже втратив близько 10 МБ. Якщо додати ще п’ять компонентів із подібними незначними витратами на кожну операцію, восьмигодинну сесію роботи, кілька вкладок, які відкриті одночасно, та постійні оновлення кожні кілька секунд, ці незначні суми перетворюються на сотні мегабайт.

Ця арифметика також пояснює, чому розробники рідко це помічають. Під час розробки сторінку перезавантажують кожні кілька хвилин, що повністю скидає всі втрати. Користувачі ж не перезавантажують сторінку; вони продовжують працювати.

Чому саме односторінкові додатки найбільше страждають

Класичні багатосторінкові сайти мали випадковий захисний механізм: кожна навігація завантажувала новий документ та видаляла всю купу JavaScript-даних, включаючи об’єкти з витоками.

Додатки з однією сторінкою, створені за допомогою React, Angular, Vue, Svelte чи подібних фреймворків, можуть працювати годинами без повної перезавантаження. Це чудово для користувацького досвіду, і саме тому важлива дисципліна у використанні пам’яті. Кожна зміна маршруту, модальне вікно, повідомлення типу toast, повідомлення через WebSocket та оновлення графіка створюють об’єкти. Якщо їх належним чином не звільняти, вони залишаються у пам’яті стільки, скільки і сама сторінка.

Тут є іронія: чим кращий досвід, тим довше люди залишаються у додатку, і тим більше шансів на накопичення незначних витоків пам’яті.

Механізм збору відходів є складним, а не ясновидцем

Сучасні двигуни використовують інкрементальний та генераційний збір відходів, одночасну позначкування об’єктів, їх компактування та збір під час періодів без активності. Ці методи роблять управління пам’яттю швидким та непомітним, але вони не можуть виправити логічні помилки.

Уявіть собі, що ви позичили комусь книгу та ніколи не просили її повернути. Вони не можуть знати, що ви про неї забули, тож з їхньої точки зору ви все одно хочете, щоб її повернули колись. Посилання працюють так само. Поки ваше запитання містить посилання, двигун вважає, що об’єкт має значення, незалежно від того, чи буде ваш код колись до нього звертатися знову. Він не може прочитати наміри; він лише слідує за лініями у графі.

Ця зміна впливає на спосіб дебаггінгу. Замість того, щоб замислюватися, чому механізм звільнення пам’яті не працює, ви ставите більш продуктивне запитання: що все ще має посилання на цей об’єкт? Майже завжди саме там знаходиться помилка.

Шаблони, які лежать в основі більшості витоків пам’яті у реальному світі

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

Слухачі подій, які ніколи не видаляються

Слухачі є одними з найпоширеніших причин витоків, особливо у SPA. Їх налаштування зазвичай є безневинним: береться елемент та до нього додається обробник подій.

const button = document.getElementById("save");
button.addEventListener("click", saveDocument);

Пізніше користувач виходить з сторінки, і кнопка зникає з неї. Видалення елемента з DOM саме по собі не призводить до розриву всіх пов’язаних з ним посилань у JavaScript. Якщо обробник подій все ще зареєстрований, а щось інше зберігає доступ до елемента чи обробника, то і слухач, і те, що він обробляє, залишаються в пам’яті. Протікання пам’яті є найбільш серйозним у випадку слухачів, приєднаних до елементів довготривалого існування, таких як window чи document, оскільки ці елементи ніколи не зникають. Рішенням є явне видалення обробника подій:

button.removeEventListener("click", saveDocument);

У React, Angular чи Vue робіть це на етапі виведення з екрану або знищення компонента під час його життєвого циклу. Корисною звичкою є розглядати кожен виклик addEventListener() як зобов’язання: коли ви додаєте такий виклик, потрібно знати, коли та де він буде видалений. Передача сигналу AbortController кільком слухачам та його скасування під час знищення компонента — це зручний спосіб виконати це зобов’язання для всіх слухачів одночасно.

Таймери, які існують довше, ніж екран

Таймери також можуть спричиняти витоки ресурсів непомітно. Панель керування, яка отримує нові дані кожні п’ять секунд, може виглядати ось так:

const timer = setInterval(() => {
    loadLatestData();
}, 5000);

Якщо користувач покидає сторінку, а інтервал так і не скасовується, функція-виклик продовжує виконуватися на фоні. Вона зберігає свою замикану функцію живою, а разом з нею — будь-які функції, змінні чи навіть цілі екземпляри компонентів, на які посилається ця замикана функція. Кілька забутих інтервалів можуть займати значно більше пам’яті, ніж ви очікуєте. Скасуйте їх, коли їхній власник піде:

clearInterval(timer);

Та сама дисципліна застосовується до setTimeout() (через clearTimeout()) та requestAnimationFrame() (через cancelAnimationFrame()).

Від’єднані вузли DOM

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

const modal = document.getElementById("modal");
modal.remove();

Здається, що елемент зник, проте якщо якась змінна, масив, замикання чи об’єкт стану все ще посилається на цей елемент, його неможливо зібрати, як і його дочірні елементи. Додатки, які динамічно створюють модальні вікна, підказки, спадні списки чи панелі сповіщень, особливо схильні до цієї проблеми. Кожен вузол є невеликим, але після сотень взаємодій відокремлені підрощини можуть займати досить велику кількість пам’яті.

Замикання, які зберігають більше, ніж потрібно

Замикання є однією з найпотужніших функцій JavaScript, але вони також ускладнюють випадкове зберігання пам’яті. Розгляньмо фабрику, яка виділяє великий масив перед тим, як повернути функцію:

function createLogger() {
    const largeData = new Array(100000).fill("data");
    return function () {
        console.log("Logging...");
    };
}

Функція, яка повертається, ніколи не впливає на largeData. Те, чи залишиться цей масив у пам’яті, залежить від того, як движок представляє навколишній контекст. На практиці V8 зберігає лише ті змінні, на які фактично посилається якась замикання у цьому контексті, тож цей конкретний фрагмент зазвичай не зберігає масив. Ризик виникає, коли друге замикання, створене в тому ж контексті, дійсно використовує largeData: замикання ділять один об’єкт контексту, тож замикання, яке існує довго, також змушене зберігати великий масив у пам’яті. Використання eval усередині контексту також змушує движок зберігати все.

Жоден з цих факторів не робить замикань чимось поганим; сучасний JavaScript залежить від них. Урок полягає у тому, щоб свідомо вирішувати, що може бачити функція з довгим терміном існування. Якщо їй потрібна лише одна значення, передайте або скопіюйте це значення замість того, щоб використовувати ціле об’єкт або набір даних. Невеликі корективи у сфері області видимості можуть значно зменшити використання пам’яті.

Кеші без політики видалення

Кешування допомагає уникнути повторної роботи, але кеш, який постійно зростає, — це просто повільне витікання ресурсів навіть за наявності хороших намірів. Ось мінімальний приклад використання мемоайзингу:

const cache = {};
function getUser(id) {
    if (!cache[id]) {
        cache[id] = fetchUser(id);
    }    return cache[id];
}

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

  • Максимальний розмір
  • Термін дії записів, залежний від часу
  • Стратегію видалення за принципом LRU (найменше використовувані останнім)
  • WeakMap — коли кеш ключується об’єктами, термін життя яких має визначати термін життя запису
  • Зауважте, що WeakMap приймає як ключі лише об’єкти (або незареєстровані символи), тому він не замінює механізм LRU для кешів, ключуємих числовими ідентифікаторами, як у прикладі вище. Якщо ви хочете ознайомитися з тим, як працюють слабкі посилання, перегляньте наш огляд Символів, WeakMaps, Проксі та генераторів. Кеш без стратегії видалення насправді не є кешем; це постійне зберігання даних.

    Глобальні змінні, які існують вічно

    Усе, що належить до глобального простору імен, існує стільки, скільки сама програма. Це зручно, але водночас і ризиковано. Колекція на рівні модуля, подібна до цієї:

    let allUsers = [];
    

    постійно зростає щоразу, коли додаються нові дані:

    allUsers.push(...newUsers);
    

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

    WebSockets та інші довготривалі з’єднання

    Функції реального часу часто базуються на WebSockets, і їх створення займає лише один рядок:

    const socket = new WebSocket(url);
    

    Помилкою є недостатнє закриття з’єднання. Відкрите з’єднання продовжує отримувати повідомлення, викликати функції-відповідачі та зберігати стан додатку навіть після того, як користувач перейшов в інше місце. Закривайте з’єднання, коли функція, яка ним керує, більше не потрібна:

    socket.close();
    

    Те саме правило стосується об’єктів типу observables, стрімів, користувацьких генераторів подій та будь-яких інших механізмів підписки, які можуть існувати довше, ніж їхні користувачі.

    Спільний мотив

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

    • Зареєстрований слухач
    • Чекаючий інтервал або таймаут
    • Область видимості закриття
    • Постійно зростаючий кеш
    • Змінна рівня модуля або глобальна змінна
    • Відкритий сокет або інша підписка

    Як тільки ви почнете думати про посилання, а не про об’єкти, пошук витоків стане набагато інтуїтивнішим. Замість того, щоб запитувати, чому пам’ять продовжує зростати, запитайте, що все ще утримує цей об’єкт. Це запитання зазвичай веде прямо до виявлення витоку.

    Виявлення витоку раніше, ніж це зроблять ваші користувачі

    Знання причин є корисним, але у реальному кодовому базі головне питання полягає у тому, де саме відбувається витік пам’яті. Великий додаток може містити тисячі компонентів та сотні слухачів, причому об’єкти створюються щосекунди. Здогадки рідко допомагають. Інструменти браузера є чудовими; різницю створює їх послідовне та систематичне використання, а не вивчення кожної функції DevTools.

    Переконайтеся, що справді відбувається витік пам’яті

    Зростання обсягу пам’яті не завжди означає витік. Двигуни браузера виділяють пам’ять під час роботи додатку та звільняють її під час збору від непотрібних об’єктів, тому здоровий додаток демонструє характерну зубчасту криву:

    Memory
     ^
     |        /\      /\       /\
     |       /  \    /  \     /  \
     |______/____\__/____\___/____\____ Time
    

    Обсяг використання пам’яті зростає під час активності додатку та знижується після кожного збору. Додаток із витоком пам’яті має іншу динаміку:

    Memory
     ^
     |          /\        /\
     |         /  \      /  \
     |        /    \    /    \
     |_______/______\__/______\________
     |              /
     |             /
     |            /
     |___________/________________ Time
    

    Невеликі спади свідчать про те, що процес збору сміття активний, але кожен такий спад відбувається на вищому рівні, ніж попередній. Пам’ять ніколи не повертається до свого початкового рівня, що є першим явним ознакою зберігання об’єктів. Перш ніж робити висновки, вручну запустіть процес збору сміття (іконка сміттєвого кошика у панелях „Пам’ять“ та „Продуктивність“), адже рівень, який здається високим лише через те, що процес збору ще не відбувся, не є ознакою витоку пам’яті.

    Крок 1: відкрийте панель „Пам’ять“

    Chrome пропонує кілька інструментів для контролю пам’яті, але вам не потрібні всі вони одночасно. Відкрийте DevTools та перейдіть до панелі Пам’ять. Залежно від версії Chrome ви побачите різні типи аналізу, такі як знімок стека пам’яті, інструменти для відстеження виділення пам’яті у часовій шкалі та вибірковий аналіз виділення пам’яті; точні назви та опції змінюються між версіями, тому перевірте актуальну документацію DevTools, якщо у вас є відмінності.

    Для більшості розслідувань знімок купи пам’яті є правильною відправною точкою, оскільки він показує, що наразі займає пам’ять.

    Крок 2: зафіксуйте початковий стан

    Перш ніж активувати підозрювану функцію, зробіть знімок початкового стану додатку, «фотографію» купи пам’яті. Потім неодноразово виконуйте дії, які ви підозрюєте. Наприклад:

    • Відкривайте та закривайте модальне вікно десять разів
    • Переходьте туди-сюди між сторінками
    • Завантажуйте файли
    • Застосовуйте фільтри до таблиці з великим обсягом даних
    • Постійно перемикайтеся між вкладками панелі керування

    Коли закінчите, зробіть ще один знімок. Тепер у вас є два стани для порівняння.

    Крок 3: порівняйте знімки

    Саме тут по-справжньому починається розслідування. Якщо процедура очищення працює належним чином, тимчасові об’єкти, створені під час взаємодії, мають зникати після їх збору. Якщо це не відбувається, певні типи об’єктів продовжують зростати. Типовими підозрюваними є:

    • Елементи DOM, які відокремлені
    • Великі масиви
    • Прослуховувачі подій
    • Власні класи додатку
    • Компоненти фреймворку, які мали б бути знищені

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

    Елементи DOM, які відокремлені, — це найпростіші докази для виявлення

    Відокремлені вузли — це один із найпростіших видів витоків, які можна розпізнати. Відкрийте та закрийте модальне вікно двадцять разів; після кожного закриття це вікно має зникнути. Якщо знімок все ще містить двадцять елементів модального вікна, щось утримує їх. Ви можете ввести „Detached“ у фільтр класу знімка, щоб швидко їх відфільтрувати.

    Сам DOM рідко є справжньою проблемою. Фактична причина зазвичай знаходиться деінде:

    • Обробник, який все ще зареєстрований на елементі або на об’єкті з довгим терміном існування
    • Інтервал або таймаут, чия функція виклику згадує цей вузол
    • Замикання, яке зафіксувало елемент
    • База даних, поле компонента або масив, які зберегли посилання на нього

    Вважайте відокремлений вузол симптомом: доказом того, що якась інша посилання завадила очищенню.

    Простежіть ланцюг утримувачів

    Як тільки ви знаходите об’єкт, який, очевидно, більше не повинен існувати, наступне питання — хто підтримує його життєздатність. Розділ Retainers у знімку купи даних відповідає саме на це питання. Виберіть об’єкт, і Chrome покаже ланцюг посилань, які пов’язують його з коренем механізму обробки сміття. Концептуально це можна описати так:

    Window
       │
    Application
       │
    UserService
       │
    cachedUsers
       │
    User Object
    

    Тепер розслідування стає простим. Замість того, щоб заморочуватися над причиною того, чому об’єкт користувача залишається, ви можете побачити, що до нього посилається колекція cachedUsers у класі UserService. Виявлення об’єкта, який зберігається, є корисним; але саме визначення того, що його підтримує, допомагає фактично виправити помилку.

    Переглядайте лайв-показники за допомогою Performance Monitor

    Знімки ідеально підходять для детального аналізу, але вони не є єдиним інструментом. Performance Monitor у Chrome відображає лайв-метрики, які включають:

    • Розмір купи JS
    • Кількість вузлів DOM
    • Кількість слухачів подій JS
    • Документи та фрейми

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

    Ізолюйте один невеликий, повторюваний сценарій

    Частою помилкою є спроба дослідити всю програму одночасно. Краще звузити увагу до однієї конкретної взаємодії:

    • Покажіть одне модальне вікно, закрийте його та повторіть це двадцять разів поспіль
    • Або ж переходьте між двома однаковими маршрутами п’ятдесят разів

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

    Навмисно тестуйте тривалі сеанси

    Розробники зазвичай працюють з додатком десять-п’ятнадцять хвилин і переходять до наступних завдань. Справжні користувачі внутрішнього панелі керування, торговельної платформи, інструменту моніторингу чи порталу підтримки можуть тримати їх відкритими цілий день. Включіть тривалі сеанси до тестування пам’яті: залиште додаток відкритим, періодично взаємодійте з ним та спостерігайте за змінами пам’яті. Багато проблем проявляються лише після сотень чи тисяч взаємодій.

    Процес виправлення помилок, який уникає здогадок

    Постійне перемикання між інструментами аналізу витрачає час. Краще дотримуватися фіксованої послідовності дій:

    1. Переконайтеся, що обсяг пам’яті продовжує зростати протягом усього часу роботи додатку.
  • Відтворіть процес зростання за допомогою однієї повторюваної дії.
  • Зробіть знімки стеку до та після цього.
  • Порівняйте кількість об’єктів у цих знімках.
  • Визначте об’єкти, які залишаються у пам’яті.
  • Відстежте ланцюг зберігачів, щоб дізнатися, хто тримає посилання.
  • Виправте код та знову запустіть точно такий самий тест.
  • Це усуває необхідність здогадок під час роботи. Замість того, щоб припускати, що винна певна компонента, ви дозволяєте доказам вказати на неї.

    Запобігання витокам перед їх потраплянням у продакшн

    Діагностика — це лише половина справи; кращим рішенням є з самого початку уникнення витоків. Більшість витоків не є наслідком непорозуміння розробниками мови JavaScript. Вони виникають тому, що сучасні додатки мають тривалий термін існування, є високоінтерактивними та постійно виділяють ресурси, і в таких умовах легко забувати, що все, що ви створюєте, зрештою потрібно буде знищити. Команди, які рідко стикаються з проблемами пам’яті, не обов’язково пишуть більш розумний код; у них є звички, які роблять витоки малоймовірними.

    Визначте чіткий термін закінчення життя кожного ресурсу

    Кожного разу, коли код створює щось, що має тривалий термін існування, поставте собі одне запитання: коли це буде знищено? Це стосується набагато більшого, ніж просто оперативної пам’яті:

    • Слухачі на елементах, window чи document
    • Інтервали, таймаути та кадри анімації
    • Сокети та інші мережеві з’єднання
  • Об’єкти спостереження, сховища та інші підписки
  • Збережені посилання на елементи DOM
  • Кешовані відповіді та обчислені значення
  • Веб-роботи та подібні фонові завдання
  • Налаштування цих елементів зазвичай є простим; проблеми виникають під час їх очищення. Є просте правило, яке це описує: якщо у вашому коді є початок, то має бути й кінець. Саме такий підхід допомагає уникнути чималої кількості проблем з витоками ресурсів.

    Змусьте компоненти самостійно очищуватися

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

    Уявіть собі виїзд з готельного номера: ви не залишаєте світло увімкненим, телевізор у режимі відтворення та кран відкритим. Хороший компонент залишає все таким, яким його знайшов. У React це означає повертати функцію очищення з кожного useEffect, який підписується, планує або під’єднується.

    Проектуйте кеші з урахуванням видалення, а не лише додавання

    Кеші створюються з добрими намірами — щоб уникнути повторних викликів API чи дорогих обчислень. Однак через кілька місяців вони можуть містити тисячі об’єктів, до яких ніхто не заходив протягом тижнів. При проектуванні кешу потрібно так само ретельно продумати, як елементи будуть видалятися, як і те, як вони потрапляють:

    • Як довго елемент має залишатися в пам’яті?
    • Який максимальний розмір?
    • Чи повинні елементи автоматично закінчувати свою дію?
    • Чи можна видаляти рідко використовувані елементи?

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

    Зберігайте лише ті дані, які вам дійсно потрібні

    Ще одна поширена проблема — зберігання цілих об’єктів, коли використовується лише їхня невелика частина. Якщо ви завантажуєте великий профіль лише для того, щоб показати ім’я користувача, немає причин зберігати повну відповідь назавжди; зберігайте лише ті поля, які потрібні інтерфейсу. Менші об’єкти споживають менше пам’яті, їх легше аналізувати, і менш ймовірно, що вони залишаться у системі випадково. Іноді рішенням є не більше коду, а менше збережених даних.

    Серйозно ставіться до незначних витоків

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

    Додайте перевірки пам’яті до щоденної розробки

    Робота над продуктивністю зазвичай зосереджується на часі завантаження та затримках API, але пам’ять також потребує уваги. Під час створення нової функції приділіть кілька додаткових хвилин перевірці наступного:

    • Чи повертається пам’ять до свого початкового рівня після використання функції?
    • Чи не зростає кількість слухачів подій несподівано?
    • Чи зникають елементи DOM після видалення компонентів?
    • Чи постійне виконання однакової дії призводить до поступового зростання пам’яті?

    Ці перевірки є простими та можуть заощадити години на наступній дебагуванні.

    Чек-лист для перегляду коду

    Перед об’єднанням пройдіться коротким списком у своїй голові:

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

    Ключові висновки

    • Проблеми з витоками даних рідко спричиняють крахи вже з першого дня; вони поступово накопичуються та спочатку впливають на найактивніших користувачів, саме тому вони є небезпечними.
    • Вони також є передбачуваними: об’єкт залишається активним лише тому, що хтось все ще на нього посилається, тож кожен витік даних має свою причину у посиланні, яке слід було видалити.
    • Коли додаток уповільнюється протягом кількох годин, утримуйтеся від звинувачень у браузері чи двигуні. Зробіть знімок пам’яті, знайдіть об’єкти, які залишилися, та простежте ланцюжок їх зберігання.
    • Звичайна відповідь є банальною: забутий слухач, нескасований таймер, необмежений кеш або компонент, який так і не завершив очищення.
    • Швидкий JavaScript — це не лише швидкість виконання; це також управління життєвим циклом того, що ви виділяєте, щоб додаток залишався реактивним незалежно від того, чи користується ним хтось протягом п’яти хвилин, чи цілий робочий день.

    Пов’язана література