Головна / Статті / Яка справжня вартість відображення React у основному потоці

Яка справжня вартість відображення React у основному потоці

Render проти commit у React: чому невидима робота з render все ще конкурує з введенням на основній сутичці, і що насправді рятує мемоізація.

1665 слів

Функція “render” у React сама по собі не змінює того, що відображається у браузері. Компонент, який виконується один раз, та той самий компонент, який виконується сотню разів, можуть здаватися ідентичними для користувача додатку. Вони виглядають ідентично тому, що браузер оновлюється лише тоді, коли на етапі збереження даних відбувається запис у DOM, а сама функція render ніколи не гарантує такого запису. То чому ж багато порад щодо продуктивності React спрямовані на уникнення виконання функцій render — useCallback (збереження ідентичності функції між її виконаннями), useMemo (збереження обчисленого значення між виконаннями), React.memo (пропуск виконання дочірнього компонента, якщо його параметри не змінилися), а також скорочення кількості оновлень стану?

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

Якщо функція render ніколи не впливає на браузер, на що ж вона насправді витрачає ресурси?

Дві фази, які ховаються всередині одного відтворення

Люди часто використовують термін „відтворення“ для позначення всього циклу оновлення. React ділить цей цикл на дві фази з різними завданнями. Одна фаза створює опис наступного інтерфейсу користувача. Інша фаза вирішує, чи має цей опис перетворитися на реальні зміни в DOM.

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

Що відбувається під час першої фази, тієї, яка завжди виконується, коли React планує оновлення?

Розбір фази відтворення

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

Потім React оцінює JSX після оператора return. JSX (<div>...</div> синтаксис) — це не HTML, і він ніколи сам по собі не потрапляє до браузера. Під час збирання проекту Babel або компілятор TypeScript перетворює його на виклики функцій — раніше це був React.createElement, а зараз частіше сучасний помічник jsx(). Саме ці виклики створюють опис компонента.

Результат зазвичай називають віртуальним DOM; внутрішня назва React — дерево елементів. Це звичайний об’єкт JavaScript, який описує бажаний інтерфейс користувача — це не справжній HTML, а не живий вузол DOM.

Розгляньмо невеликий компонент:

function Greeting({ name }) {
  const message = `Hello, ${name}`;
  return (
    <div>
      <h3>{message}</h3>
    </div>
  );
}

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

{
  "type": "div",
  "props": {
    "children": {
      "type": "h3",
      "props": { "children": "Hello, Akshat" }
    }
  }
}

Цей об’єкт є оновленим віртуальним DOM-деревом Greeting. Структура залишилась в пам’яті — не відбулося жодних змін у DOM, його відрисовування чи форматування. Що буде подальше з цим деревом?

Розбір етапу здійснення змін

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

Повернімося до Greeting та припустимо, що name змінюється з одного рядка на інший. У попередньому дереві старий текст привітання знаходився в елементі h3; у новому дереві — оновлений текст. Структура залишається незмінною — div, який оточує h3 — тому процес узгодження фіксує лише одну відмінність: цей текстовий вузол. На етапі коміту оновлюється лише цей текст; решта дерева залишається недоторканою.

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

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

Якщо фаза відображення може завершитися без залишення видимих слідів, скільки ресурсів це забрало?

Фаза відображення може виконуватися та все одно нічого не змінювати

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

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

Що це за щось інше?

Основний потік не переймається тим, чи є робота видимою

Цим «щось» є основна потік JavaScript: спільна черга, яка виконує завдання по одному. Цей самий потік виконує ваш JS-код, обчислює макет та обробляє події введення — кліки, прокрутку, натискання клавіш.

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

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

Один марний процес відображення може коштувати частку мілісекунди. Коли це стає помітним для користувача?

Чому етап відображення все одно має значення

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

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const products = useProducts(); // 200 items
  return (
      <div>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
        />
        <ProductList products={products} />
      </div>
    );
  }

function ProductList({ products }) {
  return (
    <div>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </div>
  );
}

Введіть один символ у поле пошуку, і searchTerm оновиться. Очікується, що панель керування знову запуститься — її результати змінилися. ProductList, а також усі 200 компонентів ProductRow під ним також знову запустяться, не тому, що змінилися їхні параметри (натискання клавіші не пов’язане з даними продуктів), а тому, що дочірні елементи перерендеруються, коли це роблять батьківські елементи, якщо тільки щось не зупинить цю послідовність.

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

Це сценарій, який орієнтовані на обробку функції useMemo, useCallback та React.memo — то що ж вони насправді захищають?

Що насправді захищають useMemo, useCallback та React.memo

React.memo оточує компонент та пропускає його етап відрендерування, коли нові пропси є поверхнево ідентичними попереднім (=== для кожного пропса). Оточивши ProductRow цим механізмом, натискання клавіш на панелі керування більше не спричиняє 200 викликів відрендерування; React порівнює пропси один раз на кожен рядок та зупиняється, коли посилання product залишається незмінним.

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

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

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

Коли це має значення для користувача продукту?

Витрати на відрендерування — це те, що справді відчуває користувач

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

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

Люди рідко згадують про ту «тиху» частину. Кількість менших процесів відрендерування ніколи не була кінцевою метою. Мета — залишити достатньо спільного бюджету у приблизно 16,7 мс для операцій збереження змін та обробки вхідних даних, які користувач може помітити.