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

Дев’ять поширених шаблонів, які спричиняють зайве оновлення компоненту React

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

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

Джерела: 9 React Habits That Quietly Cause Unnecessary Re-Renders

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

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

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

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

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

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

1. Зберігання часто змінюваних станів занадто високо в дереві

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

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

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

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

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

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

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

2. Використання одного контексту для непов’язаних значень

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

Проблеми починаються тоді, коли один постачальник перетворюється на універсальний контейнер для всього, що вважається „глобальним“.

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

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

Часто постачальник також перебудовує своє значення контексту під час кожного відображення:

function AppProvider({ children }) {
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState([]);
  const [sidebarOpen, setSidebarOpen] = useState(false);

  const value = {
    theme,
    setTheme,
    notifications,
    setNotifications,
    sidebarOpen,
    setSidebarOpen,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

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

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

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

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

3. Перебудова об’єктів, масивів та функцій, коли справді важлива ідентичність

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

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

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

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

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

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

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

4. Зберігання обчислених значень та їх оновлення за допомогою ефектів

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

Розгляньмо компонент, який зберігає повний список записів, поточний текст фільтра та окремо збережений відфільтрований список. Або форму, яка відстежує свої поля, помилки перевірки та окремий флаг canSubmit. Або компонент списку, який зберігає як обраний ідентифікатор, так і друге значення стану з відповідним обраним об’єктом.

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

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [visibleProducts, setVisibleProducts] = useState(products);

   useEffect(() => {
    setVisibleProducts(
      products.filter((product) =>
        product.name.toLowerCase().includes(search.toLowerCase())
      )
    );
  }, [products, search]);
  // ...
}

Ось що відбувається, коли змінюється search: спочатку компонент відображається з застарілим відфільтрованим списком у стані, React фіксує це відображення, потім запускається ефект, який оновлює стан та заплановує ще одне відображення.

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

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

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

5. Підписка на більше даних з зовнішнього сховища, ніж вам насправді потрібно

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

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

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

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

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

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

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

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

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

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

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

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

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

Компонент може продовжувати виконувати свої функції, навіть якщо він невидимий.

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

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

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

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

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

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

8. Дозвіл на перезапуск ефектів через нестабільні залежності

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

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

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

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

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

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

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

9. Заміна стану на новий об’єкт, який представляє те саме

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

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

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

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

Це зовсім не означає, що потрібно проводити глибоке порівняння кожної зміни стану. Глибоке порівняння може виявитися більш трудомістким, ніж те, чого воно намагається уникнути, крім того, воно вводить ще одне поняття „рівності“, яке потрібно послідовно дотримуватися. Більш стабільним підходом є природне збереження ідентичності об’єктів тоді, коли базові дані насправді не змінилися.

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

Мета не в тому, щоб усунути повторну обробку

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

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

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

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

Найкорисніше запитання щодо продуктивності React — це не „як мені запобігти відрендеруванню цього компонента?“

Це „що саме змінилося, хто є власником цієї зміни та чому від цього компонента знову потребують переоцінки?“

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