🚀 实现小红书瀑布流|可拖拽 |虚拟渲染

770 阅读4分钟

🧠 一、瀑布流到底是什么?

👉 本质不是“流式布局”,而是:多列不等高布局 + 最短列插入算法

核心逻辑:

  • 维护每一列高度
  • 每次插入 → 找最矮的列
// 最矮列
const minHeight = Math.min(...heights);
// 最矮列下标
const minIndex = heights.indexOf(minHeight);

❓ 二、为什么不能用纯 CSS 实现?

核心区别:👉 CSS 无法根据元素高度做动态决策

❌ CSS 的本质限制

CSS 只能:

  • 按 DOM 顺序布局
  • 声明规则(不是算法)

但瀑布流需要:👉 运行时决策(哪一列最短)

❌ 常见错误方案

  1. columns 👉 从上到下排 → ❌ 不平衡

  2. grid 👉 行高被最高元素撑开 → ❌ 留白

  3. flex 👉 按行排 → ❌ 不是真瀑布流

瀑布流是基于内容尺寸的动态布局问题,而 CSS 不具备跨元素高度感知能力,因此必须使用 JS 计算。

🧱 三、基础实现

针对基于内容尺寸动态布局的实现方式有多种,最常用的是react-masonry-css插件或者手写方法计算。

1️⃣ react-masonry-css

import Masonry from "react-masonry-css";
import './index.css';
import { breakpointColumnsObj, data } from "./config";

export default function Waterfall() {
  return (
      <Masonry
          breakpointCols={breakpointColumnsObj}
          className="my-masonry-grid"
          columnClassName="my-masonry-grid_column"
      >
        {data.map(item => (
           <div key={item.id} className="my-masonry-grid_item">
              <img src={item.img} className="my-masonry-grid_image"/>
           </div>
        ))}
      </Masonry>
    );
  }

2️⃣ 最短列插入算法

function buildColumns(data, columnCount) {
  const columns = Array.from({ length: columnCount }, () => []);
  const heights = new Array(columnCount).fill(0);

  data.forEach(item => {
    const minIndex = heights.indexOf(Math.min(...heights));
    columns[minIndex].push(item);
    heights[minIndex] += item.height;
  });

  return columns;
}

return (
  <div className="my-masonry-grid_2">
    {columns.map((col, i) => (
      <div key={i} className="my-masonry-grid_column_2">
        {col.map((item) => (
          <div key={item.id}>
            <img src={item.img} className="my-masonry-grid_image"/>
          </div>
        ))}
      </div>
    ))}
  </div>
);

🚀 四、工程级完整方案

📦 架构分层

1️⃣ 布局层:瀑布流算法 / react-masonry-css

2️⃣ 数据层:分页 / 缓存 / 去重

3️⃣ 渲染层:虚拟列表 / 骨架屏

4️⃣ 图片层(重点🔥):CDN 返回宽高 / lazy load / blur 占位

5️⃣ 交互层:无限滚动 / resize 响应式

⚡ 性能优化

🧩 1. 懒加载 vs 虚拟列表

技术作用
懒加载图片不加载
虚拟列表DOM不渲染

👉 必须同时用

🧩 2. 图片优化(最重要🔥)

服务端返回必要数据:

{ "width": 1080, "height": 1440 }

前端直接算比例:

height / width

👉 无抖动(CLS优化)

🧩 3. 无限滚动

IntersectionObserver 设置监听加载更多

🌰 示例代码

import { useEffect, useMemo, useState } from "react";
import { breakpointColumnsObj, data, type Item } from "./config";

export default function ImageWaterFall() {
  const [columnCount, setColumnCount] = useState(getColumnCount(window.innerWidth));

  const columns = useMemo(() => {
    // 初始化列和高度数组
    const cols: Item[][] = Array.from({ length: columnCount }, () => []);
    const heights = new Array(columnCount).fill(0);
    data.forEach((item) => {
      const minIndex = heights.indexOf(Math.min(...heights));
      cols[minIndex].push(item);
      heights[minIndex] += item.height;
    });
    return cols;
  }, [columnCount]);

  useEffect(() => {
    const handleResize = () => {
      const newColumns = getColumnCount(window.innerWidth);
      setColumnCount(newColumns);
    };

    window.addEventListener("resize", handleResize);
    handleResize();

    return () => {
      window.removeEventListener("resize", handleResize);
    };

  }, []);

  return ( 
    <div className="my-masonry-grid_2">
      {columns.map((col, i) => ( 
        <div key={i} className="my-masonry-grid_column_2">
          {col.map((item) => ( 
            <div key={item.id}> 
              <img src="{item.img}" class="my-masonry-grid_image" loading="lazy" alt="转存失败,建议直接上传图片文件"> 
            </div>
          ))} 
        </div>
      ))} 
    </div>
  );
}

image.pngimage.png

🎯 五、拖拽排序

👉 数据驱动,而不是 DOM 操作

拖拽 → 修改数据 → 重新布局

🔥 数据驱动

function reorder(list, from, to) {
  const newList = [...list];
  const [item] = newList.splice(from, 1);
  newList.splice(to, 0, item);
  return newList;
}

⚠️ 核心要素

  • 视觉错位 👉 用“占位元素”
  • 性能问题 👉 局部更新 + transform动画(FLIP)
  • 数据结构 👉 必须维护一维数组(不要直接操作 columns)

👉 使用 FLIP:First → Last → Invert → Play 实现丝滑移动,而不是跳动

终极方案 👉 按列虚拟化(最优)

1️⃣ 工具库安装

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

2️⃣ 类型声明

interface Item {
  id: string;
  img: string;
  height: number;
}

3️⃣ 数据准备

export const data: Item[] = [
    { id: "1", img: "https://img1.baidu.com/it/u=1980232812,3255042729&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=889", height: 889 },
    { id: "2", img: "https://img2.baidu.com/it/u=3838928867,2573503587&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=1103", height: 1103 },
    { id: "3", img: "https://img0.baidu.com/it/u=156794930,2573069428&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=613", height: 613 },
    { id: "4", img: "https://img1.baidu.com/it/u=749628616,24861745&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=606", height: 606 },
    { id: "5", img: "https://img0.baidu.com/it/u=2492442383,4103078564&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=670", height: 670 },
    { id: "6", img: "https://img0.baidu.com/it/u=156794930,2573069428&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=612", height: 612 },
    { id: "7", img: "https://img1.baidu.com/it/u=2681286390,4029274483&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=1084", height: 1084 },
    { id: "8", img: "https://img1.baidu.com/it/u=1980232812,3255042729&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=889", height: 889 },
    { id: "9", img: "https://img2.baidu.com/it/u=3838928867,2573503587&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=1103", height: 1103 },
    { id: "10", img: "https://img0.baidu.com/it/u=156794930,2573069428&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=613", height: 613 },
    { id: "11", img: "https://img1.baidu.com/it/u=749628616,24861745&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=606", height: 606 },
    { id: "12", img: "https://img0.baidu.com/it/u=2492442383,4103078564&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=670", height: 670 },
    { id: "13", img: "https://img0.baidu.com/it/u=2492442383,4103078564&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=670", height: 670 },
];

export const breakpointColumnsObj = {
    default: 4,
    1100: 3,
    700: 2,
    500: 1
};

4️⃣ 构建瀑布流

function buildColumns(data: Item[], columnCount: number) {
  const columns: Item[][] = Array.from({ length: columnCount }, () => []);
  const heights = new Array(columnCount).fill(0);
  data.forEach(item => {
    const minIndex = heights.indexOf(Math.min(...heights));
    columns[minIndex].push(item);
    heights[minIndex] += item.height;
  });
  return columns;
}

5️⃣ 响应式列计算

function getColumnCount(width: number){
  let columnCount = breakpointColumnsObj.default;
  const sortedBreakpoints = Object.keys(breakpointColumnsObj).filter((key) => key !== "default").map(Number).sort((a, b) => b - a); // 从大到小
  for (const bp of sortedBreakpoints) {
    if (width <= bp) {
      columnCount = breakpointColumnsObj[bp as keyof typeof breakpointColumnsObj];
    }
  }
  return columnCount;
};

6️⃣ 核心组件

import { useState, useMemo } from "react";
import { DndContext, closestCenter, DragEndEvent, DragStartEvent, DragOverlay } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

// 卡片
function Card({ item }: { item: Item }) {
  return (
    <div className="bg-white rounded-xl shadow p-2" style={{ height: item.height }}>
      <img src={item.img} className="w-full rounded-lg" draggable={false} />
    </div>
  );
}

// 占位
function Placeholder({ height }: { height: number }) {
  return <div className="bg-gray-200 rounded-xl" style={{ height }} />;
}

// 可排序项
function SortableItem({ item }: { item: Item }) {
  const { attributes, listeners, setNodeRef } = useSortable({ id: item.id });
  return (
    <div ref={setNodeRef} {...attributes} {...listeners}>
      <Card item={item} />
    </div>
  );
}

// 主组件
export default function WaterfallDnD({ data }: { data: Item[] }) {
  const [list, setList] = useState(data); // 图片集
  const [draggingId, setDraggingId] = useState<string | null>(null); // 正在拖拽的图片ID
  const columnCount = getColumnCount(); // 响应式列数
  const columns = useMemo(() => buildColumns(list, columnCount), [list]); // 列数据

  const flatIds = useMemo(() => list.map(i => i.id), [list]);
  
  const activeItem = draggingId ? list.find(i => i.id === draggingId) : null;

  const handleDragStart = (event: DragStartEvent) => {
    setDraggingId(event.active.id);
  };

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    setDraggingId(null);
    if (!over) return;

    const oldIndex = list.findIndex(i => i.id === active.id);
    const newIndex = list.findIndex(i => i.id === over.id);
    if (oldIndex === newIndex) return;

    const newList = [...list];
    const [moved] = newList.splice(oldIndex, 1);
    newList.splice(newIndex, 0, moved);
    setList(newList);
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
      <SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
        <div className="flex gap-4 p-4">
          {columns.map((col, i) => (
            <div key={i} className="flex-1 flex flex-col gap-4">
              {col.map(item =>
                draggingId === item.id ? <Placeholder key={item.id} height={item.height} /> : <SortableItem key={item.id} item={item} />
              )}
            </div>
          ))}
        </div>
      </SortableContext>

      {/* 拖拽悬浮层 */}
      <DragOverlay>
        {activeItem ? <Card item={activeItem} /> : null}
      </DragOverlay>
    </DndContext>
  );
}

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