🧠 一、瀑布流到底是什么?
👉 本质不是“流式布局”,而是:多列不等高布局 + 最短列插入算法
核心逻辑:
- 维护每一列高度
- 每次插入 → 找最矮的列
// 最矮列
const minHeight = Math.min(...heights);
// 最矮列下标
const minIndex = heights.indexOf(minHeight);
❓ 二、为什么不能用纯 CSS 实现?
核心区别:👉 CSS 无法根据元素高度做动态决策
❌ CSS 的本质限制
CSS 只能:
- 按 DOM 顺序布局
- 声明规则(不是算法)
但瀑布流需要:👉 运行时决策(哪一列最短)
❌ 常见错误方案
-
columns 👉 从上到下排 → ❌ 不平衡
-
grid 👉 行高被最高元素撑开 → ❌ 留白
-
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>
);
}
🎯 五、拖拽排序
👉 数据驱动,而不是 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…