Привет, друзья!
В данной статье мы с вами разработаем HOC (Higher-Order Component — компонент высшего порядка) и хук (custom hook) для наблюдения за DOM-элементами на странице с помощью Intersection Observer API.
Функционал нашего HOC будет похож на функционал, предоставляемый такими пакетами, как react-lazyload или react-lazy-load. Основное его назначение будет состоять в отложенной (ленивой — lazy) загрузке компонентов. Суть идеи заключается в рендеринге только тех компонентов, которые находятся в области просмотра (viewport — вьюпорт), что может существенно повысить производительность приложения.
Назначением хука будет регистрация пересечения (intersection) наблюдаемого (observable) элемента с областью просмотра (или другим элементом). Этот хук предоставляет очень интересные возможности, парочка из которых будет рассмотрена в соответствующем разделе.
При разработке инструментов я буду применять систему типов, предоставляемую TypeScript.
Если вам это интересно, прошу под кат.
HOC
Предположим, что у нас имеется такой список дорогих для производительности элементов/компонентов:
// тяжелый с точки зрения вычислений элемент списка
const Item = (props: { n: number }) => {
console.log("render");
return <div className="item">{props.n}</div>;
};
// список из 10 элементов
const List = () => (
<div className="list">
{Array.from({ length: 10 }).map((_, i) => (
<Item key={i} n={i + 1} />
))}
</div>
);
function App() {
return <List />;
}
export default App;
С такими стилями:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item {
background-color: deepskyblue;
display: grid;
font-size: 2rem;
height: 50vh;
place-content: center;
}
Элемент занимает половину области просмотра. Следовательно, в области просмотра помещается всего 2 таких элемента. Есть ли смысл рендерить остальные 8? Как предотвратить рендеринг компонентов, находящихся за пределами вьюпорта? Один из вариантов — обернуть элементы в HOC для ленивой загрузки:
import LazyLoad from "./LazyLoad";
const List = () => (
<div className="list">
{Array.from({ length: 10 }).map((_, i) => (
// !
<LazyLoad key={i}>
<Item n={i + 1} />
</LazyLoad>
))}
</div>
);
Реализуем этот HOC.
Вместо компонента, находящегося за пределами вьюпорта, будет рендериться его заменитель (placeholder):
import React from "react";
const Placeholder = (props: {
width?: number | string;
height?: number | string;
}) => {
const width =
props.width && typeof props.width === "string"
? props.width
: props.width + "px";
const height =
props.height && typeof props.height === "string"
? props.height
: props.height + "px";
return <div className="child-placeholder" style={{ width, height }}></div>;
};
Заменитель принимает 2 пропа (props): значения ширины и высоты в виде чисел (пикселей) или строк. Это необходимо для предотвращения сдвигов макета страницы и корректного поведения полосы прокрутки. В идеале, ширина и высота заменителя должны совпадать с шириной и высотой ленивого компонента.
Определяем типы пропов LazyLoad
:
type LazyLoadProps = {
children: JSX.Element;
width?: number | string;
height?: number | string;
once?: boolean;
observerOptions?: IntersectionObserverInit;
};
Компонент принимает ширину (width) и высоту (height) заменителя, дочерний компонент (children), индикатор однократности (once, об этом чуть позже) и настройки для Intersection Observer API
(observerOptions): root
, rootMargin
и threshold
:
const LazyLoad = (props: LazyLoadProps) => {
// todo
};
export default LazyLoad;
Определяем переменную для ссылки на обертку дочернего компонента и состояние для индикатора пересечения:
const childRef = React.useRef<HTMLDivElement>(null);
const [isIntersecting, setIntersecting] = React.useState(false);
Определяем эффект:
React.useEffect(() => {
// потомок
const child = childRef.current as HTMLDivElement;
// наблюдатель
const observer = new IntersectionObserver(([entry]) => {
// обновляем состояние индикатора
setIntersecting(entry.isIntersecting);
// если элемент находится в области просмотра и
// индикатор однократности имеет значение `true`
if (props.once && entry.isIntersecting) {
// прекращаем наблюдение
observer.unobserve(child);
}
}, props.observerOptions);
// начинаем наблюдение
observer.observe(child);
// прекращаем наблюдение при размонтировании компонента
return () => observer.unobserve(child);
}, []);
Зачем нам индикатор однократности (once)? Дело в том, что Intersection Observer
регистрирует не только вход, но и выход элемента из области просмотра. Поэтому компонент, находящийся за пределами вьюпорта, будет отрендерен при входе в область просмотра и снова заменен плейсхолдером при выходе из нее. Это имеет смысл только с целью снижения общей нагрузки на страницу. Обычно, повторная замена отрендеренного компонента плейсхолдером и его последующий повторный рендеринг будут негативно влиять на производительность.
Удаляем обертку при прекращении наблюдения за элементом:
if (props.once && isIntersecting) return props.children;
Наконец, выполняем условный рендеринг:
return (
<div ref={childRef} className="lazy-load-box">
{isIntersecting ? (
props.children
) : (
<Placeholder width={props.width} height={props.height} />
)}
</div>
);
Полный код компонента:
import React from "react";
const Placeholder = (props: {
width?: number | string;
height?: number | string;
}) => {
const width =
props.width && typeof props.width === "string"
? props.width
: props.width + "px";
const height =
props.height && typeof props.height === "string"
? props.height
: props.height + "px";
return <div className="child-placeholder" style={{ width, height }}></div>;
};
type LazyLoadProps = {
children: JSX.Element;
width?: number | string;
height?: number | string;
once?: boolean;
observerOptions?: IntersectionObserverInit;
};
const LazyLoad = (props: LazyLoadProps) => {
const childRef = React.useRef<HTMLDivElement>(null);
const [isIntersecting, setIntersecting] = React.useState(false);
React.useEffect(() => {
const child = childRef.current as HTMLDivElement;
const observer = new IntersectionObserver(([entry]) => {
setIntersecting(entry.isIntersecting);
if (props.once && entry.isIntersecting) {
observer.unobserve(child);
}
}, props.observerOptions);
observer.observe(child);
return () => observer.unobserve(child);
}, []);
if (props.once && isIntersecting) return props.children;
return (
<div ref={childRef} className="lazy-load-box">
{isIntersecting ? (
props.children
) : (
<Placeholder width={props.width} height={props.height} />
)}
</div>
);
};
export default LazyLoad;
Посмотрим, как это работает:
const List = () => (
<div className="list">
{Array.from({ length: 10 }).map((_, i) => (
<LazyLoad key={i} height="50vh" once>
<Item n={i + 1} />
</LazyLoad>
))}
</div>
);
В качестве пропов LazyLoad
передается высота заменителя, равная высоте компонента, и индикатор однократности.
Запускаем приложение и открываем консоль инструментов разработчика в браузере:
Видим сообщения о рендеринге только 2 компонентов Item
(сообщения дублируются инструментами разработчика React
).
Переходим на вкладку Elements
:
Видим, что были отрендерены только 2 первых компонента Item
. Остальные компоненты заменены плейсхолдерами.
При прокрутке списка вместо заменителей рендерятся настоящие компоненты (один раз):
Следует отметить, что данный подход является гораздо более производительным, чем применение метода getBoundingClientRect, который обычно используется в пакетах для ленивой загрузки.
В качестве вызова (challenge) можете попробовать реализовать компонент для одновременного наблюдения за несколькими дочерними компонентами (Intersection Observer API
предоставляет такую возможность).
С HOC разобрались, переходим к хуку.
Хук
Предположим, что у нас на странице имеется такой элемент:
function App() {
return (
<div className="outer">
<div className="inner">
<p>Follow You!</p>
</div>
</div>
);
}
export default App;
С такими стилями:
.outer {
background-color: deepskyblue;
height: 100vh;
margin-top: 101vh;
overflow: hidden;
}
.inner {
display: grid;
min-height: 4rem;
overflow: hidden;
place-content: center;
}
.inner p {
font-size: 4rem;
transition: 0.5s;
}
И мы хотим что-то делать с этим элементом и его содержимым в зависимости от того, находится ли элемент в области просмотра и какую часть вьюпорта он занимает. Что если мы хотим менять высоту внутреннего контейнера (.inner
), прозрачность и масштаб текста (p
)? Это может выглядеть так:
import useOnScreen, { range } from "./useOnScreen";
function App() {
// ссылка на элемент
const outerRef = React.useRef<HTMLDivElement>(null);
// см. ниже
const { ratio, height } = useOnScreen(outerRef, {
threshold: range(0, 1, 0.01, 2),
});
return (
<div className="outer" ref={outerRef}>
<div className="inner" style={{ height }}>
<p
style={{
opacity: ratio / 100 - 0.1,
transform: ratio > 90 ? "scale(1.25)" : "none",
}}
>
Follow You!
</p>
</div>
</div>
);
}
export default App;
Хук useOnScreen
принимает ссылку на элемент и настройки для Intersection Observer API
: в данном случае такой настройкой является threshold
с массивом чисел от 0 до 1, шагом 0,01 и точностью 2 знака после запятой
. Среди прочего, хук возвращает процент (степень) пересечения наблюдаемого элемента с областью просмотра (ratio) и высоту его видимой части (height). Высота видимой части внешнего контейнера (outer) используется для динамического увеличения высоты внутреннего контейнера, а значение процента пересечения — для динамического уменьшения прозрачности и увеличения масштаба текста (p) при почти полном (> 90%) пересечении.
Реализуем этот хук.
Начнем с определения функции для генерации диапазона пороговых значений:
import React from "react";
export const range = (
start: number = 0,
stop: number = 1,
step: number = 0.1,
precision: number = 1
) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) =>
Number((start + i * step).toFixed(precision))
);
Функция принимает начало, конец диапазона, шаг и точность и возвращает соответствующий массив. Например, при вызове range()
(без аргументов) возвращается [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
.
Определяем хук:
const useOnScreen = (
ref: React.RefObject<HTMLElement | null>,
observerOptions?: IntersectionObserverInit
) => {
// todo
};
export default useOnScreen;
Определяем несколько локальных состояний:
// индикатор пересечения
const [isIntersecting, setIntersecting] = React.useState(false);
// степень пересечения
const [ratio, setRatio] = React.useState(0);
// ширина видимой части
const [width, setWidth] = React.useState(0);
// высота видимой части
const [height, setHeight] = React.useState(0);
Определяем эффект:
React.useEffect(() => {
// наблюдаемый элемент
const observable = ref.current as HTMLElement;
// наблюдатель
const observer = new IntersectionObserver(([entry]) => {
// обновляем состояния
setIntersecting(entry.isIntersecting);
setRatio(entry.intersectionRatio);
setWidth(entry.intersectionRect.width);
setHeight(entry.intersectionRect.height);
}, observerOptions);
// начинаем наблюдение
observer.observe(observable);
// прекращаем наблюдение при размонтировании компонента
return () => observer.unobserve(observable);
}, []);
Наконец, округляем, мемоизируем и возвращаем значения:
const memoizedValues = React.useMemo(
() => ({
isIntersecting,
ratio: Math.round(ratio * 100),
width: Math.round(width),
height: Math.round(height),
}),
[isIntersecting, ratio, width, height]
);
return memoizedValues;
Полный код хука:
import React from "react";
export const range = (
start: number = 0,
stop: number = 1,
step: number = 0.1,
precision: number = 1
) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) =>
Number((start + i * step).toFixed(precision))
);
const useOnScreen = (
ref: React.RefObject<HTMLElement | null>,
observerOptions?: IntersectionObserverInit
) => {
const [isIntersecting, setIntersecting] = React.useState(false);
const [ratio, setRatio] = React.useState(0);
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
React.useEffect(() => {
const observable = ref.current as HTMLElement;
const observer = new IntersectionObserver(([entry]) => {
setIntersecting(entry.isIntersecting);
setRatio(entry.intersectionRatio);
setWidth(entry.intersectionRect.width);
setHeight(entry.intersectionRect.height);
}, observerOptions);
observer.observe(observable);
return () => observer.unobserve(observable);
}, []);
const memoizedValues = React.useMemo(
() => ({
isIntersecting,
ratio: Math.round(ratio * 100),
width: Math.round(width),
height: Math.round(height),
}),
[isIntersecting, ratio, width, height]
);
return memoizedValues;
};
export default useOnScreen;
Посмотрим, как это работает:
function App() {
const outerRef = React.useRef<HTMLDivElement>(null);
const { ratio, height } = useOnScreen(outerRef, {
threshold: range(0, 1, 0.01, 2),
});
return (
<div className="outer" ref={outerRef}>
<div className="inner" style={{ height }}>
<p
style={{
opacity: ratio / 100 - 0.1,
transform: ratio > 90 ? "scale(1.25)" : "none",
}}
>
Follow You!
</p>
</div>
</div>
);
}
export default App;
Запускаем приложение и начинаем медленно прокручивать контейнер:
Видим, как во внутреннем контейнере начинает проявляться надпись Follow You!
.
При увеличении высоты видимой части внешнего контейнера увеличивается высота внутреннего контейнера, что позиционирует надпись по центру. Также уменьшается прозрачность и, наконец, при почти полном пересечении увеличивается масштаб текста:
Это очень простой пример использования хука useOnScreen
. Полагаю, вы без труда найдете ему массу полезных и интересных применений.
Поиграть с кодом можно здесь:
Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время. Благодарю за внимание и happy coding!
SiSya
Выглядит крайне тяжело и бессмысленно, как мне кажется.
В частности, при беглом осмотре, глаз зацепился за
В лучшем случае, у того, кто решит применить это, будет 18 версия реакта с автобэтчем. В худшем - мы 4 раза отсюда спровоцируем рендер. Да и тут разумнее было бы применить один сеттер вместо четырёх:
По-простому: лучше один раз вызвать сеттер и за один раз изменить нужные ключи, нежели плодить кучу состояний и в моменте их все изменять.
Как-то так.