瀑布流式布局

726 阅读5分钟

瀑布流式布局

瀑布流式页面布局是一种常见的网页布局方式,其特点是页面上的内容以多栏形式展示,每栏的宽度相同高度不同,形成一种参差不齐的视觉效果。

这种布局方式适用于展示图片、文章等内容,尤其适合于图片和视频网站,如‌抖音、‌花瓣、‌小红书等。

瀑布流布局的主要优点包括节省空间、提升用户体验和良好的视觉效果,但同时也存在一些缺点,如内容总长度难以掌握页面加载负荷较大等。

image.png

原理解析

1️⃣ 布局原理(Masonry / 瀑布流)

瀑布流布局的核心思想:

  1. 多列固定宽度,纵向动态排布

    • 假设有 n 列,每列宽度固定(或者根据屏幕宽度自适应)。
    • 图片按列填充,每次放到当前最短的列,保证列高差最小。
  2. 不规则高度图片

    • 每张图片高度可能不同,所以每列高度累加不同。

    • 常见算法:

      初始化列高数组 colHeights = [0, 0, 0, 0] // 4列
      遍历每张图片:
          找到 colIndex = colHeights 中最小值索引
          图片放到 colIndex
          colHeights[colIndex] += 图片高度 + 间距
      
    • 这样就能“自然落下”,像水一样堆砌。

  3. 响应式布局

    • 屏幕宽度改变 → 重新计算列数和列宽 → 再按最短列填充。

2️⃣ 数据渲染机制

小红书首页图片流并不是一次性渲染全部图片,而是典型的 虚拟滚动 + 懒加载

  • 分页加载 / 无限滚动

    • 初始加载几屏图片,用户滚动到底部时触发下拉请求更多数据。
  • 懒加载图片

    • 只渲染可视区域的图片,未显示的图片延迟加载。
    • 避免一次性渲染几百张大图导致 DOM 卡顿。
  • 占位符 / skeleton

    • 图片未加载完成前,用固定高度占位,避免页面跳动。
    • 图片加载完成 → 替换占位符 → 列高累加调整。

3️⃣ 性能优化技巧

  1. 虚拟列表 / 瀑布流结合

    • 前端只渲染可视区域的图片 DOM。
    • 常用库:react-virtualized + react-masonry-component / MasonryLayout
  2. 图片尺寸优化

    • 根据屏幕密度生成多版本图片(WebP / AVIF)。
    • 服务端返回合理高度,前端可直接计算列高,无需等待图片加载完成。
  3. 滚动事件节流

    • 滚动触发加载更多或布局调整时,需要 throttle 或 debounce,避免频繁重排。
  4. 占位空间预计算

    • 小红书可能会在服务端返回每张图片的宽高比,前端直接算出占位高度 → 避免 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>
  );
}

模拟小红书布局

效果图如下所示:

image.pngimage.png

1. 容器组件

准备数据

image.png

声明初始变量

/** 是否到达底部,到达底部加载更多 **/
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]);

初始加载数据

image.png

监听容器滚动事件,触底加载更多

image.pngimage.png

监听元素进入和离开容器组件,可以做性能优化

image.png

获取每个Item的样式

image.png

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]);

组件设置

image.png

// 展示边界
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组件

图片加载完成后监听目标元素

image.png

完整项目地址gitee.com/nanfriend/w…