Виртуализация с элементами фиксированного размера (часть 1)

Слышали ли вы когда-нибудь о виртуализации? Знаете ли вы, как она работает под капотом?

Если это не так или если вы хотите освежить свои знания, давайте сделаем это!

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

Вся логика будет вынесена в 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 или зайти на мой сайт.

Оцените статью
Procodings.ru
Добавить комментарий