useEffect против useLayoutEffect

useEffect против useLayoutEffect

-Понимание времени рендеринга React для улучшения качества экрана-

1. Проблема, обнаруженная в процессе работы: мерцание экрана

В этом году, взяв на себя задачи по фронтенд-операциям проекта, я упорядочил сложную структуру компонентов, делая её более читаемой, и улучшил логику обработки состояния и расчета стилей. Большинство улучшений ближе к рефакторингу для повышения удобства обслуживания, чем к изменению функциональности, но в процессе просмотра кода также были обнаружены проблемы с качеством UI, которые могут быть непосредственно ощутимы пользователем.

Наиболее заметной проблемой было неестественное мерцание определенного компонента в короткий момент, когда он впервые отображался на экране. Функционально он работал нормально, но экран сначала был нарисован неправильного размера, а затем сразу же отрегулирован до правильного размера, что казалось пользователю, что макет «прыгает». Это явление обычно называют мерцанием или сдвигом макета.

Проблемный компонент должен был рассчитывать свою ширину в соответствии с шириной браузера. Например, использовал половину ширины браузера в качестве ширины компонента или располагал внутренние элементы в зависимости от фактического размера родительского элемента. Однако в существующей реализации этот расчет выполнялся внутри useEffect, поэтому сначала браузер отрисовывал экран, а затем выполнялась логика корректировки ширины.

В результате пользователь увидел как неверную ширину начального рендеринга, так и ширину после коррекции. Хотя это было очень короткое время, визуально это ощущалось как мигание, и особенно в областях, где размер экрана часто меняется или важен адаптивный макет, это может привести к ухудшению качества.

2. Основное отличие useEffect и useLayoutEffect

useEffect и useLayoutEffect в React - это хуки, предназначенные для выполнения определенных побочных эффектов после рендеринга. Поскольку они используются очень схоже, на первый взгляд разница между двумя хуками может не быть заметной. Однако есть четкая разница во времени выполнения, которая является важным критерием для определения, будет ли экран мигать.

useEffect выполняется асинхронно после того, как браузер действительно отрисует экран. То есть, React сначала применяет изменения виртуального DOM к реальному DOM, а затем, после того как браузер завершает рисование (Painting), выполняется useEffect. Поэтому он подходит для задач, не требующих блокировки первой отрисовки экрана, таких как выборка данных, отправка логов, подписка и регистрация слушателей событий.

С другой стороны, useLayoutEffect выполняется синхронно сразу после того, как изменения DOM завершены, но до того, как браузер начнет рисовать экран. Это означает, что если в useLayoutEffect измеряется размер или положение DOM и состояние изменяется в зависимости от этого результата, пользователи не увидят экран до исправления, а только финально откорректированный экран.

Поэтому для задач, где размещение должно быть точно установлено до отображения на экране, таких как измерение размера элементов DOM, корректировка положения прокрутки, вычисление положения подсказки или размещение модальных окон или выпадающих списков, useLayoutEffect более подходит. Напротив, если такие задачи обрабатываются в useEffect, пользователи могут на короткое время увидеть разницу между первоначальным экраном и исправленным экраном, что может вызвать эффект мерцания.

Категория

useEffect

useLayoutEffect

Момент выполнения

После того как браузер отрисует экран

После изменения DOM, до того как экран будет отрисован

Способ исполнения

Исполнение асинхронно

Синхронное выполнение

Основное назначение

Запрос данных, подписка на события, обработка журналов

Измерение DOM, расчет размеров, корректировка положения

Замечание

На начальном экране возможны мерцания

При чрезмерном использовании возможна задержка рендеринга

3. Проблемы с существующим кодом

Существующий код использовал состояние, переопределяемое на основе ширины браузера в useEffect после монтирования компонента. На первый взгляд код кажется простым и не имеет проблем, но при учете реального порядка рендеринга возникает мерцание.

На этапе первоначального рендеринга экран рисуется один раз с начальными значениями useState. Затем после завершения рисования браузером выполняется useEffect и вызывается setWidth. Когда состояние изменяется, React выполняет повторный рендеринг, и только тогда окончательная ширина отражается. То есть пользователь видит как экран до коррекции, так и после.

Кроме того, существующий код недостаточно реагировал на событие изменения размера браузера. На момент первоначальной отрисовки ширина рассчитывается, но если при изменении размера браузера обработка повторного расчета ширины компонента не осуществляется в том же соотношении, то это снижает согласованность как адаптивного пользовательского интерфейса. Поэтому возникла необходимость улучшить его, добавив слушатель события изменения размера вместе с изменением хуков, чтобы реагировать на изменения размера экрана.

Пример существующего кода выглядит следующим образом.

export const ExampleComponent = () => {  
  const [width, setWidth] = useState(window.innerWidth / 2);  

useEffect(() => {    
  setWidth(window.innerWidth / 2);  
}, []);  

return <div style={{ width: width, height: 100 }} />;
};

4. Улучшенный код с использованием useLayoutEffect

Направление улучшения было ясным. Поскольку необходимо было завершить расчёт ширины до того, как экран будет нарисован, я заменил useEffect на useLayoutEffect. useLayoutEffect выполняется сразу после изменения DOM, перед тем как браузер фактически отрисует экран, что позволяет уменьшить проблему с неправильным размером начального экрана.

Кроме того, я зарегистрировал событие изменения размера, чтобы ширина компонента также пересчитывалась каждый раз, когда ширина браузера меняется. В этом случае обязательно нужно удалить обработчик событий, когда компонент размонтируется. В противном случае могут возникнуть проблемы с утечкой памяти или дублированием выполнения из-за оставшихся ненужных обработчиков событий.

Таким образом, в улучшенном коде я четко обрабатывал регистрацию и удаление, используя addEventListener и removeEventListener вместе.

Измененный код выглядит следующим образом.

import { useLayoutEffect, useState } from 'react';
  
export const ExampleComponent = () => {  
  const [width, setWidth] = useState(() => window.innerWidth / 2);
  
  useLayoutEffect(() => {    
    const handleResize = () => {      
      setWidth(window.innerWidth / 2);
    }; 
  
    handleResize();
    window.addEventListener('resize', handleResize);
  
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return l&tdiv style={{ width: width, height: 100 }} />;
;};

В этом коде метод handleResize выполняется один раз сразу после монтирования компонента, чтобы скорректировать состояние на основе текущей ширины браузера. Затем, каждый раз, когда происходит событие изменения размера, выполняется тот же расчет. В функции очистки удаляются зарегистрированные обработчики событий, чтобы обеспечить безопасную обработку в соответствии с жизненным циклом компонента.

Передача начального значения в виде функции, например, useState(() => window.innerWidth / 2), является небольшим улучшением. Передача функции в useState называется ленивой инициализацией, и такая реализация позволяет выполнить расчет только один раз на начальном рендеринге, что уменьшает ненужные вычисления. Однако в среде серверного рендеринга объект window может отсутствовать, поэтому требуется отдельная логика защиты.

5. Когда следует использовать useLayoutEffect?

useLayoutEffect эффективен в снижении мерцания экрана, но это не тот хук, который всегда следует использовать вместо useEffect. Поскольку useLayoutEffect может задерживать рендеринг браузера, его чрезмерное использование может фактически ухудшить производительность первоначального рендеринга.

Поэтому лучше иметь четкие критерии. Если выполнение не вызывает проблем с пользовательским опытом после отрисовки экрана, то использование useEffect является более подходящим. С другой стороны, если необходимо измерить DOM или отрегулировать положение до отрисовки экрана, и предварительно отригованный экран не должен быть виден пользователю, можно рассмотреть использование useLayoutEffect.

6. Эффекты и итоги после применения

После применения useLayoutEffect исчезла неестественная мерцание в начальном рендеринге, а ширина компонентов плавно синхронизировалась при изменении размера браузера. Хотя функциональность самой по себе имела небольшое изменение, качество изображения, видимого пользователям, определенно улучшилось.

Я снова почувствовал, что такие хуки, как useEffect, которые используются часто, нужно выбирать более внимательно. Не всегда знакомый код является подходящим, и необходимо понимать поток рендеринга React и тайминг отрисовки в браузере, чтобы создавать лучший пользовательский интерфейс.

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

В конце концов, это улучшение стало не просто пониманием различий между useEffect и useLayoutEffect с точки зрения синтаксиса, но и стало поводом оценить их в контексте реального пользовательского опыта. Я почувствовал, что в будущем, используя хуки, нужно будет учитывать 'когда они выполняются', 'какое влияние они оказывают на экран', 'можно ли пользователю показать состояние до коррекции', чтобы сделать более подходящий выбор.

7. Вопросы, которые были учтены при практическом применении

При применении useLayoutEffect также необходимо проверить, является ли это кодом, который выполняется только в клиентской среде. Если вы напрямую используете такие объекты, как window, document, ResizeObserver, может возникнуть ошибка в среде серверного рендеринга.

Кроме того, часто более точно рассчитывать размеры на основе фактического размера родительского контейнера, в котором установлен компонент, а не исходя из полной ширины браузера. В этом случае события изменения размера окна могут быть недостаточными, и использование ResizeObserver может позволить более точно отслеживать изменения размеров конкретных элементов DOM. Например, это может помочь в ситуациях, когда браузер остается того же размера, но изменяется только область компонента, такие как открытие/закрытие боковой панели, переключение вкладок или изменение родительского макета.

Однако применение ResizeObserver на слишком большом количестве элементов без разбора может привести к нагрузке на производительность. Поэтому лучше применять его только к ключевым элементам, для которых действительно требуется измерение размера, и избегать вызова setState, если результаты вычислений совпадают с предыдущими значениями, чтобы сократить ненужные повторные рендеры. Это может показаться незначительным, но такие мелкие оптимизации на рабочем экране накапливаются и влияют на общий пользовательский опыт.

8. Резюме: Чёткие критерии выбора хуков

Если подвести итоги этого случая, выбор между useEffect и useLayoutEffect зависит не от того, какой ху́к используется чаще, а от времени рендеринга браузера. Если проблем с первоначально отображаемым значением нет, и последующая обработка может происходить асинхронно, то useEffect подходит. Напротив, если неправильный макет может попасть в руки пользователя, следует рассмотреть useLayoutEffect. Движения экрана или мерцание не являются функциональными ошибками, но пользователи могут воспринять это как нестабильный интерфейс. Поэтому в фронтенд-разработке важно понимать не только поток данных, но и порядок рендеринга, время рендеринга браузера и момент замеров DOM.

Таким образом, это улучшение было не просто заменой useEffect на useLayoutEffect, а переосмыслением того, в каком порядке компоненты React рендерятся в реальном браузере и как они выглядят для пользователей.

В будущем, при использовании хуков, следует принимать более обоснованные решения на основе времени выполнения данного кода и влияния на пользовательский интерфейс, а не по привычке.

9. Критерии проверки для предотвращения повторных случаев

Чтобы подобные проблемы не повторялись, в процессе рефакторинга я установил несколько критериев для проверки компонентов.

Первый критерий - проверить, изменяет ли изменение состояния фактический размер или положение на экране. Если это просто хранение данных или внешняя связь, то useEffect будет достаточно, но если результат изменения состояния непосредственно влияет на компоновку, то необходимо пересмотреть время выполнения.

Второй момент — это проверить, нормально ли, что начальные значения рендеринга отображаются пользователю. Если состояние до исправления выглядит неудобно для пользователя, например, всплывающая подсказка в неправильном месте, неразвернутый аккордеон или выпадающее меню, выступающее за границы экрана, то потребуется useLayoutEffect или предварительное управление на основе CSS.

Третий момент — это сначала определить, нужно ли вычислять значение с помощью JavaScript или можно решить только с помощью CSS. Вычисления на JavaScript стоит использовать только в необходимых случаях, а саму компоновку лучше всего проектировать так, чтобы по возможности ею занимался CSS.

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

10. Заключение

useEffect и useLayoutEffect очень похожи с точки зрения синтаксиса, но выполняются в совершенно разные моменты в потоке рендеринга браузера. В случае, подобном данному, где сначала отображается экран, а затем происходит коррекция, useEffect может показаться естественным выбором, но на самом деле это может вызвать мерцание в пользовательском опыте.

useLayoutEffect является инструментом для решения этой проблемы, но из-за своей способности блокировать рендеринг его следует использовать ограниченно только в необходимых местах. В конце концов, важно не просто предпочитать один конкретный хуки, а оценить, должно ли данная логика быть завершена до отображения на экране.

В этом опыте я осознал, что даже маленькие явления в пользовательском интерфейсе связаны с порядком рендеринга React, временем покраски браузера и способом измерения DOM.

Я стремлюсь улучшать фронтенд-код, учитывая не только написание работающего кода, но и поток экранов, которые на самом деле видит пользователь.

Май

Site footer