Доповідь про оптимізацію та зайців [ukr]
Викиньте ваші useMemo та useCallback під три чорти! Вам розповідали, що вони оптимізують ваш код? Ха! Дідька лисого вони оптимізують! Не вірите? Тоді обов'язково подивіться мою доповідь про те, чому в 99% вам ці гуки мало того, що не потрібні, а й навіть шкодять швидкодії вашого коду.
А ще в доповіді буде заєць.
- Сергій Бабіч, людина надзвичайних здібностей та з абсолютно відсутньою скромністю. Спікер, блоґер, ютубер. Забери у нього його мільярди і що залишиться? Вірно, той самий Бабіч, бо ніяких мільярдів у нього й не було!
- Чотирнадцятий рік у веб розробці, шостий рік у спікерстві. Хтось його знає, хтось почує про нього вперше, хтось досі проклинає той день, коли погодився лишитися з ним на пиво після афтепаті
- Працював як з ReactJS, AngularJS та Angular, так і з маловідомими бібліотеками, назв яких уже й не пам'ятає. Частково через проблеми з пам'яттю. З усього цього любить тільки HTML та CSS
- Веде власний телеграм канал, активно дописує на LinkedIn, знімає відео для ютубу. Пише про життя. Матюкається скрізь
- Веде YouTube канал "Сергій Бабіч та Дивовижний світ веброзробки"
- Telegram, Facebook, LinkedIn, Skillreveal
Транскрипція доповіді
Всім привіт! Так, все, мене чути і видно. Прекрасно. Любі гості, дорога родина, запрошуємо вас до забави. Вже представили, тому перейдемо безпосередньо до теми. Оптимізація і зайці. Чому я взагалі хочу про це поговорити? Останні три роки я пишу на «React», і часто натрапляю на статті, що ми маємо писати оптимізований код, щоб він був супер швидким. Є багато порад і статей про "useMemo" та "useCallback". Сьогодні я хочу поговорити про це.
Почнемо з інтерактиву. Підніміть руки, хто пише на «React » і розуміє, про що ми говоримо? Для онлайн-аудиторії теж. Добре. Запитаємо аудиторію: для чого потрібні хуки? Для чого нам потрібні useMemo та useCallback? Тримайте руки, будь ласка. Для уникнення додаткового рендерингу компонентів. Дуже добре. Ще ідеї? Для мемоїзації важких розрахунків, наприклад. Також гарна ідея. Тепер перейдемо до наступного слайда. Що таке ці хуки?
Далі розглядаємо саме реалізації useMemo та useCallback в реакті. Під капотом відбувається багато оптимізації та чорної магії. Ми дивимося на код реалізації. Дуже важливо помітити, що ці хуки кожен раз викликають цей код під капотом. Тепер питання: чи настільки це виглядає як оптимізація? Спробуємо згадати. Перед тим, як розмовляти про оптимізацію, давайте розглянемо такого персонажа. Ви вгадали? Це передчасний оптимізатор. Тепер переходимо до прикладу коду, який ми часто бачимо в статтях, де радять використовувати useMemo та useCallback для оптимізації коду.
Давайте розглянемо, що тут відбувається. Є якийсь useValue, якийсь інший value, і ми його типомемоізуємо в callback в useMemo і рендеримо. По ідеї, згідно всіх порад, це виглядає як оптимізація, щоб уникнути перерахування суми, і завжди отримувати одне й те саме значення. Тепер, згадуючи той код з репозиторію React, давайте розглянемо та підрахуємо, що відбувається. Ми створюємо функцію і масив залежностей, який ми передаємо в useMemo. Ми його викликаємо. Всередині ми читаємо поточний Fiber State, пов'язаний із цим компонентом, цим хуком і цим станом. Перевіряємо залежності на рівність. Повертаємо збережене значення, якщо масив залежностей збігається, або ж, якщо не збігається, тоді ми перераховуємо, зберігаємо, мемоізуємо, повертаємо, і лише після цього передаємо значення в компонент і вже рендеримо. Тепер давайте порівняємо цей приклад із випадком, коли просто обчислюємо значення прямо в компоненті.
Що відбувається? Додаємо два числа і рендеримо. Набагато простіше. Те саме стосується й випадку з usecallback'ом. Добре, можливо, кольорова схема не найкраща. Я говорив, вимкнемо світло. Що тут відбувається? Ми створюємо, за допомогою двох useCallback'ів, мемоізовані, оптимізовані, ті самі callback'и, які ми передамо в компоненти. Що відбувається тут? Є кнопочка button, в якій є onclick, і відбувається щось, наприклад, show model. Є також масив, в якому ми рендеримо елементи, і передаємо туди onclick відповідно до власного елемента масиву. Подивімося, що ж відбувається в usecallback'і. Ми створюємо функцію і масив залежностей. Викликаємо його, знову читаємо стейт Fiber, щоб визначити, чи це той самий callback чи ні. Перевіряємо залежності, повертаємо збережений інстанс функції, або ж, якщо він має бути оновлений, то ми зберігаємо новий інстанс і повертаємо його. Все це ми передаємо в компонент, і тепер подивімося на той самий приклад, але без використання useCallback, просто оголошуємо функцію. Тут є деяка різниця, чи не так?
Тепер повернемося до питання про ререндер, яке ми обговорювали. Ось це написане, але я вам озвучу його словами. Коротше кажучи, React-компоненти ререндеряться в будь-якому випадку, якщо вони не мемоізовані за допомогою мемо. Навіть якщо у них немає проксів, і компонент є доволі простим, він все одно буде ререндеритися, якщо його батьківський компонент ререндериться. Проте є певний нюанс. Це питання того, що ми вважаємо ререндером. Багато людей плутають терміни ререндера в React і безпосереднього рендера в DOM. Всі кажуть: "О, ререндер – це погано, давайте робити менше ререндерів."
Коротше кажучи, ререндер в React – це просто виконання функції компонента. Все. Всю складність порівнянь у віртуальному DOM і подібне вже виконує алгоритм React, і вони дуже оптимізовані. Якщо ви захочете розібратися більше, зверніть увагу на репозиторій React. Ці процеси під капотом оптимізовані. Таким чином, навіть якщо ваш компонент ререндериться, це не завжди призводить до безпосереднього рендера в DOM. Є певна оптимізація.
Ререндер функції компонента, якщо цей компонент написаний грамотно, майже нічого не вартує, бо він все одно буде виконуватися. Так що, коли ми говоримо, наприклад, про useCallback, в більшості випадків він може бути не потрібен, оскільки велика кількість компонентів, які ви передаєте, може не бути мемоізованою через мемо. Це означає, що ці компоненти все одно будуть ререндеритися в будь-якому випадку.
Сенс має, наприклад, мемоізувати постійний референс на той самий callback через useCallback, оскільки це може уникнути ререндера у випадку, коли інші частини компонента оновлюються. Проте, важливо пам'ятати, що це має сенс лише тоді, коли ви дійсно мемоізуєте багато компонентів. Отже, ось цей випадок стосується використання useCallback і useMemo. І тепер ми перейдемо до причин, чому вони можуть не завжди бути корисними.
Отже, запам'ятайте, що якщо ви мемоізуєте, скажімо, за допомогою useMemo або useCallback, це повинно мати сенс. Але це має сенс лише в тому випадку, якщо той компонент, в який ви передаєте це все, дійсно розрахований на те, що ви передаєте однакові референси. Проте більшість компонентів з популярних бібліотек не мемоізовані. Так що не морочте собі голову тим, чим не варто.
Тепер щодо garbage collector. Ми створюємо нові інстанси під час кожного рендеру, і це збільшує обсяг пам'яті. Але не переймайтеся цим занадто сильно. Garbage collector справиться з цим. Важливо розуміти, що він видалить все, що створено всередині компонента, якщо воно має замкнуті посилання. Однак, оскільки ми не замикатимемо, створюючи нові інстанси callback'ів, garbage collector все одно справиться з їхнім видаленням. Так що не турбуйтеся про це.
Тепер перейдемо до питання часу. Який час я маю на увазі? Давайте спробуємо визначити час, який ви зараз бачите. Яка ваша оцінка в мілісекундах? 300? Ти близький. 100 мілісекунд? 500 мілісекунд? Та подібні варіанти. Проблема в тому, що людина фактично не помічає різниці менше 0,1 секунди. Таким чином, різниця між 200 і 300 мілісекундами може бути помічена досить обмежено. Різниця в сприйнятті затримки становить близько 400 мілісекунд. Запам'ятайте ці цифри?
Якщо ви уперті і усвідомлено оптимізуєте свій код, щоб ваш масив з трьох елементів оброблявся не за 10 мілісекунд, а за 1 мілісекунду, то ви фактично втратите більше часу на це, адже користувач не помітить цієї різниці в продуктивності. Особливо, якщо це застосовано до невеликого списку. Так що важливо витрачати час на оптимізацію розумно і не упарюватися в напряму, де це необгрунтовано.
Тепер декілька слів щодо самої оптимізації. UseMemo і useCallback не завжди є оптимізацією. Пам'ятайте це. Я навіть бачив у деяких статтях досить неправильні твердження, що, мовляв, вони настільки чарівні, що, якщо ви мемоізуєте через них, то вони навіть не створюють нові інстанси функцій. Це абсолютно неправда. Кожен раз, коли ви викликаєте useMemo або useCallback, вони створюють новий інстанс функції. Важливо зрозуміти, що виконання кожної операції у програмуванні - це час. Навіть у високорівневих мовах програмування, таких як JavaScript, час все ще вимірюється в мілісекундах, і це може вплинути на продуктивність.
Отже, давайте розглянемо це детальніше. useMemo і useCallback не призначені для оптимізації в сенсі швидкості виконання. Вони надають сталий референс, що дозволяє уникати зайвих рендерів та покращує продуктивність. Важливо розуміти, що вони не є універсальними магічними рішеннями, а лише частиною стратегії оптимізації. Також важливо зауважити, що навіть якщо ви використовуєте useMemo і useCallback, це не гарантує кешування. React може вирішити перекешувати їх, і вам треба бути готовими до такого рішення в алгоритмі.
Щодо оптимізації, рекомендується уникати важких обчислень прямо в компонентах. Замість цього обробляйте дані десь за межами компонента, наприклад, у стейт-менеджменті або сервісах. Це може суттєво зменшити витрати на обчислення, особливо при використанні примітивів, які вже порівнюються за значенням. Якщо ви намагаєтеся оптимізувати за допомогою useMemo, слід уникати складних обчислень всередині нього. Обчислення в useMemo можуть займати більше часу, ніж простий виклик колбеку в useMemo, і в такому випадку виграш у продуктивності може бути обмеженим.
Нарешті, важливо уникати зайвих рендерів компонентів. Якщо ви використовуєте useMemo і useCallback, переконайтеся, що вони дійсно приносять вигоду та використовуються належним чином. Узагальнюючи, оптимізація повинна бути розсудливою та спрямованою на конкретні проблеми, уникаючи зайвих обчислень в компонентах та використовуючи useMemo і useCallback тільки тоді, коли це справді потрібно.
Зайві рендери дуже часто не пов'язані з мемоізацією. Зайві рендери часто викликаються, не батьківським компонентом у дочірньому компоненті, а компонент сам собі в ногу стріляє. Ну, точніше, ви йому стріляєте в ногу. Як можна повернути зайвий рендер в реакції? Скажіть, будь ласка, хтось. Ні? Та навіть не зайвий. Просто можна не подумати. У вас в залежності кожен раз на кожен рендер буде нове значення або новий референс. І дуже часто я стикався з тим, що там воно навіть не треба, але воно є в масиві. Або якось так, воно там не мемоізовано, мовляв, це залежність, воно змінюється. Або, наприклад, ви забули взагалі передати масив залежностей в UseEffect. І у вас там асинхронний запит. Тобто ви можете просто самі вести компонент в істерику. Дуже простими і не складними рухами.
Відповідно, зменшити кількість рендерів можна, в першу чергу, розглядаючи вище, що у вас там відбувається. Чому він так постійно ререндериться? Швидше за все, якщо ви пробіжитеся і викинете деякі зайві залежності, наприклад, ті, які постійно змінюються, то ви зменшите кількість ререндерів набагато. Тому що, як ми пам'ятаємо в React, будь-який чіх, будь-яка зміна значення, все це викличе ререндер, щоб отримати новий результат виконання функції.
Оце стратегія оптимізації, яка набагато краще, ніж вигадувати якісь кишування і подібні речі. Тобто ви маєте шукати проблему в корені, не боротьба з наслідками. Бо коли ми говоримо про useMama, useCallback і те, що дочірній компонент ререндериться при зміні проксів, це вже боротьба з наслідками. Бо фактично у вас відбувається багато ререндерів, але ви боретеся не з причиною, чому вони відбуваються. Бо у вас там батьківський компонент, він на кожне чихання реагує, і ви починаєте лікувати симптоми. Спробуйте дивитися на корінь.
І відверто кажучи, сьогодні я перевершив сам себе. Це найкоротший мій виступ за всю мою кар'єру. Отже, висновки, вони дуже короткі. UseMemory, UseCallback не врятують. Оптимізуйте лише, коли є проблеми. Ось про що був оптимізатор. Можливо, я забув розповісти про це, але, як завжди, вставив слайд, тому розкажу зараз. Це також дуже поширена проблема. Коли розробник починає писати оптимізований код, він часто старається, пишучи код, який насправді нічого не робить. Тобто він оптимізує наперед. А раптом, я пробачаю за мову, але от коли раптом станеться, от тоді шукайте причину. Бо вона може бути непередбачуваною. І ви просто нагородите собі оптимізований код, який виконується набагато довше, ніж ваш не оптимізований код. І ви почнете дивитися і шукати проблеми навіть не там. Бо у мене в житті є таке золоте правило: розв'язувати проблеми лише тоді, коли вони виникають. Справжньо, навіть не тільки в роботі, а в житті взагалі. От є проблема - розв'язуємо. Нема проблеми - чому тратити на неї час? От і все. І тут так само. Пишіть код так, щоб він працював.
Щоб він виконував поставлену перед ним і перед вами задачу. Якщо ви помітили, що він починає працювати не оптимально, десь тормозить, тоді вже почніть дивитися, що пішло не так. Де можна його трошки підшаманити, зробити там трошки оптимізованим, щось переграти і таке інше. Чому? Тому що, знову ж таки, повертаючись, якщо ви почнете передчасно оптимізувати, швидше за все, ви нічого не оптимізуєте, а коли проблема все ж таки виникне, ви почнете шукати її корінь не там, де вона буде. Тому що ви будете вважати, що ви цей випадок вже вирішили передчасно.
React, насправді, це така дуже класна бібліотека, яка пробачає дуже багато. Прямо дуже багато. Відповідно, всі ці референси, рендери, це все вбудовано в його дизайн. React працює саме так. Це його природа. Не йдіть проти природи. Тому розв'язуйте проблеми, коли вони виникають. І також важливий момент, який, насправді, ці пункти дали мені важкий досвід і власні помилки. Якщо, як визначаємо ми з вами, компонент все одно перерендерюється. Якщо він не змемоізований, явно. Тому гратися тут з оптимізацією, проксів і так далі, може бути втратою часу.
Структура даних і вартість рендера. Це перші ваші кейси, які ви маєте перевірити, чи у вас все нормально. А вже потім, якщо там все нормально, тоді можна почати дивитися, що там відбувається далі. Тобто, не ускладнюйте собі життя і дійте від простого. І коли я говорю про оптимізацію даних і подібні речі, особливо початківцям, як вас, це, правда, може не бути питань на співбесідах, і слава богам за це. Прочитайте, зокрема, про різноманітні структури даних, що вони передбачають, як можна їх трансформувати та використовувати.
Наприклад, у мене був кейс на одному з проєктів, де треба було рендерити просто такий стратегічний список однотипних елементів. І всі вони були інтерактивні. І не лише вони були інтерактивні, але також мали оцінки від одного до п'яти, і треба було рахувати середнє значення для кожної секції, а потім середнє значення всіх цих оцінок загалом. І, звісно, мій перший підхід полягав у використанні масиву. Сідання в масиві, коротше кажучи, там, де я групував їх, і так далі. Проблеми з продуктивністю були значущі.
Очевидно, коли ви рендерите 200 інтерактивних елементів, які постійно десь щось тягнуть у ререндерах, сторінці стає важко. Що мені допомогло? Мені допомогла банальна нормалізація даних. Якщо хтось не знає, швидко гугліть це. Тому що, коли ваші дані нормалізовані, доступ до одного елемента спрощується з O(N) до O(1). І, звісно, це прискорює роботу. Далі були інші оптимізації, розрахунки і так далі, і так далі, що дозволили мені досягти того, що при перевірці в React DevTools замість того, щоб відбувалася дискотека на всій сторінці при кожному кліку, оновлювалася виключно ця кнопка і ці значення, а також умовні блоки, в яких виводились обчислені середні значення. Три елементи на сторінку оновлювалися при кожному кліку, незалежно від кількості інших елементів на сторінці. Так, довелося, звісно, переписати UI, щоб воно коректно працювало з цим. Але, знову ж таки, зміна структури даних значно покращила продуктивність. До того, коли у мене були масиви з використанням useCallback та інші методи, і я мемоізував майже кожен компонент, це не допомагало. Ось так, тому намагайтеся вирішувати проблеми в корені, а не в їх наслідках. Ну і, в принципі, це все