瀑布流式布局
瀑布流式页面布局是一种常见的网页布局方式,其特点是页面上的内容以多栏形式展示,每栏的宽度相同但高度不同,形成一种参差不齐的视觉效果。
这种布局方式适用于展示图片、文章等内容,尤其适合于图片和视频网站,如抖音、花瓣、小红书等。
瀑布流布局的主要优点包括节省空间、提升用户体验和良好的视觉效果,但同时也存在一些缺点,如内容总长度难以掌握、页面加载负荷较大等。
原理解析
1️⃣ 布局原理(Masonry / 瀑布流)
瀑布流布局的核心思想:
-
多列固定宽度,纵向动态排布
- 假设有
n列,每列宽度固定(或者根据屏幕宽度自适应)。 - 图片按列填充,每次放到当前最短的列,保证列高差最小。
- 假设有
-
不规则高度图片
-
每张图片高度可能不同,所以每列高度累加不同。
-
常见算法:
初始化列高数组 colHeights = [0, 0, 0, 0] // 4列 遍历每张图片: 找到 colIndex = colHeights 中最小值索引 图片放到 colIndex colHeights[colIndex] += 图片高度 + 间距 -
这样就能“自然落下”,像水一样堆砌。
-
-
响应式布局
- 屏幕宽度改变 → 重新计算列数和列宽 → 再按最短列填充。
2️⃣ 数据渲染机制
小红书首页图片流并不是一次性渲染全部图片,而是典型的 虚拟滚动 + 懒加载:
-
分页加载 / 无限滚动:
- 初始加载几屏图片,用户滚动到底部时触发下拉请求更多数据。
-
懒加载图片:
- 只渲染可视区域的图片,未显示的图片延迟加载。
- 避免一次性渲染几百张大图导致 DOM 卡顿。
-
占位符 / skeleton:
- 图片未加载完成前,用固定高度占位,避免页面跳动。
- 图片加载完成 → 替换占位符 → 列高累加调整。
3️⃣ 性能优化技巧
-
虚拟列表 / 瀑布流结合
- 前端只渲染可视区域的图片 DOM。
- 常用库:
react-virtualized+react-masonry-component/MasonryLayout。
-
图片尺寸优化
- 根据屏幕密度生成多版本图片(WebP / AVIF)。
- 服务端返回合理高度,前端可直接计算列高,无需等待图片加载完成。
-
滚动事件节流
- 滚动触发加载更多或布局调整时,需要 throttle 或 debounce,避免频繁重排。
-
占位空间预计算
- 小红书可能会在服务端返回每张图片的宽高比,前端直接算出占位高度 → 避免 layout shift。
4️⃣ 总结
| 核心概念 | 小红书做法 |
|---|---|
| 列布局 | 多列固定宽度,按最短列堆叠 |
| 图片加载 | 懒加载 + 占位符 |
| 数据加载 | 分页 / 无限滚动 |
| 性能优化 | 虚拟列表 + 图片尺寸预计算 + 滚动节流 |
import React, { useState } from 'react';
interface Item {
id: number;
height: number; // 模拟图片高度
url: string; // 图片地址
}
const items: Item[] = [
{ id: 1, height: 150, url: 'https://via.placeholder.com/150x150' },
{ id: 2, height: 200, url: 'https://via.placeholder.com/150x200' },
{ id: 3, height: 120, url: 'https://via.placeholder.com/150x120' },
{ id: 4, height: 180, url: 'https://via.placeholder.com/150x180' },
{ id: 5, height: 220, url: 'https://via.placeholder.com/150x220' },
];
export default function TwoColumnMasonry() {
// 两列布局
const [col1, col2] = items.reduce<[Item[], Item[]]>(
(acc, item) => {
const [c1, c2] = acc;
// 计算当前每列高度
const h1 = c1.reduce((sum, i) => sum + i.height, 0);
const h2 = c2.reduce((sum, i) => sum + i.height, 0);
// 放入高度小的一列
if (h1 <= h2) c1.push(item);
else c2.push(item);
return acc;
},
[[], []]
);
return (
<div style={{ display: 'flex', gap: '10px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{col1.map(item => (
<img key={item.id} src={item.url} style={{ width: '100%', height: item.height }} />
))}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{col2.map(item => (
<img key={item.id} src={item.url} style={{ width: '100%', height: item.height }} />
))}
</div>
</div>
);
}
模拟小红书布局
效果图如下所示:
1. 容器组件
准备数据
声明初始变量
/** 是否到达底部,到达底部加载更多 **/
let isLoadingData = useRef(false);
/** 容器内渲染的数据列表 **/
const [list, setList] = useState([]);
/** 渲染的数据对应的样式列表 **/
const [styleList, setStyleList] = useState([]);
/** 当前容器信息 **/
const [frameInfo, setFrameInfo] = useState({
height: 0,
width: 0,
});
/** 容器向上滚动的距离 **/
const [scrollTop, setScrollTop] = useState(0);
/** 外层容器实例 **/
const waterfallFlowDom = useRef(null);
let waterfallFlowListInfo = useRef([]);
/** 自定义骨架屏高度 **/
let heightList = [170, 230, 300];
根据容器尺寸计算每行列数
const rowNum = useMemo(() => {
let width = frameInfo.width || 0;
if (width >= 1200) {
return 6;
} else if (width > 768 && width <= 1199) {
return 4;
} else {
return 2;
}
}, [frameInfo]);
计算列公共宽度
// 间距是4px
const cluWidth = useMemo(() => {
return (frameInfo.width - (rowNum - 1) * 4) / rowNum;
}, [frameInfo, rowNum]);
初始加载数据
监听容器滚动事件,触底加载更多
监听元素进入和离开容器组件,可以做性能优化
获取每个Item的样式
const getStyleList = useCallback(() => {
// 样式列表
let temporaryStyleList = styleList;
// 最后一行的index
let bottomItemIndex = [];
for (let i = 0; i < list.length; i++) {
let currentRow = Math.floor(i / rowNum); // 当前i应该出现的行数
let remainder = (i % rowNum) + 1; // i是奇数还是偶数
let minHeightInd = 0; // 高度最低的item的下标
let minHeight = 99999999; // 高度最低的item的高度
/** 找最低高度下标 **/
if (currentRow === 0) {
bottomItemIndex[i] = i; // 第一行正常排列
} else {
// 遍历当前最后一行,找到最小高度的item的高度和下标
for (let j = 0; j < bottomItemIndex.length; j++) {
if (waterfallFlowListInfo.current[bottomItemIndex[j]].top + waterfallFlowListInfo.current[bottomItemIndex[j]].height < minHeight
) {
minHeightInd = j;
minHeight = waterfallFlowListInfo.current[bottomItemIndex[j]].top + waterfallFlowListInfo.current[bottomItemIndex[j]].height;
}
}
bottomItemIndex[minHeightInd] = i;
}
if (waterfallFlowListInfo.current[i] === undefined) {
waterfallFlowListInfo.current[i] = {};
}
// 第一行特殊处理,一定是从左到右铺的
if (currentRow === 0) {
if (remainder === 1) {
waterfallFlowListInfo.current[i].left = 0;
} else {
waterfallFlowListInfo.current[i].left = waterfallFlowListInfo.current[i - 1].left + cluWidth + 4;
}
waterfallFlowListInfo.current[i].top = 0;
}
// 剩下的行数,铺在当前最低高度下面
else {
waterfallFlowListInfo.current[i].left = waterfallFlowListInfo.current[minHeightInd].left;
waterfallFlowListInfo.current[i].top = minHeight + 25;
}
// 是否已经有高度,有高度使用已有高度,否则随机生成
waterfallFlowListInfo.current[i].height = waterfallFlowListInfo.current[i].height || heightList[createRandomNum(0, 2)];
temporaryStyleList[i] = {
transform:`translate(${waterfallFlowListInfo.current[i].left}px,${waterfallFlowListInfo.current[i].top}px)`,
width: `${cluWidth}px`,
height: waterfallFlowListInfo.current[i].height,
};
}
return [...temporaryStyleList];
}, [list, rowNum, cluWidth]);
// 生成随机数
const createRandomNum = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
监听rowNum、cloWidth、list重新计算样式列表
useEffect(() => {
setStyleList(getStyleList());
}, [cluWidth, rowNum, list]);
组件设置
// 展示边界
const showBorder = useMemo(() => {
return scrollTop + window.document.documentElement.clientHeight;
}, [scrollTop]);
// 每个item图片加载完后更新item高度
const onSizeChange = useCallback((height, index) => {
if (waterfallFlowListInfo.current[index] === undefined) {
waterfallFlowListInfo.current[index] = {};
}
waterfallFlowListInfo.current[index].height = height;
setStyleList(getStyleList());
},[getStyleList]);
2. Item组件
图片高度设置
const imgHeight = useMemo(() => {
return imgInfo.height * (cluWidth / imgInfo.width);
}, [imgInfo, cluWidth])
3. Img组件
图片加载完成后监听目标元素
完整项目地址:gitee.com/nanfriend/w…