Головна / Статті

Коли мемоізація в React призводить до проблем: чому менша кількість відображень уповільнила додаток.

Коли мемоізація в React дає зворотний ефект: функції memo, useMemo та useCallback додали навантаження та застарілі замикання до великої сторінки. Переміщення стану та аналіз потоку допомогли вирішити проблему.

Видавець: REACTAPP.TOP13 хв читання2888 слів

Джерела: The React Optimization That Made Our App Slower

Випадок виправлення неправильної проблеми

Уявіть собі велику сторінку, яка переробляється щоразу, коли хтось вводить дані у фільтр. Вона містить таблиці, діаграми, підсумки та кілька інтерактивних панелей, а React Profiler відображає довгий список компонентів. Це виглядає як доказ того, що React виконує занадто багато роботи.

Зазвичай застосовується стандартне рішення: компоненти обгортаються функцією memo, обробники подій — useCallback, обчислення — useMemo, а селектори отримують власні функції порівняння. Компонент, який все ще відображається, має більше своїх параметрів, які стабілізуються. Об’єкт, посилання на який змінюється, стає мемоізованим. Неуспішне порівняння замінюється більш детальним.

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

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

Що насправді означає „непотрібне“

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

Візьмімо функційний компонент, який отримує кілька примітивних параметрів та повертає просту деревоподібну структуру елементів. Він виконується миттєво, React порівнює отриманий результат із попереднім та майже не змінює структуру DOM, тому блокування цього виклику не приносить жодної користі.

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

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

Яку справді вартість має memo

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

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

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

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

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

Глибоке порівняння

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

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

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

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

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

Зберігання значень у пам’яті

Далі йшли значення. Фільтровані масиви, об’єкти конфігурації, сформатовані етикетки, таблиці пошуку та прості обчислення отримували функцію useMemo, як тільки компоненти, які їх оточували, були мемоізовані.

Деякі з них справді цього заслуговували. Сортування великої колекції чи створення складної моделі може виправдати кешування, якщо вхідні дані змінюються рідше, ніж оновлюється батьківський компонент. Багато інших — ні. Об’єднання двох рядків чи створення невеликого об’єкта є дешевшим, ніж весь супутній „каркас“: компонент все одно створює масив залежностей, React все одно порівнює кожен елемент із попереднім, попередній результат все одно займає пам’ять, а розробникам все одно доводиться вирішувати, чи повний список.

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

const label = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

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

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

Колбеки та застарілі замикання

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

Це усунуло певну змінність у посиланнях, але призвело до створення довших списків залежностей. Обробник збереження залежав від чернетки, обраної запису, поточного користувача, дозволів, сервісу сповіщень та функції мутації, і будь-яка зміна в цих елементах все одно призводила до створення нового калебеку. Калебек рідко залишався стабільним достатньо довго, щоб його можна було повторно використати. Дочірній елемент все одно відображався, а батьківський елемент мав ще один Hook та ще один контракт залежності.

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

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

Кеші використовують пам’ять

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

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

Справжня проблема полягала у місці зберігання стану

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

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

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

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

Компоненти відображаються рідше, але навантаження на браузер залишається таким самим

Навіть там, де мемоізація усувала необхідність оновлення компонентів у React, інтеракції іноді залишалися повільними, оскільки основною причиною не був сам React.

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

Продуктивність React — це лише частина продуктивності фронтенду, а не її повна заміна. Навіть за наявності мемоізації зміна залежності Effect все одно може змусити сторонній віджет перебудуватися. Пропуск відрендерування батьківського елемента не скорочує час декодування занадто великої зображення. Стабільний калебек не усуне проблем із лейаутом, спричинених читанням та записом показників елементів у неправильному порядку. Потрібно простежити весь процес через JavaScript, відрендерування React, зміни в DOM, обчислення стилів, формування лейауту, процес малювання та мережеві операції, адже прискорення одного етапу може залишити весь процес незмінним. React залишався мовчазним, поки браузер продовжував працювати.

Код, який важче змінювати

Втрата продуктивності була лише половиною проблеми. Крім того, доглядати за додатком стало складніше.

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

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

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

Вимірювання взаємодії змінило рішення

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

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

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

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

Що залишилося

Не кожен елемент memo, useMemo та useCallback було видалено, адже це призвело б до повторення помилки у зворотному напрямку.

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

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

Відображення — не ворог

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

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

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

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

  • Чому розробка фронтенду складна після React: стан, ефекти, межі та інструменти ШІ — React ускладнив створення інтерфейсів, але не полегшив їхнє проектування. Справжньою роботою фронтенд-розробника є визначення місця зберігання стану, розбирання складних ефектів, встановлення меж системи та робота з кодом, створеним ШІ.
  • Додавання екрана запуску до додатку React Native на iOS та Android — Покрокова інструкція щодо налаштування екрана запуску для React Native на iOS та Android: екран запуску в Xcode, теми та макети для Android, а також використання react-native-splash-screen для усунення білого спалаху.
  • 30 звичок використання React та Redux для масштабованих та підтримуваних фронтендів — Тридцять коротких порад щодо React та Redux щодо стану, даних з сервера, хуків, селекторів, структури папок, тестування та рефакторингу, призначених для додатків, які мають понад тисячу компонентів.