← Усі статті

Компілятор React: прибрали половину useMemo і лишили потрібні

У панелі на React 19 було 214 викликів useMemo і useCallback. Компілятор зняв більшу частину; два краї все одно лишилися ручними.

Компілятор React: прибрали половину useMemo і лишили потрібні
Зміст

Коротко

У панелі на React 19 за два роки назбиралося 214 викликів useMemo і useCallback.

Автор перерахував їх пошуком по коду в п’ятницю й довго дивився на цифру. Ніхто не сідав і не планував стільки обгорток: вони наросли самі.

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

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

Часті оновлення від перетягування — окрема історія. Це ніколи не було задачею запам’ятовування.

Що сталося

Застосунок — панель на React 19. У ній велика віртуалізована таблиця, кілька графіків і повзунок шкали часу, який тягають мишею.

Команда продуктова, не команда продуктивності. Обгортки ставили «про всяк випадок», коли щось уже сіпалося.

Повільна таблиця отримала useMemo. Графік перестав блимати після useCallback. На рецензії просили обгорнути значення, бо щоразу приходили нові вхідні дані.

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

Увімкнення компілятора виявилося нудним, і автор вважає це найкращим відгуком.

У проєкті на Next.js це прапорець у налаштуваннях. У Vite — один плагін збірки, babel-plugin-react-compiler.

Одразу на весь застосунок прапорець не ставили. Спершу режим позначки: компілятор чіпав лише компоненти з директивою "use memo", ділянка за ділянкою.

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

За два тижні перейшли на звичайний режим. Компоненти, яким ще не довіряли, позначили "use no memo" і лишили жити по-старому поруч із уже скомпільованими.

Найкориснішим був крок до цього. Прогнали npx react-compiler-healthcheck@latest і правила компілятора в eslint-plugin-react-hooks.

Перевірка сказала, скільки компонентів компілятор узагалі бере. Лінтер пояснив, чому решту він пропускає. Список, за словами автора, був незручний.

Типовий список замовлень до чистки фільтрував і сортував усередині двох useMemo, обгортав вибір рядка в useCallback і експорт у React.memo.

Після чистки лишилися звичайні .filter() і .sort() по копії. Обробник іде в рядок як є.

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

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

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

Якщо щось просіло, видно, яке видалення винне. Велике прибирання одразу по всій панелі такого сліду не лишає.

Зі 214 викликів лишилося близько 80.

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

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

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

Чому це важливо

Чотирикратного прискорення не було. Автор прямо пише, що на панелі, яку вже обгортали вручну, такій цифрі не повірив би.

Міряли три речі і не публікували дрібнішої таблиці результатів.

У профілювальнику React DevTools ганяли один і той самий записаний сценарій до і після кожної зміни: скільки разів інтерфейс фіксував оновлення і скільки це тривало.

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

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

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

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

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

Збірка трохи виросла: на пару відсотків у основного фрагмента. Окремого розкладу по екранах автор не наводить.

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

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

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

Чесний підсумок замірів такий. Де були обережні — прискорення немає. Де не були — воно є. Коду стало менше.

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

На стенді виплили два місця, які компілятор не замінює.

Перше — тихий пропуск. Компілятор не падає, якщо компонент порушує правила React. Він пропускає його і йде далі.

У таблиці лідерів масив балів сортували просто на місці. .sort() змінює вихідний масив, а тут це вхідні дані.

Таке безпечно не оптимізувати, тому компонент лишився як був. Правка — один рядок: сортувати копію, [...scores].sort(...).

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

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

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

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

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

useMemo став не всередині графіка, а там, де гарантії компілятора ще не почалися. Де вони закінчуються, знову починається ручна робота.

Окремо від цих двох випадків — повзунок шкали часу. Він рухає вказівник на кожну подію pointermove.

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

У стан React ішло понад 60 оновлень на секунду. Жодна обгортка не скасовує сам факт, що дерево дізнається про кожен зсув пальця.

Жест винесли зі стану. Змінну CSS рухають із requestAnimationFrame, а React дізнається про значення лише коли жест закінчився.

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

Часте введення ніколи не було задачею запам’ятовування. Це третій сюжет статті, не третя поломка компілятора.

На практиці

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

Спершу перевірка готовності і список зауважень лінтера. Це і є черга на перенесення, а не привід одразу вичистити файл.

Далі — дрібні зміни із заміром, а не одне велике прибирання. Велике видалення одразу по всій панелі ні з чим порівняти, якщо замір просів.

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

Порядок, який він сам обрав би на наявній кодовій базі:

  1. Нічого не видаляти. Увімкнути компілятор, прогнати перевірку готовності і закрити зауваження лінтера.
  2. Вмикати поступово: режим позначки "use memo" або явна відмова "use no memo" на ризикованих компонентах.
  3. Знімати замір до і після кожного видалення. Немає зсуву — видаляти. Є відкат — це межа або порушення правил.
  4. Ручне запам’ятовування лишити на краях: нескомпільований батько і чужі компоненти, яким потрібне те саме посилання на об’єкт.
  5. Часте введення прибрати зі стану React. Рух вказівника, прокрутка і перетягування живуть у посиланні на значення, у змінній CSS або в requestAnimationFrame.

Варто тримати короткий список меж. Де скомпільований код зустрічається з нескомпільованим, ви знову в ручній зоні.

Підсумок

Зі 214 ручних обгорток лишилося близько 80, і в кожної вцілілої є причина.

Поки зайві useMemo і useCallback стояли поруч, ці причини було не розгледіти. Після чистки вони видні одразу.

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

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

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

Коментарі

Завантаження коментарів…