14. useLayoutEffect
TypeScript: useLayoutEffect — Когда важен каждый пиксель
Заголовок раздела «TypeScript: useLayoutEffect — Когда важен каждый пиксель»Привет, друзья! Яша на связи. Сегодня мы погрузимся в одну из самых тонких, но мощных фишек React — хук useLayoutEffect. Если вы когда-либо сталкивались с “мерцанием” или странным поведением элементов интерфейса при их динамическом изменении, то этот хук — ваш спаситель. Он позволяет нам буквально “схватить” DOM за горло до того, как браузер успеет что-либо нарисовать, обеспечивая идеальную синхронизацию ваших данных с визуальным представлением.
🤔 useLayoutEffect: Зачем он нужен и как работает?
Заголовок раздела «🤔 useLayoutEffect: Зачем он нужен и как работает?»Представьте, что вы — дизайнер интерьера, и вам нужно расставить мебель в комнате.
useEffect— это как если бы вы сначала показали клиенту пустую комнату, потом занесли мебель, и только потом клиент видит результат. Если вам нужно что-то сразу передвинуть после того, как мебель занесли (например, подвинуть диван на 5 см), клиент может на мгновение увидеть его в неправильном месте.useLayoutEffect— это ваш личный ассистент, который расставляет мебель, и если нужно что-то изменить, он делает это сразу же, до того, как клиент вообще войдет в комнату. Клиент видит только конечный, правильный результат, без всяких “промежуточных” состояний.
Технически, useLayoutEffect очень похож на useEffect по своей сигнатуре. Основное отличие в моменте его выполнения:
useEffect: Выполняется асинхронно после того, как браузер обновил DOM и отрисовал изменения. Это безопасно для большинства случаев и не блокирует отрисовку.useLayoutEffect: Выполняется синхронно после того, как React обновил DOM, но до того, как браузер отрисует эти изменения на экране. Это означает, что вы можете читать размеры DOM-элементов, изменять их, и эти изменения будут учтены до первой отрисовки, предотвращая визуальные “мерцания” (flicker).
Когда использовать useLayoutEffect?
- Измерение и позиционирование DOM-элементов: Когда вам нужно получить размеры или позицию элемента после его рендера, но до того, как пользователь увидит его на экране. Например, для позиционирования всплывающих подсказок, модальных окон.
- Синхронные обновления состояния: Если обновление состояния зависит от DOM и должно произойти немедленно, чтобы избежать мерцания.
- Взаимодействие с нативными API или сторонними библиотеками: Которые напрямую манипулируют DOM и требуют точного знания его состояния.
🏠 Базовый пример: Избегаем “мерцания” при репозиционировании
Заголовок раздела «🏠 Базовый пример: Избегаем “мерцания” при репозиционировании»Представьте, что у нас есть компонент, который должен быть смещен на 100px вправо, если его ширина превышает 200px. Если мы используем useEffect, мы можем увидеть элемент на мгновение в исходной позиции, а затем он “прыгнет” в новую.
import React, { useRef, useState, useEffect, useLayoutEffect } from 'react';
// Типы для пропсов компонентаinterface RepositionedBoxProps { initialText: string;}
const RepositionedBox: React.FC<RepositionedBoxProps> = ({ initialText }) => { const boxRef = useRef<HTMLDivElement>(null); // Ссылка на DOM-элемент const [offsetLeft, setOffsetLeft] = useState<number>(0); const [text, setText] = useState<string>(initialText);
// useEffect - может вызвать мерцание // useEffect(() => { // if (boxRef.current) { // const width = boxRef.current.offsetWidth; // console.log(`useEffect: Ширина элемента ${width}px. Смещаем, если нужно.`); // if (width > 200 && offsetLeft === 0) { // Проверяем, чтобы не было бесконечного цикла // setOffsetLeft(100); // Смещаем на 100px // } else if (width <= 200 && offsetLeft !== 0) { // setOffsetLeft(0); // } // } // }, [text, offsetLeft]); // Зависимость от text, чтобы пересчитывать при его изменении
// useLayoutEffect - предотвращает мерцание useLayoutEffect(() => { if (boxRef.current) { const width = boxRef.current.offsetWidth; console.log(`useLayoutEffect: Ширина элемента ${width}px. Смещаем, если нужно.`); if (width > 200 && offsetLeft === 0) { setOffsetLeft(100); // Смещаем на 100px } else if (width <= 200 && offsetLeft !== 0) { setOffsetLeft(0); } } }, [text, offsetLeft]); // Зависимость от text, чтобы пересчитывать при его изменении
return ( <div> <h3>Пример с RepositionedBox ({offsetLeft ? 'Смещен' : 'На месте'})</h3> <div ref={boxRef} style={{ border: '2px solid purple', padding: '10px', margin: '20px', display: 'inline-block', // Чтобы ширина была по контенту transform: `translateX(${offsetLeft}px)`, // Применяем смещение transition: 'transform 0.3s ease-out', // Для наглядности }} > <p>Содержимое: {text}</p> <button onClick={() => setText(prev => prev.length < 50 ? prev + ' очень длинный текст' : 'Короткий текст')}> Изменить текст </button> </div> <p> <small>Нажмите кнопку, чтобы увидеть изменение ширины и смещения. Попробуйте раскомментировать `useEffect` и закомментировать `useLayoutEffect`, чтобы увидеть разницу.</small> </p> </div> );};
// Пример использования// <RepositionedBox initialText="Привет, мир!" />// <RepositionedBox initialText="Это очень длинный текст, который должен превысить 200px в ширину и сместить элемент." />В этом примере, если вы быстро кликнете на кнопку “Изменить текст”, используя useEffect, вы можете заметить, как элемент сначала отрисовывается в одном месте, а затем “перепрыгивает”. С useLayoutEffect этот “прыжок” будет незаметен, так как все изменения произойдут до первого paint’а.
📐 Продвинутый пример: Адаптивный компонент “Read More”
Заголовок раздела «📐 Продвинутый пример: Адаптивный компонент “Read More”»Часто возникает задача: если текст слишком длинный, отобразить только часть и кнопку “Читать далее”. useLayoutEffect идеально подходит для этого, так как мы должны измерить высоту текста до его отображения.
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
interface ExpandableTextProps { content: string; maxHeightPx?: number; // Максимальная высота в пикселях до скрытия}
const ExpandableText: React.FC<ExpandableTextProps> = ({ content, maxHeightPx = 100 }) => { const textRef = useRef<HTMLDivElement>(null); const [isExpanded, setIsExpanded] = useState<boolean>(false); const [showButton, setShowButton] = useState<boolean>(false);
// Используем useLayoutEffect для измерения высоты до отрисовки useLayoutEffect(() => { if (textRef.current) { // Измеряем полную высоту текста const currentHeight = textRef.current.scrollHeight; // Если полная высота больше максимальной, показываем кнопку setShowButton(currentHeight > maxHeightPx);
// Важный момент: если текст стал короче и кнопка не нужна, // мы должны сбросить состояние isExpanded if (currentHeight <= maxHeightPx && isExpanded) { setIsExpanded(false); } } }, [content, maxHeightPx, isExpanded]); // Пересчитываем при изменении контента или макс. высоты
const toggleExpand = useCallback(() => { setIsExpanded(prev => !prev); }, []);
return ( <div style={{ margin: '20px', border: '1px dashed gray', padding: '10px' }}> <h3>Адаптивный текст "Читать далее"</h3> <div ref={textRef} style={{ maxHeight: isExpanded ? 'none' : `${maxHeightPx}px`, // Устанавливаем высоту overflow: 'hidden', // Обрезаем содержимое transition: 'max-height 0.3s ease-out', // Плавное открытие/закрытие }} > <p>{content}</p> </div> {showButton && ( <button onClick={toggleExpand} style={{ marginTop: '10px' }}> {isExpanded ? 'Свернуть' : 'Читать далее'} </button> )} </div> );};
// Пример использованияconst longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;const shortText = `Короткий текст, который не должен быть свернут.`;
// <ExpandableText content={longText} maxHeightPx={100} />// <ExpandableText content={shortText} maxHeightPx={100} />Здесь useLayoutEffect гарантирует, что мы точно знаем scrollHeight элемента до того, как пользователь увидит компонент. Таким образом, кнопка “Читать далее” появляется или скрывается без какого-либо визуального сдвига или мерцания.
🐛 Типичные ошибки и их решения
Заголовок раздела «🐛 Типичные ошибки и их решения»-
Блокировка отрисовки: Поскольку
useLayoutEffectвыполняется синхронно, любая длительная операция внутри него будет блокировать отрисовку страницы.- Ошибка: Выполнение сложного алгоритма или большого цикла внутри
useLayoutEffect. - Решение: Перенесите ресурсоемкие вычисления в
useEffectили используйтеrequestAnimationFrameдля более плавных анимаций, если они не требуют немедленного изменения DOM.useLayoutEffectдолжен быть максимально “легким”.
// 👎 Плохо:useLayoutEffect(() => {// Это заблокирует отрисовку, если items.length очень большойconst computedValue = items.map(item => performExpensiveCalculation(item)).reduce((acc, val) => acc + val, 0);setResult(computedValue);}, [items]);// 👍 Хорошо:useEffect(() => { // Используем useEffect для дорогостоящих вычисленийconst computedValue = items.map(item => performExpensiveCalculation(item)).reduce((acc, val) => acc + val, 0);setResult(computedValue);}, [items]); - Ошибка: Выполнение сложного алгоритма или большого цикла внутри
-
Бесконечные циклы: Как и
useEffect,useLayoutEffectможет вызвать бесконечный цикл, если вы обновляете состояние, от которого он зависит, без должного контроля.- Ошибка: Обновление состояния в
useLayoutEffectбез проверки условия, которое может изменить зависимости хука, вызывая его повторный запуск. - Решение: Всегда используйте функциональные обновления состояния (
setSomething(prev => ...)) или включайте в массив зависимостей только те значения, которые действительно должны вызывать пересчет, и убедитесь, что обновление состояния внутриuseLayoutEffectпроисходит только при необходимости.
// 👎 Плохо: Может вызвать бесконечный цикл, если myValue всегда меняется при каждом рендере// useLayoutEffect(() => {// if (ref.current) {// // ... делаем что-то// setMyValue(ref.current.offsetWidth); // Если offsetWidth меняется при каждом рендере// }// }, [myValue]); // Зависимость от myValue, который мы обновляем// 👍 Хорошо: Проверяем условие и используем функциональное обновлениеuseLayoutEffect(() => {if (boxRef.current) {const newOffset = boxRef.current.offsetWidth > 200 ? 100 : 0;// Обновляем состояние только если оно действительно изменилосьsetOffsetLeft(prevOffset => (prevOffset !== newOffset ? newOffset : prevOffset));}}, [text]); // Зависимость только от text, который инициирует изменение ширины - Ошибка: Обновление состояния в
### 🎯 Практика
Заголовок раздела «### 🎯 Практика»Время закрепить знания на практике!
-
Автоматический скролл в чате: Создайте компонент
ChatWindow, который принимает массив строк (messages: string[]). Отображайте сообщения в контейнере. ИспользуйтеuseLayoutEffect, чтобы окно чата автоматически скроллилось до последнего сообщения при добавлении новых, без видимого “прыжка”.- Подсказка: используйте
useRefдля ссылки на контейнер сообщений иscrollHeight/scrollTopдля управления прокруткой.
- Подсказка: используйте
-
Динамический заголовок с усечением: Разработайте компонент
TruncatedHeader, который принимает длинный текстtitle: string. Если ширина контейнера заголовка (например,div) становится меньше 300px, текст должен усекаться и добавляться многоточие (...). Изменение должно происходить плавно и без мерцания при изменении размера окна браузера.- Подсказка: Вам понадобится
useRefдля элемента заголовка иResizeObserverдля отслеживания изменений размера, который вы будете подключать/отключать вuseLayoutEffectилиuseEffect. Но само применение усечения, зависящее от размеров, лучше делать черезuseLayoutEffect.
- Подсказка: Вам понадобится
-
Адаптивный тултип (всплывающая подсказка): Создайте компонент
Tooltip(который рендерится внутриButtonилиHoverAreaкомпонента). Когда пользователь наводит курсор на элемент, появляется тултип.Tooltipдолжен позиционироваться относительно элемента, на который навели, и гарантировать, что он не выходит за пределы видимой области экрана (viewport). Пересчет позиции должен происходить вuseLayoutEffectпри первом появлении тултипа или при скролле/ресайзе страницы.- Подсказка:
getBoundingClientRect()элемента-триггера иwindow.innerWidth/window.innerHeightдля viewport. ИспользуйтеuseStateдля хранения позиции тултипа (top,left).
- Подсказка:
### 💡 Совет
Заголовок раздела «### 💡 Совет»Используйте useLayoutEffect только тогда, когда вам абсолютно необходимо синхронно взаимодействовать с DOM после его изменения React’ом, но до того, как браузер перерисует страницу. Во всех остальных случаях useEffect является предпочтительным, поскольку он не блокирует отрисовку и обеспечивает более отзывчивый интерфейс. Если вы сомневаетесь, начните с useEffect. Если заметите визуальное “мерцание” или некорректное поведение, тогда попробуйте useLayoutEffect. Помните: с большой силой приходит большая ответственность за производительность!
Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: