5,000 mục trong một hàng — không cần ảo hóa
ScrollMenu và vẫn phản hồi — cuộn overflow gốc làm việc di chuyển, IntersectionObserver làm việc theo dõi, và React hầu như không làm gì.5,000 items, no virtualization
Kéo ray hoặc phân trang bằng mũi tên — mỗi thẻ trong 5,000 thẻ là một nút DOM thật; không gì bị windowing.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
Nơi công việc không xảy ra
Việc cuộn không bao giờ đi vào React. Ray là một container overflow đúng nghĩa: con lăn và cảm ứng cuộn nó một cách gốc, và dây kéo chỉ gán vào scrollContainer.current.scrollLeft — không state, không render lại theo frame. Khả năng hiển thị là một thể hiện IntersectionObserver duy nhất theo dõi cả 5,000 phần tử mục; callback đến theo lô, và chỉ các component đã đăng ký bằng useIsVisible cập nhật khi chính mục của chúng lật. Không có phép tính cuộn theo-mục ở bất kỳ đâu.
Story điều chỉnh gì
Card được bọc trong React.memo với một bộ so sánh trên selected và title, nên chọn một thẻ không reconcile 4,999 thẻ còn lại. Chỉ số hiển thị đi qua useDeferredValue: sau một cú nhảy trang, hàng trăm mục lật trạng thái cùng lúc, và trì hoãn giữ cơn bùng phát đó khỏi đường tới hạn của tương tác gây ra nó. noPolyfill={true} khiến các lần cuộn lập trình dùng scrollIntoView của chính trình duyệt thay vì polyfill cuộn mượt. Kéo là cùng pattern DragDealer như ví dụ mouse-drag.
Sự đánh đổi mà trang này thừa nhận
Ray demo phía trên không được render trên server: 5,000 thẻ serialize thành khoảng một megabyte HTML, nên ray chỉ mount ở client phía sau một placeholder khớp chiều cao và không có layout shift. Đó là hóa đơn thật ở kích thước này — trình duyệt xử lý 5,000 nút sống thoải mái, nhưng gửi chúng làm payload SSR là một quyết định riêng. Ở đâu đó trong hàng chục nghìn nút, bộ nhớ và chi phí render ban đầu cũng đuổi kịp; đó là nơi windowing hết là tùy chọn.
Ghi chú
- DOM cho 5,000 thẻ được xây một lần, khi mount —
React.memobiến các lần render sau của cha thành no-op cho mỗi thẻ. - Mũi tên phân trang gần một viewport mỗi lần, nên đi qua cả ray bằng mũi tên chậm theo thiết kế — cú hất kéo hay cú nhảy
scrollToItemhợp với quy mô này hơn. - Các mũi tên vẫn chạy trên
useIsVisible('first')vàuseIsVisible('last')— cùng cơ chế observer của menu mười mục, ở 500 lần số mục.
Nguồn đầy đủ
Đầy đủ và sẵn sàng sao chép-dán — đây là file chính xác phía sau phiên bản Storybook có thể chỉnh sửa trực tiếp.
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();
}
}