5 000 éléments dans une rangée — pas besoin de virtualisation

Le conseil habituel avec quelques centaines d’éléments est de recourir à la virtualisation. Cet exemple rend 5 000 vrais nœuds DOM dans un seul ScrollMenu et reste réactif — l’overflow natif fait le mouvement, un IntersectionObserver fait l’observation, et React ne fait presque rien.

5,000 items, no virtualization

Faites glisser le rail ou paginez avec les flèches — chacune des 5 000 cartes est un vrai nœud DOM ; rien n’est fenêtré.

Là où le travail ne se produit pas

Le défilement n’entre jamais dans React. Le rail est un vrai conteneur overflow : la molette et le toucher le font défiler nativement, et le câblage du glissement n’assigne que scrollContainer.current.scrollLeft — pas d’état, pas de re-rendus par frame. La visibilité est une seule instance d’IntersectionObserver observant les 5 000 éléments ; les callbacks arrivent par lots, et seuls les composants abonnés via useIsVisible se mettent à jour quand leur propre élément bascule. Il n’y a aucun calcul de défilement par élément nulle part.

Ce que la story ajuste

Card est enveloppée dans React.memo avec un comparateur sur selected et title, donc sélectionner une carte ne réconcilie pas les 4 999 autres. La lecture de visibilité passe par useDeferredValue : après un saut de page, des centaines d’éléments basculent d’état d’un coup, et différer garde cette rafale hors du chemin critique de l’interaction qui l’a causée. noPolyfill={true} fait que les défilements programmatiques utilisent le scrollIntoView du navigateur au lieu du polyfill de défilement fluide. Le glissement est le même motif DragDealer que l’exemple mouse-drag.

Le compromis que cette page admet

Le rail de démo ci-dessus n’est pas rendu côté serveur : 5 000 cartes sérialisent en à peu près un mégaoctet de HTML, donc le rail monte côté client uniquement, derrière un espace réservé de même hauteur, et il n’y a pas de décalage de mise en page. C’est la vraie facture à cette taille — le navigateur gère 5 000 nœuds vivants confortablement, mais les expédier comme charge SSR est une décision séparée. Quelque part dans les dizaines de milliers de nœuds, la mémoire et le coût du rendu initial rattrapent aussi ; c’est là que le fenêtrage cesse d’être optionnel.

Notes

Source complète

Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.

Performance.source.tsx
import 'react-horizontal-scrolling-menu/dist/styles.css';

import styled from '@emotion/styled';
import React from 'react';
import {
  type publicApiType,
  ScrollMenu,
  VisibilityContext,
} from 'react-horizontal-scrolling-menu';

const ITEMS = 5000;

export function Performance() {
  const [items] = React.useState(() => getItems(ITEMS));
  const [selected, setSelected] = React.useState<string[]>([]);

  // NOTE: for drag by mouse
  const dragState = React.useRef(new DragDealer());

  const handleDrag = React.useCallback(
    ({ scrollContainer }: publicApiType) =>
      (ev: React.MouseEvent) =>
        dragState.current.dragMove(ev, (posDiff) => {
          if (scrollContainer.current) {
            scrollContainer.current.scrollLeft += posDiff;
          }
        }),
    [],
  );

  const onMouseDown = React.useCallback(
    () => dragState.current.dragStart,
    [dragState],
  );
  const onMouseUp = React.useCallback(
    () => dragState.current.dragStop,
    [dragState],
  );

  const handleItemClick = React.useCallback((itemId: string) => {
    if (dragState.current.dragging) {
      return false;
    }

    setSelected((currentSelected: string[]) =>
      currentSelected.includes(itemId)
        ? currentSelected.filter((el) => el !== itemId)
        : currentSelected.concat(itemId),
    );
  }, []);

  return (
    <>
      <div style={{ marginBottom: '50px' }}>{ITEMS} items and still fast!</div>
      <div onMouseLeave={() => dragState.current.dragStop()}>
        <ScrollMenu
          LeftArrow={LeftArrow}
          RightArrow={RightArrow}
          onMouseDown={onMouseDown}
          onMouseUp={onMouseUp}
          onMouseMove={handleDrag}
          onWheel={onWheel}
          // better for performance
          noPolyfill={true}
        >
          {items.map(({ id }) => (
            <Card
              title={id}
              itemId={id} // NOTE: itemId is required for track items
              key={id}
              onClick={handleItemClick}
              selected={selected.includes(id)}
            />
          ))}
        </ScrollMenu>
      </div>
    </>
  );
}
export default Performance;

class DragDealer {
  clicked: boolean;
  dragging: boolean;
  position: number;

  constructor() {
    this.clicked = false;
    this.dragging = false;
    this.position = 0;
  }

  public dragStart = (ev: React.MouseEvent) => {
    this.position = ev.clientX;
    this.clicked = true;
  };

  public dragStop = () => {
    window.requestAnimationFrame(() => {
      this.dragging = false;
      this.clicked = false;
    });
  };

  public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
    const newDiff = this.position - ev.clientX;

    const movedEnough = Math.abs(newDiff) > 5;

    if (this.clicked && movedEnough) {
      this.dragging = true;
    }

    if (this.dragging && movedEnough) {
      this.position = ev.clientX;
      cb(newDiff);
    }
  };
}

const LeftArrow = React.memo(() => {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isFirstItemVisible = visibility.useIsVisible('first', true);

  return (
    <Arrow
      disabled={isFirstItemVisible}
      onClick={() => visibility.scrollPrev()}
      testId="left-arrow"
    >
      Left
    </Arrow>
  );
});

const RightArrow = React.memo(() => {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isLastItemVisible = visibility.useIsVisible('last', false);

  return (
    <Arrow
      disabled={isLastItemVisible}
      onClick={() => visibility.scrollNext()}
      testId="right-arrow"
    >
      Right
    </Arrow>
  );
});

function Arrow({
  children,
  disabled,
  onClick,
  testId,
}: {
  children: React.ReactNode;
  disabled: boolean;
  onClick: VoidFunction;
  testId: string;
}) {
  return (
    <ArrowButton disabled={disabled} onClick={onClick} data-testid={testId}>
      {children}
    </ArrowButton>
  );
}
const ArrowButton = styled('button')((props) => ({
  cursor: 'pointer',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  marginBottom: '2px',
  opacity: props.disabled ? '0' : '1',
  userSelect: 'none',
  borderRadius: '6px',
  borderWidth: '1px',
}));

const Card = React.memo(
  ({
    onClick,
    selected,
    title,
    itemId,
  }: {
    onClick: (itemId: string) => void;
    selected: boolean;
    title: string;
    itemId: string;
  }) => {
    const visibility = React.useContext<publicApiType>(VisibilityContext);
    const isVisible = visibility.useIsVisible(itemId, true);
    const isVisibleDeffered = React.useDeferredValue(isVisible);
    const handleClick = React.useCallback(
      () => onClick(itemId),
      [itemId, onClick],
    );
    const onKeyDown = React.useCallback(
      (ev: React.KeyboardEvent) => {
        ev.code === 'Enter' && handleClick();
      },
      [handleClick],
    );

    return (
      <CardBody
        data-cy={itemId}
        onClick={handleClick}
        onKeyDown={onKeyDown}
        data-testid="card"
        role="button"
        tabIndex={0}
        className="card"
        visible={isVisibleDeffered}
        selected={selected}
      >
        <div className="header">
          <div>{title}</div>
          <div className="visible">
            visible: {JSON.stringify(isVisibleDeffered)}
          </div>
          <div className="selected">selected: {JSON.stringify(!!selected)}</div>
        </div>
        <div className="background" />
      </CardBody>
    );
  },
  (prevProps, nextProps) =>
    prevProps.selected === nextProps.selected &&
    prevProps.title === nextProps.title,
);

const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
  (props) => ({
    border: '1px solid',
    display: 'inline-block',
    margin: '0 10px',
    width: '160px',
    userSelect: 'none',
    borderRadius: '8px',
    overflow: 'hidden',

    '& .header': {
      backgroundColor: 'white',
    },

    '& .visible': {
      backgroundColor: props.visible ? 'transparent' : 'gray',
    },

    '& .background': {
      backgroundColor: props.selected ? 'green' : 'bisque',
      height: '200px',
    },
  }),
);

const getId = (index: number) => `${'test'}${index}`;

const getItems = (count: number) =>
  Array(count)
    .fill(0)
    .map((_, ind) => ({ id: getId(ind) }));

function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
  // NOTE: no good standart way to distinguish touchpad scrolling gestures
  // but can assume that gesture will affect X axis, mouse scroll only Y axis
  // of if deltaY too small probably is it touchpad
  const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;

  if (isThouchpad) {
    ev.stopPropagation();
    return;
  }

  if (ev.deltaY < 0) {
    apiObj.scrollNext();
  } else {
    apiObj.scrollPrev();
  }
}

Exemples associés

Tous les exemples (21)