瀑布流长列表滚动卡顿怎么优化?

Air-欣怡 阅读 4

我用 React 实现了一个图片瀑布流,数据一多滚动就特别卡,试过给每个 item 加 key 但没用。是不是得用虚拟滚动?但瀑布流高度不一致,普通的 react-window 好像不支持……

这是我的简化代码:

const WaterfallList = ({ items }) => {
  return (
    <div className="columns-2 gap-4">
      {items.map(item => (
        <div key={item.id} className="break-inside-avoid mb-4">
          <img src={item.url} alt="" className="w-full" />
        </div>
      ))}
    </div>
  );
};
我来解答 赞 1 收藏
二维码
手机扫码查看
1 条解答
UX瑞腾
UX瑞腾 Lv1
瀑布流卡顿的根本原因是DOM节点太多,浏览器渲染压力太大。你现在用的是CSS columns实现的瀑布流,这种方式虽然简单,但有一个致命问题:所有图片元素都在DOM里,哪怕用户只能看到屏幕范围内的二三十张,后面几百张图片的DOM节点也都存在,滚动时浏览器要计算它们的布局,当然卡。

加key没用是因为key只影响React的diff算法,不影响实际DOM数量。

解决方案有两个层面,我先说简单有效的:

第一步:图片懒加载

这是最容易上手的优化,很多卡顿其实是图片同时加载导致的。用Intersection Observer让图片进入可视区域再加载真实URL:

import { useState, useEffect, useRef } from 'react';

// 单个图片组件 - 只有进入可视区域才加载
const LazyImage = ({ src, alt }) => {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(false);
const imgRef = useRef(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // 提前100px开始加载
);

if (imgRef.current) {
observer.observe(imgRef.current);
}

return () => observer.disconnect();
}, []);

return (
<div ref={imgRef} className="break-inside-avoid mb-4">
{inView ? (
<img
src={src}
alt={alt}
className="w-full"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
) : (
<div className="bg-gray-200" style={{ minHeight: '200px' }} />
)}
</div>
);
};


第二步:分页加载

一次不要渲染所有数据,比如每次只加载20-30条,用户滚动到底部再加载更多:

const WaterfallList = ({ initialItems, fetchMore }) => {
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const loaderRef = useRef(null);

// 分页加载更多
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);

const newItems = await fetchMore(); // 获取下一页数据
setItems(prev => [...prev, ...newItems]);
setLoading(false);
}, [loading, fetchMore]);

// 监听滚动到底部
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 }
);

if (loaderRef.current) {
observer.observe(loaderRef.current);
}

return () => observer.disconnect();
}, [loadMore]);

// 简单的列分配逻辑
const columns = [[], [], []];
const heights = [0, 0, 0];

items.forEach(item => {
const shortest = heights.indexOf(Math.min(...heights));
columns[shortest].push(item);
heights[shortest] += item.height || 300;
});

return (
<div className="flex gap-4 px-4">
{columns.map((column, i) => (
<div key={i} className="flex-1">
{column.map(item => (
<LazyImage key={item.id} src={item.url} alt={item.title} />
))}
</div>
))}

{/* 加载更多触发器 */}
<div ref={loaderRef} className="h-10 w-full flex justify-center items-center">
{loading && <span>加载中...</span>}
</div>
</div>
);
};


第三步:虚拟滚动(数据量特别大时用)

如果你的数据量达到几千条,上面两个优化可能还不够,需要虚拟滚动。瀑布流的虚拟滚动确实比普通列表复杂,因为每列高度不一致,需要动态计算位置。

原理是这样的:只渲染可视区域内的图片,根据每列的累积高度算出每个item的绝对定位。给你一个简化版的实现思路:

import { useState, useEffect, useRef, useCallback } from 'react';

const VirtualWaterfall = ({ items, columnCount = 3, itemHeight = 300 }) => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 30 });
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const containerHeight = 800; // 容器高度,可从useRef获取

// 预计算每列高度
const columnHeights = new Array(columnCount).fill(0);
const itemPositions = items.map((item, index) => {
const col = columnHeights.indexOf(Math.min(...columnHeights));
const top = columnHeights[col];
columnHeights[col] += item.height || itemHeight;
return { col, top, height: item.height || itemHeight };
});

const totalHeight = Math.max(...columnHeights);

// 监听滚动
const handleScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);

// 计算可视范围
const viewTop = scrollTop;
const viewBottom = viewTop + containerHeight;

let start = 0, end = items.length;

// 二分查找起始位置
let left = 0, right = items.length - 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (itemPositions[mid].top + (itemPositions[mid].height || 0) < viewTop) {
left = mid + 1;
} else {
right = mid;
}
}
start = Math.max(0, left - 10);

// 找到结束位置
for (let i = start; i < items.length; i++) {
if (itemPositions[i].top > viewBottom) {
end = i + 10;
break;
}
}

setVisibleRange({ start, end: Math.min(end, items.length) });
}, [scrollTop, items.length]);

useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);

return (
<div
ref={containerRef}
style={{ height: '100vh', overflow: 'auto', position: 'relative' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{items.slice(visibleRange.start, visibleRange.end).map((item, index) => {
const actualIndex = visibleRange.start + index;
const pos = itemPositions[actualIndex];
const colWidth = 100 / columnCount;

return (
<div
key={item.id}
style={{
position: 'absolute',
top: pos.top,
left: ${pos.col * colWidth}%,
width: ${colWidth}%,
padding: '0 8px',
boxSizing: 'border-box'
}}
>
<LazyImage src={item.url} alt={item.title} />
</div>
);
})}
</div>
</div>
);
};


这个虚拟滚动的核心思路是:先根据预估高度把每个item应该放的位置算出来(绝对定位),然后滚动时只渲染落在可视区域附近的那些item。

不过说实话,虚拟滚动实现起来坑比较多。如果你数据量不是特别夸张(几千条以上),前面两步优化通常就够了。实际项目中我更推荐用现成的库,比如react-virtualized或者react-window他们都有社区版的瀑布流解决方案,或者用react-waterfall-layout这个专门为瀑布流设计的库。

你现在先试试前两步,应该能明显改善。有什么问题再问。
点赞
2026-03-20 05:02