性能优化 - 虚拟滚动 🚀
前言
在项目开发中,我遇到了一个棘手的性能问题:我们的产品是一个无限滚动加载图片展示的应用,随着用户不断滚动加载更多图片,页面中的DOM节点数量呈指数级增长。当滚动加载的图片越来越多时,页面性能急剧下降,用户体验变得糟糕。
这种情况下,虚拟滚动成为了我们的救星。那么,什么是虚拟滚动?虚拟滚动又该如何实现呢?让我们深入探讨这个前端性能优化的利器。
什么是虚拟滚动? 💡
虚拟滚动是一种针对大量数据列表的高效渲染优化技术。简单来说,它的核心思想是只渲染用户当前可见区域的DOM元素,将不在可视范围内的元素从DOM树中移除,从而大幅提升页面性能。
🎯 核心理念:眼不见,心不烦 —— 看不到的内容就不渲染
为什么需要虚拟滚动? 🤔
当页面中存在过多DOM节点时,会带来以下问题:
- 🐌 内存占用过高:每个DOM节点都会消耗内存资源
- ⏰ 渲染时间过长:浏览器需要计算更多元素的布局和样式
- 🎭 页面卡顿严重:滚动、交互等操作响应缓慢
- 📱 移动端体验差:在配置较低的移动设备上表现更为明显
虚拟滚动的实现方案对比 🔄
在深入实现之前,我们需要了解虚拟滚动的多种情况,它们各有应用场景,实现方式以及难点也各不相同。
虚拟滚动的实现可以从以下 5 个主要维度组合来看:
| 分类维度 | 可能取值 | 说明 |
|---|---|---|
| 1️⃣ 布局方向 | 单列 / 多列 / 网格 | 决定滚动的主轴方向与定位逻辑 |
| 2️⃣ 每项尺寸 | 固定高度 / 动态高度 | 决定是否可以直接通过数学计算定位 |
| 3️⃣ 容器特性 | 固定高度 / 自动高度(撑开) | 决定是否用 scrollTop 驱动 |
| 4️⃣ 数据特性 | 固定数量 / 动态数量(无限加载) | 决定是否需要懒加载机制 |
| 5️⃣ 定位方案 | Padding 占位法 / 绝对定位法 | 可视区域的定位方式 |
首先我们从定位方案区分一下,因为这个没有难度上的差异,这里不再赘述。
定位方案区分
- 方案一:Padding 占位法 📐
实现原理: 在容器中使用正常的文档流布局,只渲染可见部分的数据,通过padding-top和padding-bottom来撑起总高度,维持滚动条的正确长度。
核心代码示例:
<div class="virtual-container" style="height: 400px; overflow-y: auto;">
<div class="virtual-list" style="
padding-top: 1000px; /* 前面被隐藏项目的总高度 */
padding-bottom: 2000px; /* 后面被隐藏项目的总高度 */
">
<!-- 只渲染可见的几十个项目 -->
<div class="item">可见项目1</div>
<div class="item">可见项目2</div>
<div class="item">可见项目3</div>
</div>
</div>- 方案二:绝对定位法 🎯
实现原理: 容器使用position: relative,每个项目通过position: absolute精确计算位置,通过top和left属性控制元素的确切位置(这里也可以用transform: translateY()来控制整体位移)。
核心代码示例:
<div class="virtual-container" style="
height: 400px;
overflow-y: auto;
position: relative;
">
<!-- 撑起滚动条的占位元素 -->
<div style="height: 100000px;"></div>
<!-- 单独对每个可见项目使用绝对定位 -->
<div class="item" style="position: absolute; top: 0px; left: 0;">项目1</div>
<div class="item" style="position: absolute; top: 50px; left: 0;">项目2</div>
<div class="item" style="position: absolute; top: 100px; left: 0;">项目3</div>
<!-- 对整个容器进行位移/定位 -->
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualizedState.offsetY}px)`,
willChange: 'transform',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: '16px',
}}></div>其他的方案分类
如果实际要搭配各种分类划分,将会有很多场景,我们这里举出其中一些典型场景的方案来对比一下实现要点:
1. 单列 + 固定高度 + 定容器 + 定量(最简单)
思路:
每项高度已知,直接用 index * itemHeight 计算偏移量。只渲染可视区 + buffer,多余项用 transform: translateY() 定位。
难点:
几乎没有复杂难点,注意滚动抖动、buffer 大小调整与 key 使用稳定。
完整代码:
// FixedSizeList.jsx
import React, { useRef, useState, useMemo, useEffect } from 'react';
/**
* props:
* - data: Array<any>
* - itemHeight: number (px)
* - height: number (container height px)
* - renderItem: (item, index) => ReactNode
* - overscan: number (可视区上下 buffer 行数)
*/
export default function FixedSizeList({ data, itemHeight, height, renderItem, overscan = 2 }) {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const totalHeight = data.length * itemHeight;
const visibleCount = Math.ceil(height / itemHeight);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
function onScroll(e) {
setScrollTop(e.target.scrollTop);
}
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(data.length, startIndex + visibleCount + overscan * 2);
const items = [];
for (let i = startIndex; i < endIndex; i++) {
const item = data[i];
items.push(
<div
key={i}
style={{
height: itemHeight,
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${i * itemHeight}px)`,
}}
>
{renderItem(item, i)}
</div>
);
}
return (
<div
ref={containerRef}
style={{ position: 'relative', height, overflow: 'auto', width: '100%' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{items}
</div>
</div>
);
}要点与优化:
overscan影响平滑性与渲染量:滑动卡顿时增大overscan,渲染压力高时减小- 生产使用建议:直接使用
react-window(FixedSizeList)以获得更完善的边界处理与性能
2. 单列 + 固定高度 + 定容器 + 动态数量(无限加载)
思路:
基于类型 1,再在底部或锚点使用 IntersectionObserver 触发加载更多(防抖),并保持已有 scroll position 的稳定。
难点:
- 防止重复触发加载(节流/锁)
- 加载完成后要正确更新 totalHeight 与渲染范围
- 在插入大量数据时避免跳跃(可通过保持 scrollTop 或计算差值)
完整代码:
// InfiniteFixedList.jsx
import React, { useRef, useState, useEffect, useCallback } from 'react';
import FixedSizeList from './FixedSizeList'; // 上面实现的组件
export default function InfiniteFixedList({ fetchMore, itemHeight, height, renderItem }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const loadMoreAnchorRef = useRef(null);
const isMountedRef = useRef(true);
useEffect(() => { return () => { isMountedRef.current = false } }, []);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
const newItems = await fetchMore(); // 应返回数组(可能空)
if (!isMountedRef.current) return;
setData(prev => [...prev, ...newItems]);
} finally {
if (isMountedRef.current) setLoading(false);
}
}, [fetchMore, loading]);
useEffect(() => {
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
loadMore();
}
});
}, { root: null, rootMargin: '200px' }); // 提前 200px 触发
if (loadMoreAnchorRef.current) obs.observe(loadMoreAnchorRef.current);
return () => obs.disconnect();
}, [loadMore]);
return (
<div style={{ position: 'relative' }}>
<FixedSizeList
data={data}
itemHeight={itemHeight}
height={height}
renderItem={renderItem}
overscan={3}
/>
<div ref={loadMoreAnchorRef} style={{ height: 1 }} />
{loading && <div style={{ textAlign: 'center' }}>加载中...</div>}
</div>
);
}要点与优化:
rootMargin可用来提前加载,给用户更平滑体验- 在
fetchMore中考虑分页(cursor、page)与空数据终止条件 - CI/SSR 场景下注意预加载/SSR 数据注入
3. 单列 + 固定高度 + 容器撑开(window滚动)+ 定量
思路:
整个页面滚动而不是独立容器。监听 window.scroll,计算相对文档偏移。常用于"页面自然滚动"的长列表。
难点:
- 计算
scrollTop时要将容器相对于文档顶部的 offset 考虑进来(container.getBoundingClientRect().top + window.scrollY) - 在复杂页面中
getBoundingClientRect的频繁调用造成性能压力,需 throttle
完整代码:
// WindowFixedList.jsx
import React, { useRef, useState, useEffect } from 'react';
export default function WindowFixedList({ data, itemHeight, renderItem, overscan = 3 }) {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerTop, setContainerTop] = useState(0);
useEffect(() => {
function updateContainerTop() {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// containerTop 相对于文档顶部
setContainerTop(rect.top + window.scrollY);
}
updateContainerTop();
window.addEventListener('resize', updateContainerTop);
return () => window.removeEventListener('resize', updateContainerTop);
}, []);
useEffect(() => {
// throttle using requestAnimationFrame
let ticking = false;
function onScroll() {
if (!ticking) {
window.requestAnimationFrame(() => {
setScrollTop(window.scrollY);
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const height = window.innerHeight;
const totalHeight = data.length * itemHeight;
// 视口相对于 container 的 scrollTop
const relativeScrollTop = Math.max(0, scrollTop - containerTop);
const startIndex = Math.max(0, Math.floor(relativeScrollTop / itemHeight) - overscan);
const endIndex = Math.min(data.length, startIndex + Math.ceil(height / itemHeight) + overscan * 2);
const items = [];
for (let i = startIndex; i < endIndex; i++) {
items.push(
<div key={i} style={{ position: 'absolute', top: i * itemHeight, left: 0, right: 0, height: itemHeight }}>
{renderItem(data[i], i)}
</div>
);
}
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{items}
</div>
</div>
);
}要点与优化:
window.requestAnimationFrame节流很重要,避免 scroll 事件触发过多重渲- 在 SSR 或移动端要测试
window.innerHeight与 safe area 情况
4. 单列 + 动态高度 + 定容器 + 定量(测高 + 二分索引)
思路:
项目高度不一致,需在渲染后测量每项高度并构建 offsetTable(累计偏移)。滚动时通过二分查找 offsetTable 找到 startIndex。用 ResizeObserver 动态维护高度表。
难点:
- 需要准确且高效地维护每项高度(避免频繁 reflow)
- 新测量到高度变化后要更新累计偏移并修正滚动位置(防止闪跳)
- 二分查找实现要求正确
完整代码:
// VariableSizeList.jsx
import React, { useRef, useState, useCallback, useEffect } from 'react';
/**
* props:
* - data
* - height (container height px)
* - renderItem: (item, index, measureRef) => ReactNode // measureRef 用于测量 DOM
* - estimatedItemHeight: number
* - overscan
*
* 实现思路:
* - 维护 heights Map<index, height>
* - 维护 offsets array: offsets[i] = sum_{0..i-1} heights
* - 二分查找给定 scrollTop -> 找到 startIndex 使 offsets[startIndex] <= scrollTop < offsets[startIndex+1]
*/
export default function VariableSizeList({ data, height, renderItem, estimatedItemHeight = 80, overscan = 3 }) {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const heightsRef = useRef(new Map()); // index -> height
const offsetsRef = useRef([]); // 累计偏移,length = data.length + 1
const resizeObserverRef = useRef(null);
// 创建/更新 offsets(根据 heightsRef)
const recomputeOffsets = useCallback(() => {
const offsets = new Array(data.length + 1);
offsets[0] = 0;
for (let i = 0; i < data.length; i++) {
const h = heightsRef.current.get(i) ?? estimatedItemHeight;
offsets[i + 1] = offsets[i] + h;
}
offsetsRef.current = offsets;
}, [data.length, estimatedItemHeight]);
useEffect(() => {
recomputeOffsets();
}, [recomputeOffsets]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onScroll = () => setScrollTop(el.scrollTop);
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
// 二分查找:找到最大的 idx 使 offsets[idx] <= value
function binarySearch(value) {
const offsets = offsetsRef.current;
let low = 0, high = offsets.length - 1;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (offsets[mid] <= value) low = mid;
else high = mid - 1;
}
return low;
}
// 渲染的 start/end
const startIndex = Math.max(0, binarySearch(scrollTop) - overscan);
// compute endIndex by scanning forward until offsets[end+1] > scrollTop + height
let endIndex = startIndex;
const offsets = offsetsRef.current;
while (endIndex < data.length && offsets[endIndex] < scrollTop + height + overscan * estimatedItemHeight) {
endIndex++;
}
// 用于每个 item 的测量 ref
function MeasureWrapper({ index, children }) {
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
function measure() {
const h = node.getBoundingClientRect().height;
const prev = heightsRef.current.get(index);
if (prev !== h) {
heightsRef.current.set(index, h);
recomputeOffsets();
}
}
// 初次测量
measure();
// observe
const ro = new ResizeObserver(measure);
ro.observe(node);
return () => ro.disconnect();
}, [index]);
return <div ref={ref} style={{ position: 'absolute', left: 0, right: 0 }}>{children}</div>;
}
const totalHeight = offsetsRef.current.length ? offsetsRef.current[offsetsRef.current.length - 1] : data.length * estimatedItemHeight;
const items = [];
for (let i = startIndex; i < Math.min(data.length, endIndex + overscan); i++) {
const top = offsetsRef.current[i] ?? (i * estimatedItemHeight);
items.push(
<div key={i} style={{ position: 'absolute', top }}>
<MeasureWrapper index={i}>
{renderItem(data[i], i)}
</MeasureWrapper>
</div>
);
}
return (
<div ref={containerRef} style={{ position: 'relative', height, overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{items}
</div>
</div>
);
}要点与优化:
estimatedItemHeight对初次滚动与首屏展示非常重要。设置合理估值能减少布局抖动ResizeObserver用于动态调整,避免getBoundingClientRect的频繁同步布局读取- 使用二分查找能将滚动定位复杂度从 O(n) 降为 O(log n)
- 生产使用建议:使用
react-virtual或react-virtualized的动态尺寸实现来避免重复造轮子
5. 单列 + 动态高度 + 定容器 + 动态数量(最常见的复杂场景)
思路:
在类型 4 的基础上再支持分页/无限加载。需要注意新数据插入时 offsets 的增量更新,以及避免滚动位置跳动(如果在顶部插入或中间插入会影响位置)。
难点:
- 新数据插入(尤其是前端插入)会改变 offsets,导致用户看到跳动
- 需要处理加载时的占位、Skeleton 与测量顺序
完整代码:
// InfiniteVariableList.jsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import VariableSizeList from './VariableSizeList'; // 上一个实现
export default function InfiniteVariableList({ fetchMore, renderItem, height, estimatedItemHeight = 80 }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const anchorRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
const more = await fetchMore();
if (more && more.length) setData(prev => [...prev, ...more]);
} finally {
setLoading(false);
}
}, [fetchMore, loading]);
useEffect(() => {
const obs = new IntersectionObserver(entries => {
for (const e of entries) if (e.isIntersecting) loadMore();
}, { root: null, rootMargin: '300px' });
if (anchorRef.current) obs.observe(anchorRef.current);
return () => obs.disconnect();
}, [loadMore]);
return (
<div>
<VariableSizeList
data={data}
height={height}
renderItem={(item, index) => renderItem(item, index)}
estimatedItemHeight={estimatedItemHeight}
/>
<div ref={anchorRef} style={{ height: 1 }} />
{loading && <div>加载中...</div>}
</div>
);
}要点与优化:
如果需要在顶部 prepend(如聊天历史加载更早消息),则必须在插入前记录当前第一个可视元素与它到容器顶部的距离,插入后调整 scrollTop 以保持用户视图稳定。实现步骤:
- 记录当前
firstVisibleIndex与offset(scrollTop - offsets[firstIndex]) - 插入数据并更新 offsets
- 计算新
offsets[firstIndex + newItems.length]并设置scrollTop = newOffsets[firstIndex + newLen] + offset
💡 提示:生产用例建议使用成熟库或自己封装稳健的"prepend"逻辑。
6. 多列(网格)+ 定高(FixedGrid)
思路:
按行列映射:已知每个 item 宽高(或已知列宽与行高),可以把列表看成若干行,每行渲染 colCount 个项,按行进行虚拟化。
难点:
- 宽度自适应下列数动态计算(窗口 resize)
- 横向填充/对齐与空白项处理
完整代码:
// FixedGrid.jsx
import React, { useRef, useEffect, useState } from 'react';
/**
* props:
* - data
* - containerHeight
* - itemWidth
* - itemHeight
* - renderItem
* - gap
*/
export default function FixedGrid({ data, containerHeight, itemWidth, itemHeight, renderItem, gap = 8 }) {
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const onResize = () => {
setContainerWidth(containerRef.current?.clientWidth || 0);
};
onResize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const colCount = Math.max(1, Math.floor((containerWidth + gap) / (itemWidth + gap)));
const rowCount = Math.ceil(data.length / colCount);
const rowHeightWithGap = itemHeight + gap;
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
function onScroll() {
setScrollTop(el.scrollTop);
}
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
const visibleRows = Math.ceil(containerHeight / rowHeightWithGap);
const startRow = Math.max(0, Math.floor(scrollTop / rowHeightWithGap) - 2);
const endRow = Math.min(rowCount, startRow + visibleRows + 4);
const items = [];
for (let row = startRow; row < endRow; row++) {
for (let col = 0; col < colCount; col++) {
const index = row * colCount + col;
if (index >= data.length) break;
const left = col * (itemWidth + gap);
const top = row * rowHeightWithGap;
items.push(
<div key={index} style={{ position: 'absolute', left, top, width: itemWidth, height: itemHeight }}>
{renderItem(data[index], index)}
</div>
);
}
}
return (
<div ref={containerRef} style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}>
<div style={{ height: rowCount * rowHeightWithGap, position: 'relative', width: '100%' }}>
{items}
</div>
</div>
);
}要点与优化:
- 处理 gap 与 colCount 计算时要注意容器宽度与滚动条宽度
- 生产使用建议:
react-window的FixedSizeGrid更成熟,支持列/行虚拟化
7. 多列 + 不定高(Masonry / 瀑布流)
思路:
瀑布流通常按列维护高度数组 colHeights,把每个 item 放进当前最短列,从而实现不定高的纵向填充。虚拟化的难点是:如何根据 scrollTop 快速判断哪些 items 在视口内——因为 items 的 top 是不均匀分布。
常见做法有两种:
- 按列维护列表:对每列分别做虚拟化(每列是一个竖直列表)
- 全局索引 + 布局缓存:在布局阶段计算每个 item 的 top/left/height 并缓存,滚动时二分查找 top 数组找到可视区
难点:
- 需要一次性布局或逐步布局并缓存(首次布局代价高)
- 瀑布布局需测量每个 item 的高度(异步图片加载会影响)
- 当 item 高度后加载(图片)改变会引起重新布局与大量重排
完整代码:
// Masonry.jsx (简化实现,只为把核心思路展现清楚)
import React, { useEffect, useRef, useState } from 'react';
/**
* props:
* - data
* - columnWidth
* - gap
* - containerHeight
* - renderItem: (item, index) => ReactNode (item must render an element that auto-height fits)
*/
export default function Masonry({ data, columnWidth = 250, gap = 12, containerHeight, renderItem }) {
const containerRef = useRef(null);
const [colCount, setColCount] = useState(1);
const [columns, setColumns] = useState([]); // array of arrays of {index, top, height}
useEffect(() => {
const onResize = () => {
const w = containerRef.current?.clientWidth || 0;
const c = Math.max(1, Math.floor((w + gap) / (columnWidth + gap)));
setColCount(c);
};
onResize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [columnWidth, gap]);
// layout: naive: place items into shortest column, but need heights
// We'll render items invisibly first to measure heights, then build columns.
useEffect(() => {
// create a measurement container offscreen
const measureContainer = document.createElement('div');
measureContainer.style.position = 'absolute';
measureContainer.style.left = '-99999px';
measureContainer.style.top = '0';
document.body.appendChild(measureContainer);
const tempHeights = new Array(data.length);
let measuredCount = 0;
data.forEach((item, idx) => {
const node = document.createElement('div');
node.style.width = `${columnWidth}px`;
node.innerHTML = `<div>${String(renderItem(item, idx) ?? '')}</div>`;
measureContainer.appendChild(node);
// we can't reliably measure react node html this way; in real world use ReactDOM.render or render into iframe/container
// Here we simulate synchronous measurement by reading clientHeight
tempHeights[idx] = node.getBoundingClientRect().height || 0;
measuredCount++;
});
// build columns
const cols = Array.from({ length: colCount }, () => ({ items: [], height: 0 }));
for (let i = 0; i < data.length; i++) {
let minCol = 0;
for (let c = 1; c < cols.length; c++) {
if (cols[c].height < cols[minCol].height) minCol = c;
}
const top = cols[minCol].height;
cols[minCol].items.push({ index: i, top, height: tempHeights[i] });
cols[minCol].height += tempHeights[i] + gap;
}
setColumns(cols.map(c => c.items));
document.body.removeChild(measureContainer);
}, [data, colCount, columnWidth, gap, renderItem]);
// render
const containerStyle = { position: 'relative', height: containerHeight, overflow: 'auto' };
const columnStyle = (i) => ({
position: 'absolute',
left: i * (columnWidth + gap),
top: 0,
width: columnWidth,
});
return (
<div ref={containerRef} style={containerStyle}>
<div style={{ position: 'relative', height: Math.max(...columns.map(col => col.reduce((a,b)=>Math.max(a,b.top+b.height),0)), 0) }}>
{columns.map((col, ci) =>
<div key={ci} style={columnStyle(ci)}>
{col.map(item =>
<div key={item.index} style={{ position: 'absolute', top: item.top, width: '100%' }}>
{renderItem(data[item.index], item.index)}
</div>
)}
</div>
)}
</div>
</div>
);
}⚠️ 注意:上面示例为教学化简版。真实工程中不建议用同步 DOM 拼字符串测量(示例只是展示思路)。真实实现应:
- 使用真实 React 节点渲染到一个隐藏测量区并使用
ResizeObserver监听图片加载引起的高度变化(或先用占位高度,图片加载后触发 reflow)- 或使用像
react-virtualized的 Masonry、masonic、react-virtual-masonry这类库,它们处理了大量边界情况与图片加载问题
要点与优化:
- 对图片做占位(占位高度 + 图片比例)以减少重排
- 使用
IntersectionObserver只渲染可视区内的列项 - 大型列表建议使用第三方成熟库(
masonic/react-virtual-masonry)
8. 混合(不定高 + 撑开容器 + 动态数据)——最复杂(自研示例)
思路:
这是现实大型 feed 的场景(页面滚动、动态加载、内容高度不定)。最佳实践通常是混合方案:
- 测量并缓存:对每条 item 测量高度,维护 offsets(累计高度表),持久化缓存(local/session/PERSIST)以便后续打开时复用
- 二分 + 页块化:用二分查找定位 startIndex;为避免每次都遍历大量 offsets,可把 offsets 划分成块(bucket),每块维护累计高度,二分先在块上定位,再在块内二分
- 虚拟化 + 懒渲染:只渲染窗口内 + buffer;图片使用占位(skeleton)并在图片加载完成后更新高度
- 增量加载 & 前置/后置插入:插入数据时保持或修正 scrollTop(记录视口第一个元素的相对偏移并在数据变更后重定位)
- 防抖/节流测量:将测量操作聚合在微任务或
requestIdleCallback里执行以减少 layout thrash
下面给出一个尽可能完整的示例(有点长),它展示了:offset 缓存、二分查找、ResizeObserver、页面滚动、以及底部无限加载。此实现为示范用途,真实工程建议用经过高度优化的库或把核心逻辑进一步分离优化。
完整代码:
// ComplexFeed.jsx
import React, { useEffect, useRef, useState, useCallback } from 'react';
/**
* ComplexFeed 要点:
* - 支持页面滚动(container 撑开)
* - 支持动态高度(ResizeObserver)
* - 支持无限加载
* - 维护 offsets (累积高度),并做二分查找
*
* props:
* - fetchMore(cursor) -> {items: [], nextCursor}
* - renderItem(item, index, measureRef)
* - initialData?
* - estimatedHeight
*/
export default function ComplexFeed({ fetchMore, renderItem, estimatedHeight = 120 }) {
const containerRef = useRef(null);
const [items, setItems] = useState([]);
const heightsRef = useRef(new Map()); // index -> height
const offsetsRef = useRef([0]); // offsets[0]=0; offsets[i]=sum heights[0..i-1]
const [scrollY, setScrollY] = useState(window.scrollY);
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(false);
const anchorRef = useRef(null);
// init: compute offsets
const recomputeOffsets = useCallback(() => {
const offsets = [0];
for (let i = 0; i < items.length; i++) {
const h = heightsRef.current.get(i) ?? estimatedHeight;
offsets[i + 1] = offsets[i] + h;
}
offsetsRef.current = offsets;
}, [items.length, estimatedHeight]);
useEffect(() => { recomputeOffsets(); }, [recomputeOffsets]);
// scroll listener (throttled via rAF)
useEffect(() => {
let ticking = false;
function onScroll() {
if (!ticking) {
window.requestAnimationFrame(() => {
setScrollY(window.scrollY);
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// binary search using offsets
function findStartIndexByScroll(scrollTop) {
const offsets = offsetsRef.current;
let low = 0, high = offsets.length - 1;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (offsets[mid] <= scrollTop) low = mid;
else high = mid - 1;
}
return Math.max(0, low - 1); // low is the largest idx with offsets[idx] <= scrollTop; item index = low
}
// compute visible window indexes
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
const startIndex = Math.max(0, findStartIndexByScroll(scrollY) - 3);
let endIndex = startIndex;
const offsets = offsetsRef.current;
while (endIndex < items.length && offsets[endIndex] < scrollY + viewportHeight + 300) { // 300px buffer
endIndex++;
}
// measurement wrapper to attach ResizeObserver and update heights
function MeasuredItem({ index, children }) {
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const measure = () => {
const h = node.getBoundingClientRect().height;
const prev = heightsRef.current.get(index);
if (prev !== h) {
heightsRef.current.set(index, h);
recomputeOffsets();
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
return () => ro.disconnect();
}, [index]);
return <div ref={ref} style={{ position: 'absolute', left: 0, right: 0 }}>{children}</div>;
}
// render visible items
const rendered = [];
for (let i = startIndex; i < Math.min(items.length, endIndex + 3); i++) {
const top = offsetsRef.current[i] ?? (i * estimatedHeight);
rendered.push(
<div key={i} style={{ position: 'absolute', top }}>
<MeasuredItem index={i}>
{renderItem(items[i], i)}
</MeasuredItem>
</div>
);
}
// total height for spacer
const totalHeight = offsetsRef.current.length ? offsetsRef.current[offsetsRef.current.length - 1] : items.length * estimatedHeight;
// infinite loading at bottom
useEffect(() => {
const obs = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting && !loading) {
// load more
(async () => {
setLoading(true);
try {
const res = await fetchMore(cursor);
if (res?.items?.length) {
setItems(prev => [...prev, ...res.items]);
setCursor(res.nextCursor ?? null);
}
} finally {
setLoading(false);
}
})();
}
}, { root: null, rootMargin: '400px' });
if (anchorRef.current) obs.observe(anchorRef.current);
return () => obs.disconnect();
}, [fetchMore, cursor, loading]);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{rendered}
</div>
<div ref={anchorRef} style={{ height: 1 }} />
{loading && <div style={{ textAlign: 'center' }}>加载中...</div>}
</div>
);
}要点与优化:
- 分块 offsets(Bucket):当 items 数量非常大(几十万)时,不要在 offsets 中做全量重算;可以把 offsets 按块(例如每 1000 项)缓存块内累加值,只在涉及到的块做重算
- 持久化缓存:将 item 高度缓存到 IndexedDB 或 localStorage(带版本)以便下次打开时直接使用,减少首次布局成本
- 图片占位策略:图片应先占位或用
srcset/loading="lazy"并监听 load 事件在加载完成后测量高度 - 并发测量聚合:把测量更新合并到一个
requestIdleCallback或setTimeout(...,0)批次中执行,避免大量同步 layout
⚠️ 生产建议:混合方案的实现非常容易出边界 bug(闪动、跳位、内存暴涨),建议使用成熟库或在内部封装成通用组件并进行大量测试。
实战案例:响应式网格的虚拟滚动实现 🎯
在实际项目中,我们经常需要处理更复杂的场景:响应式多列网格布局 + 无限滚动 + 虚拟化优化。下面分享一个真实项目中的完整实现案例。
项目背景
这是一个图片/卡片展示应用,需要支持:
- 📱 响应式布局:不同屏幕尺寸显示不同列数(2-6列)
- ♾️ 无限滚动:分页加载大量数据
- ⚡ 虚拟滚动:优化渲染性能,支持数万张卡片
- 🎨 动态高度:卡片高度根据屏幕宽度自适应
核心实现思路
与前面介绍的单列虚拟滚动不同,这个案例我们首先使用了Padding占位法 + 多列网格的方案,但在实践过程中,我们发现了一些值得注意的问题,特此记录。
padding方案实现代码
import { useRef, useCallback, useState, useEffect, useMemo } from 'react';
import useBreakpoint from './useBreakpoint';
function useResponsiveVirtualGrid(cards) {
const virtualizedContainerRef = useRef(null);
const { isAbove } = useBreakpoint();
const initialTop = useRef(0);
useEffect(() => {
if (virtualizedContainerRef.current) {
const rect =
virtualizedContainerRef.current.getBoundingClientRect();
initialTop.current = rect.top;
}
}, []);
const VIRTUALIZATION_CONFIG = {
gap: 16, // 行间距
bufferRows: 5, // 缓冲行数,避免滚动时闪烁
};
const getLayoutConfig = useCallback(() => {
const vw = window.innerWidth;
switch (true) {
case isAbove('3xl'):
return {
columns: 6,
cardHeight: 341,
renderRows: 10,
};
case isAbove('2xl'):
return {
columns: 6,
cardHeight: 284,
renderRows: 10,
};
case isAbove('xl'):
return {
columns: 5,
cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28),
renderRows: 8,
};
case isAbove('md'):
return {
columns: 4,
cardHeight: Math.max(180, (0.9 * vw - 316) / 4 - 16),
renderRows: 6,
};
case isAbove('sm'):
return {
columns: 3,
cardHeight: Math.max(180, (0.9 * vw - 316) / 3 - 16),
renderRows: 4,
};
case isAbove('mobile'):
return {
columns: 3,
cardHeight: Math.max(0.28 * vw, 180),
renderRows: 4,
};
default:
return {
columns: 2,
cardHeight: 180,
renderRows: 4,
};
}
}, [isAbove]);
const [virtualizedState, setVirtualizedState] = useState({
startIndex: 0,
endIndex: 0,
paddingTop: 0,
paddingBottom: 0,
});
const layoutConfig = useMemo(() => getLayoutConfig(), [getLayoutConfig]);
/**
* 总高度 = paddingTop + 上buffer + 可视区域 + 下buffer + paddingBottom
* 可视区域 = 可视行数(renderRows) * 行高(cardHeight + gap)
* 总行数 = 卡片数量(cards.length) / 列数(columns)
* 列数 = 总列数(columns)
* 卡片数量 = 卡片数量(cards.length)
* 缓冲行数 = 缓冲行数(bufferRows)
* 滚动距离 = 滚动距离(-containerRect.top)
*/
const calculateVirtualization = useCallback(() => {
const container = virtualizedContainerRef.current;
if (!container || cards.length === 0) {
return {
startIndex: 0,
endIndex: 0,
paddingTop: 0,
paddingBottom: 0,
};
}
const { columns, cardHeight, renderRows } = layoutConfig;
const { gap, bufferRows } = VIRTUALIZATION_CONFIG;
const totalCards = cards.length;
// const rowHeight = cardHeight + gap;
const totalRows = Math.ceil(totalCards / columns);
// const containerRect = container.getBoundingClientRect();
// const viewportHeight = window.innerHeight;
// 可见区域
const scrollTop = window.scrollY - initialTop.current;
// 可见行范围
const firstVisibleRow = Math.floor(scrollTop / (cardHeight + gap));
const startRow = Math.max(0, firstVisibleRow - bufferRows);
const endRow = Math.min(
totalRows - 1,
firstVisibleRow + renderRows + bufferRows - 1
);
const paddingTop = startRow * (cardHeight + gap);
const paddingBottom = (totalRows - endRow - 1) * (cardHeight + gap);
const startIndex = startRow * columns;
const endIndex = Math.min(totalCards - 1, (endRow + 1) * columns - 1);
return {
startIndex,
endIndex,
paddingTop,
paddingBottom,
};
}, [cards, layoutConfig]);
const updateVirtualization = useCallback(() => {
const newState = calculateVirtualization();
// console.log('newState', newState);
setVirtualizedState(newState);
}, [calculateVirtualization]);
const throttledUpdateRef = useRef();
const lastUpdateTimeRef = useRef(0);
const throttledUpdate = useCallback(() => {
const now = Date.now();
if (throttledUpdateRef.current) {
clearTimeout(throttledUpdateRef.current);
}
const timeSinceLastUpdate = now - lastUpdateTimeRef.current;
const delay = Math.max(0, 16 - timeSinceLastUpdate);
throttledUpdateRef.current = setTimeout(() => {
updateVirtualization();
lastUpdateTimeRef.current = Date.now();
}, delay);
}, [updateVirtualization]);
useEffect(() => {
const handleScroll = () => throttledUpdate();
const handleResize = () => updateVirtualization(); // resize不需要节流
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
updateVirtualization();
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (throttledUpdateRef.current) {
clearTimeout(throttledUpdateRef.current);
}
};
}, [throttledUpdate, updateVirtualization]);
useEffect(() => {
updateVirtualization();
}, [cards.length, updateVirtualization]);
const poolCount = useMemo(() => {
const { columns, renderRows } = layoutConfig;
const { bufferRows } = VIRTUALIZATION_CONFIG;
// 固定池大小 = (可视行 + 上下 buffer)* 列数
return (renderRows + bufferRows * 2) * columns;
}, [layoutConfig]);
return {
virtualizedContainerRef,
virtualizedState,
layoutConfig,
poolCount,
};
}
export default useResponsiveVirtualGrid;
// 组件中的使用示例
function GalleryComponent({ cards, loadMore, allLoaded, isLoading }) {
const { virtualizedContainerRef, virtualizedState, poolCount } =
useResponsiveVirtualGrid(cards);
return (
<div
className={`gallery-container h-full pb-[40px] ${
cards.length === 0 ? 'overflow-hidden' : ''
}`}
ref={virtualizedContainerRef}
style={{
paddingTop: virtualizedState.paddingTop,
paddingBottom: virtualizedState.paddingBottom,
}}
>
<ScrollLoader
allLoaded={allLoaded}
loadMore={loadMore}
ref={ScrollLoaderRef}
width="100%"
>
{Array.from({ length: poolCount }).map((_, i) => {
const actualIndex =
virtualizedState.startIndex + i;
const inRange =
actualIndex <= virtualizedState.endIndex &&
actualIndex < cards.length;
const card = inRange
? cards[actualIndex]
: null;
if (!card) return null;
return (
<div
key={card.task_uuid} // key={i}
>
{inRange && card ? (
<CardDataContext.Provider
value={card}
>
<RodinCard />
</CardDataContext.Provider>
) : null}
</div>
);
})}
</ScrollLoader>
</div>
);
}上述方案的缺陷
上面的示例代码我们在实践过程当中,发现了两个坑:
1. key值的使用
- 这里特别要注意,虚拟列表里面的key值不能使用唯一的id值,而要使用索引值!!!
- 因为当快速滚动的时候,react的渲染机制可能会复用旧的DOM节点,导致出现多个重复的DOM节点。
2. padding的问题
- 这里使用padding机制会有些隐藏的bug,因为我这里是一个滚动触底加载的场景,这里可能会出现当触底加载的时候,scrollTop的计算问题,导致滚动到底部时,会闪烁一下直接渲染新数据,也就是当你在第10页触发新数据加载的时候,瞬间到了第11页的底部。
- 我这里猜测是浏览器自动布局的问题,因此后面转用定位的方案来解决这个问题
transform方案实现代码
完整hook:
import { useRef, useCallback, useState, useEffect, useMemo } from 'react';
import useBreakpoint from './useBreakpoint';
function useResponsiveVirtualGrid(cards) {
const virtualizedContainerRef = useRef(null);
const { isAbove } = useBreakpoint();
const initialTop = useRef(0);
useEffect(() => {
if (virtualizedContainerRef.current) {
const rect =
virtualizedContainerRef.current.getBoundingClientRect();
// 记录容器相对文档的初始位置
initialTop.current = rect.top + window.scrollY;
}
}, []);
const VIRTUALIZATION_CONFIG = {
gap: 16,
bufferRows: 10,
};
const getLayoutConfig = useCallback(() => {
const vw = window.innerWidth;
switch (true) {
case isAbove('3xl'):
return { columns: 6, cardHeight: 341, renderRows: 10 };
case isAbove('2xl'):
return { columns: 6, cardHeight: 284, renderRows: 10 };
case isAbove('xl'):
return {
columns: 5,
cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28),
renderRows: 8,
};
case isAbove('md'):
return {
columns: 4,
cardHeight: Math.max(180, (0.9 * vw - 316) / 4 - 16),
renderRows: 6,
};
case isAbove('sm'):
return {
columns: 3,
cardHeight: Math.max(180, (0.9 * vw - 316) / 3 - 16),
renderRows: 4,
};
case isAbove('mobile'):
return {
columns: 3,
cardHeight: Math.max(0.28 * vw, 180),
renderRows: 4,
};
default:
return { columns: 2, cardHeight: 180, renderRows: 4 };
}
}, [isAbove]);
const [virtualizedState, setVirtualizedState] = useState({
startIndex: 0,
endIndex: 0,
offsetY: 0,
totalHeight: 0,
});
const layoutConfig = useMemo(() => getLayoutConfig(), [getLayoutConfig]);
const calculateVirtualization = useCallback(() => {
const container = virtualizedContainerRef.current;
if (!container || cards.length === 0) {
return { startIndex: 0, endIndex: 0, offsetY: 0, totalHeight: 0 };
}
const { columns, cardHeight, renderRows } = layoutConfig;
const { gap, bufferRows } = VIRTUALIZATION_CONFIG;
const totalCards = cards.length;
const rowHeight = cardHeight + gap;
const totalRows = Math.ceil(totalCards / columns);
const totalHeight = totalRows * rowHeight - gap;
const scrollTop = window.scrollY - initialTop.current;
const firstVisibleRow = Math.floor(scrollTop / rowHeight);
const startRow = Math.max(0, firstVisibleRow - bufferRows);
const endRow = Math.min(
totalRows - 1,
firstVisibleRow + renderRows + bufferRows - 1
);
const offsetY = startRow * rowHeight;
const startIndex = startRow * columns;
const endIndex = Math.min(totalCards - 1, (endRow + 1) * columns - 1);
return { startIndex, endIndex, offsetY, totalHeight };
}, [cards, layoutConfig]);
const updateVirtualization = useCallback(() => {
const newState = calculateVirtualization();
setVirtualizedState(newState);
}, [calculateVirtualization]);
useEffect(() => {
const handleScroll = () => updateVirtualization();
const handleResize = () => updateVirtualization();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
updateVirtualization();
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [updateVirtualization]);
const poolCount = useMemo(() => {
const { columns, renderRows } = layoutConfig;
const { bufferRows } = VIRTUALIZATION_CONFIG;
return (renderRows + bufferRows * 2) * columns;
}, [layoutConfig]);
const minHeight = useMemo(() => {
const { cardHeight } = layoutConfig;
const { gap } = VIRTUALIZATION_CONFIG;
return 2 * cardHeight + gap;
}, [layoutConfig]);
return {
virtualizedContainerRef,
virtualizedState,
layoutConfig,
poolCount,
minHeight,
};
}
export default useResponsiveVirtualGrid;使用样例:
const {
virtualizedContainerRef,
virtualizedState,
layoutConfig,
poolCount,
minHeight,
} = useResponsiveVirtualGrid(cards);
const { columns } = layoutConfig;
<ScrollLoader
allLoaded={allLoaded}
loadMore={loadMore}
ref={ScrollLoaderRef}
sentinelRefAbsolute
width="100%"
>
{/* 内层内容通过 translateY 定位 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualizedState.offsetY}px)`,
willChange: 'transform',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: '16px',
}}
>
{Array.from({ length: poolCount }).map(
(_, i) => {
const actualIndex =
virtualizedState.startIndex + i;
const inRange =
actualIndex <=
virtualizedState.endIndex &&
actualIndex < cards.length;
const card = inRange
? cards[actualIndex]
: null;
if (!card) return null;
return (
<CardDataContext.Provider
key={actualIndex}
value={card}
>
<RodinCard />
</CardDataContext.Provider>
);
}
)}
</div>
</ScrollLoader>实现亮点分析 ✨
这个实战案例展现了虚拟滚动在复杂场景下的应用技巧:
1. 响应式配置统一管理 📱
// 🎯 亮点:将所有断点配置集中管理,避免重复计算
const getLayoutConfig = useCallback(() => {
const vw = window.innerWidth;
// 根据视口宽度返回不同的布局配置
switch (true) {
case isAbove('3xl'): return { columns: 6, cardHeight: 341, renderRows: 7 };
case isAbove('2xl'): return { columns: 6, cardHeight: 284, renderRows: 6 };
case isAbove('xl'): return { columns: 5, cardHeight: Math.max(180, (0.9 * vw - 346) / 5 - 28), renderRows: 5 };
// ... 更多断点配置
}
}, [isAbove]);2. 行级虚拟化策略 📏
// 🎯 亮点:以行为单位进行虚拟化,而不是单个卡片
const visibleStartRow = Math.floor(scrollTop / rowHeight);
const visibleEndRow = Math.min(totalRows, Math.ceil(scrollBottom / rowHeight));
// 添加缓冲区,提升滚动体验
const renderStartRow = Math.max(0, visibleStartRow - bufferRows);
const renderEndRow = Math.min(totalRows, visibleEndRow + bufferRows);3. 性能优化细节 🚀
// 节流处理,控制更新频率
const throttledUpdateVirtualization = useCallback(
useThrottle(updateVirtualization, 16), // 约60fps
[updateVirtualization]
);
// 被动监听,避免阻塞滚动
window.addEventListener('scroll', handleScroll, { passive: true });
// 开发环境调试信息
if (process.env.NODE_ENV === 'development') {
console.log('虚拟化更新:', { /* 调试数据 */ });
}三方库使用 react-window
1. 什么是 React-Window?
react-window 是一个用于实现虚拟列表的 React 库,它提供了:
List- 一维列表(垂直或水平)Grid- 二维网格(多列多行)
2. 核心原理
┌─────────────────────────┐
│ 可视区域(Viewport) │ ← 只渲染这个区域内的元素
├─────────────────────────┤
│ [1] [2] [3] [4] [5] │ ← 可见的单元格
│ [6] [7] [8] [9] [10] │
│ ... │
├─────────────────────────┤
│ 虚拟占位空间 │ ← 不渲染,但占据空间
│ [11-1000] │
└─────────────────────────┘Grid 组件使用详解
1. 安装
npm install react-window
# 或
pnpm add react-window2. 基本导入
import { Grid, CellComponentProps, useGridRef } from 'react-window'Grid- 网格组件CellComponentProps- 单元格组件的 props 类型useGridRef- 创建 Grid 的 ref(用于获取 Grid 的 DOM 元素)
2. Grid 组件必需参数
2.1 cellComponent - 单元格渲染函数
// 这是渲染每个单元格的函数
const Cell = ({ columnIndex, rowIndex, style, ...props }: CellComponentProps) => {
// columnIndex: 列索引(从 0 开始)
// rowIndex: 行索引(从 0 开始)
// style: Grid 自动计算的样式(包含 position、transform 等)
const index = rowIndex * columnCount + columnIndex
return (
<div style={style}>
{/* 你的单元格内容 */}
项目 #{index}
</div>
)
}重要: style 必须应用到单元格的根元素上,否则定位会出错!
2.2 cellProps - 传递给单元格的额外 props
<Grid
cellProps={{
// 这里的数据会作为 props 传递给 Cell 组件
items: myItems
}}
/>2.3 columnCount - 列数
columnCount={4} // 一行显示 4 列2.4 rowCount - 行数
// 行数 = 数据总数 / 列数(向上取整)
const rowCount = Math.ceil(items.length / columnCount)2.5 columnWidth - 列宽
columnWidth={280} // 每列 280 像素
// 或者使用函数:(index) => number
columnWidth={(index) => index === 0 ? 300 : 280}2.6 rowHeight - 行高
rowHeight={200} // 每行 200 像素
// 或者使用函数:(index) => number
rowHeight={(index) => index % 2 === 0 ? 200 : 250}2.7 width 和 height - Grid 容器尺寸
width={800} // Grid 宽度(像素)
height={600} // Grid 高度(像素)
// 或者
width="100%" // 百分比宽度2.8 style - Grid 容器样式
style={{
height: '600px',
width: '800px',
overflowY: 'auto', // 允许垂直滚动
overflowX: 'hidden' // 隐藏水平滚动
}}3. 完整的基础示例
import { Grid, CellComponentProps, useGridRef } from 'react-window'
function MyGrid() {
const gridRef = useGridRef()
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `项目 ${i}` }))
const columnCount = 4
const rowCount = Math.ceil(items.length / columnCount)
// 单元格渲染函数
const Cell = ({ columnIndex, rowIndex, style }: CellComponentProps) => {
const index = rowIndex * columnCount + columnIndex
if (index >= items.length) {
return null // 超出范围不渲染
}
const item = items[index]
return (
<div style={style} className="p-4 border">
{item.name}
</div>
)
}
return (
<Grid
gridRef={gridRef}
cellComponent={Cell}
cellProps={{}}
columnCount={columnCount}
columnWidth={280}
rowCount={rowCount}
rowHeight={200}
width={1200}
style={{
height: '600px',
overflowY: 'auto',
overflowX: 'hidden'
}}
/>
)
}