今天我就来分享一下在 React 中实现瀑布流布局的完整历程,包括踩过的坑和最终的优化方案。
初探瀑布流:最简单的奇偶排列
最开始我尝试用最直观的方式实现——按奇偶数列排列。具体思路是把数据分成两列,奇数项放左边,偶数项放右边:
function SimpleWaterfall({ items }) {
const leftColumn = items.filter((_, index) => index % 2 === 0);
const rightColumn = items.filter((_, index) => index % 2 === 1);
return (
<div className="waterfall-container">
<div className="column">
{leftColumn.map(item => (
<WaterfallItem key={item.id} item={item} />
))}
</div>
<div className="column">
{rightColumn.map(item => (
<WaterfallItem key={item.id} item={item} />
))}
</div>
</div>
);
}
对应的 CSS 也很简单:
.waterfall-container {
display: flex;
gap: 16px;
}
.column {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
这种实现确实简单粗暴,在小规模数据下看起来也不错。但当我用真实项目数据测试时,问题很快就暴露出来了...
问题浮现:极端情况下的布局灾难
在测试过程中,我遇到了两个极端情况:
- 连续多个高度相近的奇数项:导致左列明显比右列长很多
- 某个特别高的项目:如果这个高项目恰好排在某一列,整列会被拉得很长
举个例子,假设我们有以下高度数据(单位px):
[300, 150, 300, 150, 300, 150]
按照奇偶排列的结果是:
- 左列:300, 300, 300(总高900)
- 右列:150, 150, 150(总高450)
两列高度差达到了450px!这完全破坏了瀑布流应该保持的平衡美感。
解决方案:动态计算列高度
为了解决这个问题,我研究后发现需要动态计算列高度,始终把新项目添加到当前较矮的那一列。下面是改进后的实现:
function DynamicWaterfall({ items }) {
const columnRefs = [useRef(null), useRef(null)];
const [columns, setColumns] = useState([[], []]);
useEffect(() => {
const newColumns = [[], []];
items.forEach(item => {
// 获取两列当前高度
const heights = columnRefs.map(ref =>
ref.current?.clientHeight || 0
);
// 添加到较矮的列
const targetCol = heights[0] <= heights[1] ? 0 : 1;
newColumns[targetCol].push(item);
});
setColumns(newColumns);
}, [items]);
return (
<div className="waterfall-container">
{columns.map((column, index) => (
<div key={index} ref={columnRefs[index]} className="column">
{column.map(item => (
<WaterfallItem key={item.id} item={item} />
))}
</div>
))}
</div>
);
}
这个方案通过实时计算列高度,确保了两列的平衡。但很快我又发现了新的性能问题...
性能优化:避免不必要的计算
在快速滚动加载大量数据时,频繁的DOM查询(clientHeight)导致了明显的卡顿。于是我引入了两个优化:
- 使用ResizeObserver替代直接高度查询
- 记录高度状态避免重复计算
优化后的核心逻辑:
function OptimizedWaterfall({ items }) {
const [columns, setColumns] = useState([[], []]);
const [heights, setHeights] = useState([0, 0]);
const observers = useRef([]);
// 初始化ResizeObserver
useEffect(() => {
const callback = (entries) => {
const newHeights = [...heights];
entries.forEach(entry => {
const index = observers.current.indexOf(entry.target);
if (index !== -1) {
newHeights[index] = entry.contentRect.height;
}
});
setHeights(newHeights);
};
const observer = new ResizeObserver(callback);
observers.current.forEach(el => observer.observe(el));
return () => observer.disconnect();
}, []);
// 动态分配项目
useEffect(() => {
const newColumns = [[], []];
items.forEach(item => {
const targetCol = heights[0] <= heights[1] ? 0 : 1;
newColumns[targetCol].push(item);
});
setColumns(newColumns);
}, [items, heights]);
// 省略渲染部分...
}
锦上添花:实现懒加载
最后,为了让长列表滚动更流畅,我加入了懒加载功能:
function LazyLoadWaterfall({ initialItems, fetchMore }) {
const [items, setItems] = useState(initialItems);
const loaderRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchMore().then(newItems => {
setItems(prev => [...prev, ...newItems]);
});
}
});
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [fetchMore]);
return (
<>
<OptimizedWaterfall items={items} />
<div ref={loaderRef} className="loader">加载中...</div>
</>
);
}
经验总结
通过这次实践,我学到了几个重要经验:
- 简单的奇偶排列只适合高度均匀的项,真实项目必须考虑动态高度
- 直接查询DOM性能堪忧,ResizeObserver是更好的选择
- 懒加载能显著提升长列表性能,但要注意加载阈值
- React中的瀑布流需要状态驱动,纯CSS方案难以实现动态平衡
最终效果在我的个人项目中运行良好,即使加载上千项也能保持流畅。希望这篇文章能帮助到正在探索瀑布流布局的你!