Оптимізація продуктивності в React-застосунку

Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!

Привіт! Мене звати Олексій Подобед, я маю понад 10 років досвіду роботи в IT.

Починав кар’єру як Angular-розробник, потім перейшов на React. Зараз я Tech Lead у компанії Yalantis. У кінці серпня виступав як спікер на DOU Front-end Meetup з темою «Оптимізація продуктивності в React-аппці». Вирішив перевести свою доповідь у статтю, тож почнімо!

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

Що потрібно враховувати перед оптимізацією продуктивності React

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

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

  • Які ваші поточні вузькі місця в продуктивності React?
  • Який компонент React мені потрібно спочатку оптимізувати?

Основні інструменти для аналізу перфомансу — Chrome DevTools (використовуємо для профайлінгу JS-коду, а саме: рівень завантаження основного треду та причини цієї поведінки) і React DevTools (використовуємо для профайлінгу тільки React-частини — час рендерингу і кількість ререндерів).

Цикл рендерингу в React

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

Зазвичай більшість випадків пов’язані з проблемою ререндера.

Перед тим як компонент з’явиться на UI, React-у потрібно його відрендерити.
Сам процес рендерингу поділяється на 3 фази. Triggering → Rendering → Committing:

  • Triggering — фаза, де відбувається initial render чи зміна стану.
  • Rendering — фаза, коли React визначає, де була зміна стану та що треба змінити (на цій фазі будується virtual-dom).
  • Committing — фаза, де React маніпулює з DOM-елементами.

Більш детальний приклад Rendering-фази:

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

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

Техніки оптимізації

Перша частина основних підходів до оптимізації перфомансу стосується зменшення кількості ререндерів.

Далі розглянемо такі концепції: композиція компонентів і стейту, мемоізація та робота з масивами. Щоб покращити розуміння цих концепцій, розглянемо приклади проблем та способи їх вирішення.

Набір компонентів

У нас є компонент Counter, який відображає поточне значення та кнопку, що змінює це значення. Також усередині лежить проблемний ExpensiceComponent, у якому є синхронна операція, що займає 200 мс. Згідно з дефолтними правилами, ExpensiceComponent буде ререндеритись щоразу, коли змінюється стейт у parent-компоненті. Дотиком на кнопку бачимо, що зміна Counter відбувається з затримкою.

const Counter = () => {

  const [count, setCount] = useState(0);

  const increaseCount = () => setCount(count + 1);

  return (

    <div>

      Count: <span>{count}</span>

      <ExpensiveComponent />

      <button onClick={increaseCount}>increase</button>

    </div>

  )

};

Є два способи вирішення цієї проблеми.

Перший: винести ExpensiveComponent за межі Counter.

const Counter = ({ children }) => {

  const [count, setCount] = useState(0);

  const increaseCount = () => setCount(count + 1);

  return (

    <div>

      Count: <span>{count}</span>

      {children}

      <button onClick={increaseCount}>increase</button>

    </div>

  );

};

const Application = () => {

  return (

    <>

      <Counter>

        <ExpensiveComponent />

      </Counter>

    </>

  );

};

Тепер ExpensiveComponent стає дочірнім компонентом Application, який не має зміни стейту і відрендерюється лише під час початкового рендерингу. Це призводить до того, що при зміні стану в Counter ExpensiveComponent не рендериться знову. Такий спосіб розвʼязання цієї проблеми є оптимальним.

Другий: використовувати мемоізацію.

const Counter = <strong>React.memo</strong>(() => {

  const [count, setCount] = useState(0);

  const increaseCount = () => setCount(count + 1);

  return (

    <div>

      Count: <span>{count}</span>

      <ExpensiveComponent />

      <button onClick={increaseCount}>increase</button>

    </div>

  );

});

За допомогою React.memo() ми можемо обгорнути ExpensiveComponent. В інтерфейсі в Counter компоненти Expensive Component залишаться в тому ж місці. Цей підхід також забезпечує хорошу продуктивність і відсутність лагів.

Однак важливо користуватися React.memo обережно. Він мемоізує результат компонента та перевіряє пропси. Це корисно, коли компонент рендериться часто з однаковими пропсами або має складний інтерфейс. Проте не варто надмірно використовувати React.memo(), оскільки це може призвести до надмірного навантаження й унаслідок до погіршення продуктивності.

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

Стейт-композиція

Розглянемо другий випадок, який допоможе вам краще зрозуміти проблему стейт-композиції.

const UserPage = () => {

  const [users, setUsers] = useState([]);

  const [userInput, setUserInput] = useState('');

  const handleAddUser = () => setUsers([...users, makeStubUser(userInput)]);

  const handleRemove = (userId) => setUsers(users.filter(user => user.id !== userId));

  return (

    <>

      <div>

        <Input value={userInput} onChange={e => setUserInput(e.target.value)} />

        <button onClick={handleAddUser}>Add</button>

      </div>

      {users.map((user) => (

        <UserPreview

          key={user.id}

          user={user}

          onRemove={() => handleRemove(user.id)}

        />

      ))}

    </>

  );

};

У прикладі маємо сторінку, яка є контейнером і містить два різні стани: список й input користувача. Проте на UI ми спостерігаємо, що коли користувач вводить дані в поле input, увесь список повторно рендериться. Ця поведінка не є оптимальною. Як її виправити?

Розвʼязання цієї проблеми є досить простим — необхідно локалізувати стани. Усе, що стосується форми додавання користувача (форма, кнопка і стан), слід винести в окремий компонент.

const UserPage = () => {

  const [users, setUsers] = useState([]);

  const handleAddUser = useCallback((newUser) => /*..*/, []);

  const handleRemove = useCallback((userId) => /*..*/, []);

  return (

    <>

      <UserForm onAddUser={handleAddUser}/>

      {users.map((user) => ( /*...*/ ))}

    </>

  );

};

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

Однак важливо зауважити, що використання React-контексту також має свої нюанси. Розглянемо типовий приклад з його використанням:

const UsersProvider = ({ children }) => {

  const [state, dispatch] = useReducer(reducer, getInitialState());

  return (

    <UsersContext.Provider value={{ state, dispatch }}>

      {children}

    </UsersContext.Provider>

  );

};

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

Для розв’язання проблеми можна створити окремі контексти під свої завдання:

const UsersProvider = ({ children }) => {

  const [state, dispatch] = useReducer(reducer, getInitialState());

  return (

    <ActionsContext.Provider value={dispatch}>

      <UsersContext.Provider value={state.users}>

        <FiltersContext.Provider value={state.filters}>

          {children}

        </FiltersContext.Provider>

      </UsersContext.Provider>

    </ActionsContext.Provider>

  );

};

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

Або ж можна використати бібліотеки для стейт-менеджменту, які надають сильніший контроль над станом застосунку та мають більш атомарний підхід до управління станом.

Вибір бібліотеки залежить від конкретних вимог проєкту та особливостей використання. Я виписав ті, з якими працював — це Zustand, Yoatai, Recoil, Redux.

Recoil та Yoatai дуже схожі та мають атомарний підхід в стейт-менеджменті. Мені більше подобається Zustand, він легкий та простий, може працювати за межами React.

Мемоізація

Наступний тематичний блок стосується мемоізації, а саме: використання хуків useCallback() та useMemo(). Ці хуки допомагають мемоізувати результати функцій та значень.

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

Розгляньмо ситуації, коли варто використовувати useCallback:

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

Друге правило — використовуйте useCallback, якщо є залежності від інших хуків.

Крім цього, не варто використовувати useCallback там, де функція ассайниться як обробник на DOM-елемент, оскільки всі нативні DOM-елементи ререндеряться щоразу.

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

Оптимізація виконання JavaScript

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

Throttling & debouncing можна використовувати для оптимізації API-запитів, щоб не перевантажувати ваш бекенд. Наприклад, якщо у вас є фільтрація або пошук, які викликають реальні запити на бекенд, то має сенс обгортати ці виклики в debouncing або throttling.

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

Data normalization. Якщо ви працюєте з великою кількістю даних на клієнтському рівні, особливо, якщо маєте вкладені дані — нормалізація може значно покращити продуктивність. Усі операції з даними виконуються синхронно.

Щодо нових фіч в React: тепер у нас є можливість позначати деякі оновлення як «нетермінові». Це означає, що навіть якщо є важкі оновлення, UI не блокуватиметься до їхнього виконання. React виконає їх тільки тоді, коли буде готовий, та якщо будуть вільні ресурси.

За допомогою хуків useTransition() і useDeferredValue() — ми позначатимемо, що є нетерміновим оновленням. Далі розглянемо приклад використання цих хуків.

Для прикладу: у нас є стан, де міститься 10 000 користувачів та фільтрація, яка застосовується до цього масиву. Без оптимізації ми помічаємо, що застосунок повільно реагує на зміни фільтра.

const UserPage = () => {

  const [users] = useState(() => range(10000).map(makeMockedUser));

  const [filters, setFilter] = useFilters();

  const filteredList = useMemo(() => filterUsers(users, filters), [users, filters]);

  const handleChange = (event) => {

    const { name, value } = event.target;

    setFilter(name, value);

  };

  return (

    <>

      <Filters filters={filters} onChange={handleChange} />

      <UserList users={filteredList} />

    </>);

};

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

const UserPage = () => {

  const [users] = useState(() => range(10000).map(makeMockedUser));

  const [filters, setFilter] = useFilters();

  const [filtersInput, setFilterInput] = useFilters();

  const [isPending, startTransition] = useTransition();

  const filteredList = useMemo(() => filterUsers(users, filters), [users, filters]);

  const handleChange = (event) => {

    const { name, value } = event.target;

    setFilterInput(name, value);

    startTransition(() => setFilter(name, value));

  };

  return (

    <>

      <Filters filters={filtersInput} onChange={handleChange} />

      <UserList users={filteredList} isPending={isPending} />

    </>);

};

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

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

Інший підхід — використання хука useDeferredValue().

Обидва хуки — useTransition і useDeferredValue — виконують подібні завдання.

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

Тепер розгляньмо інший підхід — віртуалізацію.

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

Повернемося до приклада, коли в нас є 10 000 користувачів у стані та фільтрація, яка застосовується до цього списку. Без оптимізації ми помічаємо, що застосунок реагує повільно. Віртуалізація полягає в тому, що ми відображаємо лише видиму частину списку (наприклад, лише ті, що помістились у viewport, плюс по 20 зверху та знизу для скрола) — так ми оптимізуємо відображення. Коли користувач скролить вгору або вниз, додаємо або видаляємо необхідну кількість елементів. Віртуалізація — це найкращий підхід для списків, оскільки вона значно покращує продуктивність.

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

WebWorker

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

Наприклад, ви помітили, що в застосунку є функція, яка блокує основний потік. Нехай це буде обчислення числа Фібоначчі. І ви бачите: розпочинаючи це обчислення, застосунок починає лагати, а інтерфейс користувача стає несправним. Що робити в такому випадку? Ми можемо створити WebWorker і делегувати це обчислення йому, тим самим розблокувавши основний потік, і забезпечити адекватну реакцію інтерфейсу користувача.

Через postMessage() надсилаємо івент. Наприклад, користувач натискає на якусь кнопку, а ми відправляємо івент через postMessage на WebWorker. WebWorker обчислює цю операцію, і, як тільки все готово, відправляє повідомлення з результатом назад на нашу сторінку — тоді ми відображаємо його.

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

Висновки

І на завершення кілька рекомендацій:

  • послуговуйтеся перш за все State Composition або Component Composition, якщо це можливо. Вони не потребують додаткових перевірок та є найефективнішими;
  • використовуйте мемоізацію обдумано та тільки там, де це справді потрібно. Не застосовуйте її всюди, оскільки це може зробити код складнішим;
  • використовуйте Transition API, якщо інші підходи вже не працюють. Transition API допомагає позначити окремі апдейти як нетермінові, що дозволяє поліпшити продуктивність, особливо при зміні стану великої кількості даних. Transition API допоможе уникнути зайвих перерендерів та забезпечує більш рівномірну реакцію на зміни.

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

👍ПодобаєтьсяСподобалось18
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за матеріал, подобається підбірка problem-solution!

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

Причиною рендера ExpensiveComponent є зміна стейту в компоненті Counter (як описано у прикладі Rendering фази)
І є 2 рішення:
— Вкладення компонента
— Мемоізація (react.memo)

Причина чому не відбуватиметься ререндер:

Тепер ExpensiveComponent стає дочірнім компонентом Application, який не має зміни стейту і відрендерюється лише під час початкового рендерингу. Це призводить до того, що при зміні стану в Counter, ExpensiveComponent не рендериться знову.

Підписатись на коментарі