Головна / Статті / Спочатку зробіть менше роботи: перелік перевірок продуктивності React перед використанням мемоїзації

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

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

2118 слів

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

Ключове запитання протягом усього посібника є простим: чи можна повністю уникнути цієї роботи?

Зменште кількість запитів: використовуйте debounce для поля пошуку

Поле пошуку є класичним джерелом зайвих запитів. Наївний обробник викликає API при кожній зміні:

const handleSearch = (value) => {
  fetchUsers(value);
};

Якщо ввести слово „React“ у це поле, кожна натиснута клавіша генерує окремий запит:

R
Re
Rea
Reac
React

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

const handleSearch = debounce((value) => {
  fetchUsers(value);
}, 300);

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

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

Самодостатній компонент пошуку з ефектом затримки

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

import { useEffect, useState } from "react";
function UserSearch() {
  const [search, setSearch] = useState("");
  const [users, setUsers] = useState([]);

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

useEffect(() => {
    const timer = setTimeout(async () => {
      if (!search.trim()) {
        setUsers([]);
        return;
      }

Для реального запиту функція-колбек запитує користувачів, які відповідають критеріям, кодуючи термін пошуку, щоб спеціальні символи не пошкодили URL:

const response = await fetch(
        `/api/users?search=${encodeURIComponent(search)}`
      );

Потім вона аналізує JSON та зберігає результат, все це все ще протягом таймера у 300 мс:

const data = await response.json();
      setUsers(data);
    }, 300);

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

return () => clearTimeout(timer);
  }, [search]);

Нарешті, компонент відображає контрольоване поле введення, прив’язане до search:

return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />

І він виводить список користувачів, пронумерований за їхніми ID:

{users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

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

Загальний урок: запобігання виконанню операцій зазвичай краще, ніж прискорення вже існуючих операцій.

Завантажуйте лише ті дані, які потрібні екрану

Ще однією поширеною причиною витрати ресурсів є завантаження значно більшої кількості даних, ніж відображається. Припустимо, кінцева точка повертає 10 000 користувачів, тоді як на екрані відображається по 20 за раз. Браузер все одно мусить завантажити, обробити та зберегти їх усіх у пам’яті, а React мусить працювати з набагато більшими масивами, ніж це необхідно.

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

API
 ↓
20 users
 ↓
Browser
 ↓
Display

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

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

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

function Dashboard() {
  const [search, setSearch] = useState("");
return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <Analytics />
      <UserTable />
    </>
  );
}

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

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

Надавайте елементам динамічного списку стабільні ключі

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

{users.map((user, index) => (
  <UserCard key={index} user={user} />
))}

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

{users.map((user) => (
  <UserCard key={user.id} user={user} />
))}

Різниця полягає у тому, що представляє собою ключ:

index → position
id    → identity

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

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

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

const filteredUsers = users
  .filter((user) => user.isActive)
  .sort((a, b) => a.name.localeCompare(b.name))
  .map((user) => ({
    ...user,
    displayName: user.name.toUpperCase()
  }));

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

Завантажувати функції за потребою

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

const Reports = lazy(() => import("./Reports"));

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

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

Мемоізація з причиною

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

const handleClick = useCallback(() => {
  setSelectedUser(id);
}, [id]);

Або кожного похідного значення:

const data = useMemo(() => {
  return processData(users);
}, [users]);

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

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

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

Чек-лист для перевірки

Перегляньте ці запитання під час аудиту функціоналу React:

  1. Непотрібні запити? Шукайте виклики за кожен натиск клавіші, дубльовані запити та отримання даних, які наразі не відображаються.
  2. Занадто багато даних? Використовуйте сторінкування, фільтрацію на сервері та відкладайте отримання даних до моменту їх необхідності.
  • Значення занадто високе? Перевірте, чи одне оновлення змушує переробити більшу частину дерева, ніж це необхідно.
  • Нестабільні ключі списку? Використовуйте стабільні ідентифікатори там, де елементи можуть зміщуватися або змінювати своє приналежність.
  • Надмірна обробка? Перед оптимізацією перевірте, чи можна перенести обчислення на сервер або їх пропустити.
  • Функції, які виконуються заздалегідь? Завантажуйте великі або рідко використовувані модулі з відкладеною загрузкою.
  • Недоречна мемоізація? Не додавайте useMemo, useCallback чи React.memo лише тому, що вони існують.
  • Продуктивність — це процес, а не просто відображення

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

    API calls
       ↓
    Amount of data
       ↓
    State updates
       ↓
    Component structure
       ↓
    Data processing
       ↓
    Rendering
       ↓
    Bundle size
    

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

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

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