Слышали ли вы когда-нибудь о виртуализации? Знаете ли вы, как она работает под капотом?
Если это не так или если вы хотите освежить свои знания, давайте сделаем это!
Примечание: В этой статье я рассматриваю только реализацию при работе с фиксированным размером элементов. Вертикальная и горизонтальная верстка будут обработаны.
Во второй статье мы рассмотрим, как управлять при переменной высоте элемента.Вся логика будет вынесена в JS-класс, чтобы вы могли использовать его в своей любимой библиотеке. В конце я сделаю это с помощью React.
Что такое виртуализация?
Виртуализация — это факт помещения в DOM только тех элементов, которые отображаются на экране пользователя. На самом деле, есть некоторые элементы до и после, чтобы обеспечить плавную прокрутку.
Зачем мы это делаем?
Если вы поместите слишком много элементов в DOM, вы можете иметь некоторые проблемы с производительностью и плохой пользовательский опыт из-за:
- большого количества узлов DOM в памяти
- вычисления стиля и затрат на рисование
Кто использует это?
Это техника, которая действительно используется в индустрии, часто в сочетании с бесконечной прокруткой. Например, ее используют такие сайты, как Twitter, Instagram или Reddit.
Примечание:
dev.to
, похоже, не использует его, что вызывает некоторые проблемы, когда мы много прокручиваем страницу. Например, когда у нас 650 статей, мы можем увидеть некоторые проблемы:
Статьи с фиксированной высотой
Для первой статьи мы собираемся сделать виртуализацию с элементами, имеющими одинаковую высоту.
Прежде чем «углубиться» в реализацию, важно понять стратегию, которую мы будем использовать.
Как вы можете видеть на рисунке, есть несколько вещей, которые необходимо понять:
- контейнер: это элемент, который будет содержать список.
- видимые элементы: это элементы, которые в данный момент видны пользователю. На изображении они выделены синим цветом.
- невидимые элементы, присутствующие в DOM: это дополнительные элементы, которые в настоящее время не видны пользователю, но присутствуют в DOM для плавной прокрутки.
- невидимые элементы: это элементы, которые находятся в списке, но не присутствуют в DOM. Они будут присутствовать в DOM, если они находятся в диапазоне элементов, которые нужно поместить в DOM, поскольку находятся в двух предыдущих категориях, при прокрутке.
Получение первого и последнего индекса
Давайте сделаем немного математики, простой, не бойтесь, чтобы вычислить индекс первого видимого элемента:
// Rounding down if the first item is half displayed
// for example
const firstVisibleIndex = Math.floor(scrollOffset / itemSize);
Видите, ничего сложного. Давайте сделаем то же самое, чтобы получить последний индекс:
// Rounding down if the last item is half displayed
// for example
const lastVisibleIndex = Math.floor(
(scrollOffset + window.height) / itemSize
);
Дополнительные элементы
Теперь поговорим о дополнительных элементах. Как мы видели ранее, чаще всего мы добавляем дополнительные элементы до и после отображаемых. Это позволит улучшить плавность прокрутки и не отображать большой белый экран при быстрой прокрутке.
Итак, первый индекс данного элемента:
// We do not want to have negative index
// So let's take the max of the calculation and 0
const firstPresentIndex = Math.max(
firstVisibleIndex - extraItems,
0
);
И последний индекс данного элемента:
// We do not want to have an index superior to
// the maximum item number
// So let's take the min of the calculation and `itemNumber`
const lastPresentIndex = Math.min(
lastVisibleIndex + extraItems,
itemNumber
);
Позиционирование элементов
Нам нужно будет вручную разместить представленные элементы в элементе списка. Решение, которое я выбрал, это установить элемент списка с position: relative
, а элементы с position: absolute
.
Если вы не привыкли к позиционированию relative / absolute
, вот небольшое изображение для пояснения:
Для нашей виртуализации элементы, которые находятся в абсолютной
позиции, позиционируются относительно элемента списка (который имеет относительную
позицию) благодаря top
или left
css свойствам в функции макета списка.
Затем список будет прокручиваться внутри контейнера благодаря overflow: auto
.
Первое, что нужно сделать, это установить стиль списка:
const getListStyle = () => {
const listSize = this.itemNumber * this.itemSize;
if (this.isVerticalLayout()) {
// When dealing with vertical layout
// it's the height that we configure
return {
height: listSize,
position: "relative",
};
}
// Otherwise it's the width
return {
width: listSize,
position: "relative",
};
};
А теперь сделаем метод для получения стиля элемента по его индексу:
const getItemStyle = (itemIndex) => {
const itemPosition = itemIndex * this.itemSize;
if (this.isVerticalLayout()) {
// When dealing with vertical layout
// the item is positioned with the
// `top` property
return {
height: this.itemSize,
width: "100%",
position: "absolute",
top: itemPosition,
};
}
// Otherwise with the `left` property
return {
height: "100%",
width: this.itemSize,
position: "absolute",
left: itemPosition,
};
};
Полная реализация в классе
Давайте реализуем все, что мы видели ранее, в классе FixedVirtualization
:
class FixedVirtualization {
constructor({
containerHeight,
containerWidth,
itemNumber,
itemSize,
extraItems,
layout,
}) {
this.containerHeight = containerHeight;
this.containerWidth = containerWidth;
this.itemNumber = itemNumber;
this.itemSize = itemSize;
this.extraItems = extraItems;
this.layout = layout;
}
isVerticalLayout = () => {
return this.layout === "vertical";
};
getListStyle = () => {
const listSize = this.itemNumber * this.itemSize;
if (this.isVerticalLayout()) {
// When dealing with vertical layout
// it's the height that we configure
return {
height: listSize,
position: "relative",
};
}
// Otherwise it's the width
return {
width: listSize,
position: "relative",
};
};
getItemStyle = (itemIndex) => {
const itemPosition = itemIndex * this.itemSize;
if (this.isVerticalLayout()) {
// When dealing with vertical layout
// the item is positioned with the
// `top` property
return {
height: this.itemSize,
width: "100%",
position: "absolute",
top: itemPosition,
};
}
// Otherwise with the `left` property
return {
height: "100%",
width: this.itemSize,
position: "absolute",
left: itemPosition,
};
};
getFirstItemIndex = (scrollOffset) => {
return Math.max(
Math.floor(scrollOffset / this.itemSize) -
this.extraItems,
0
);
};
getLastItemIndex = (scrollOffset) => {
return Math.min(
Math.floor(
(scrollOffset + this.containerHeight) /
this.itemSize
) + this.extraItems,
this.itemNumber
);
};
}
И вот мы на месте! Остался всего один шаг до того, как мы получим что-то полностью функциональное.
Обнаружение прокрутки
Теперь нам нужно следить за тем, когда пользователь прокручивает контейнер.
Давайте просто добавим слушателя на событие scroll
нашего элемента контейнера:
// Just register an event listener on `scroll` event
// In React will be done inside a `useEffect` or
// directly with an `onScroll` prop
const onScroll = () => {
// Update a state or retrigger rendering of items
// In React will be done with a `useState` to set the offset
};
container.addEventListener("scroll", onScroll);
// You will probably have to add a `removeEventListener`
Давайте поиграем
Теперь, когда мы извлекли логику виртуализации в FixedVirtualization
и знаем, что нам нужно повторно отображать наши элементы при прокрутке в элементе контейнера, давайте сделаем это в React.
API, который я решил сделать, это выставить компонент List
со следующими реквизитами:
Вот как вы будете его использовать:
function App() {
return (
<List
containerHeight={400}
containerWidth={600}
itemNumber={1000}
itemHeight={50}
renderItem={({ index, style }) => (
<div
key={index}
style={{
...style,
// Just put a border to see each item
border: "1px solid black",
}}
>
{index}
</div>
)}
/>
);
}
А вот реализация компонента List
:
function List({
renderItem,
containerHeight,
containerWidth,
itemNumber,
itemSize,
layout = "vertical",
}) {
const [fixedVirtualization] = useState(
() =>
new FixedVirtualization({
containerHeight,
itemNumber,
itemSize,
extraItems: 10,
layout,
})
);
// We put the offset in a state
// And get the right items to display at each render
// and their styles
const [scrollOffset, setScrollOffset] = useState(0);
const firstIndex =
fixedVirtualization.getFirstItemIndex(scrollOffset);
const lastIndex =
fixedVirtualization.getLastItemIndex(scrollOffset);
// Let's create an array of the items
// which are present in the DOM
const items = [];
for (
let index = firstIndex;
index <= lastIndex;
index++
) {
items.push(
renderItem({
index,
style: fixedVirtualization.getItemStyle(index),
})
);
}
// Let's create an `onScroll` callback
// We `useCallback` it only to have a stable ref for
// the throttling which is for performance reasons
const onScroll = useCallback(
throttle(250, (e) => {
const { scrollTop, scrollLeft } = e.target;
setScrollOffset(
layout === "vertical" ? scrollTop : scrollLeft
);
}),
[]
);
return (
<div
style={{
height: containerHeight,
width: containerWidth,
overflow: "auto",
}}
onScroll={onScroll}
>
<div style={fixedVirtualization.getListStyle()}>
{items}
</div>
</div>
);
}
Примечание: Здесь я использую библиотеку
throttle-debounce
.
Заключение
Вы можете поиграть с полным кодом в этой песочнице:
В следующей статье вы увидите, как управлять, когда у нас есть предметы с разной высотой.
Не стесняйтесь комментировать, и если вы хотите увидеть больше, вы можете следить за мной на Twitch или зайти на мой сайт.