瀑布流是很常见的布局,移动端、PC端均可使用,主要特点是每个元素等宽,但是不等高。
(比如我们逛淘宝,商品的展示就是瀑布流)瀑布流只使用css的话,效果非常不好,且限制特别多,一般都是使用JS进行高度计算
注:本文的实现,需要提前知道图片的大致宽高比例,可以让后端返回(因为在Img加载出来后,前端才能获取到真实的宽高,不划算)
本文代码示例效果:支持宽度响应式,支持任意列数,支持触底加载,良好的TS类型支持
(图片来源于网络,未用于商业用途,侵权请联系删除)
实现瀑布流有很多种思路。这里简要说一下我的思路。
不要把整个瀑布流看成一个整体,而是每一列拆开看。比如我想分成三列,那么就可以看成下面这样的盒子布局: (黑色的三列)。这样就好办了,不用考虑太多CSS方面的问题,只需要让每一列都从上往下排列就行。 也就是需要把原本的列表,一个个的插入到二维数组中。
但是现在问题来了,我们不能均匀的把每一项分别插入列中,因为如果均匀插入的话,就会出现一个问题:有的列特别长,有的列特别短。 这时候我们就需要知道图片的大致宽高比例,这样才方便我们选择插入 (每一项都选择当前最短的列插入)
Tip:下面的代码因为断断续续,可能看不明白,可以翻到本文最下面的“完整代码”栏目,复制粘贴到VScode中,本文绝大部分都使用了 /** xxx */ 进行注释,鼠标悬浮到变量上,就能看到当前变量的含义。
// Waterfall.tsx
export interface waterfallItem {
/**高:宽的大致比例,用于每一轮获取数据时的估计高度 */
scale: number;
}
/**瀑布流组件的props */
export interface waterfallProps {
/**本组件的外层的ref。用于监听元素的滚动(谁滚动就填谁) */
scrollRef: RefObject;
/**一行个数(要多少列)默认5*/
cols?: number;
/**每列之间的间距,默认30 */
marginX?: number;
/**下拉触底、组件初次渲染时,触发的函数。用来获取新一轮的数据,需要return出新列表 */
getList: () => Promise;
/**元素的渲染函数 */
itemRender: (item: T, i: number) => ReactNode;
}
/**展示瀑布流的组件 */
export default function Waterfall(props: waterfallProps) {
const { scrollRef, cols = 5, marginX = 30, getList, itemRender } = props;
//....
// Waterfall.tsx
// ...承上
/**瀑布流最外层的ref */
const listRef = useRef(null);
/**每一列的ref。是个数组 */
const colRef = useRef<(HTMLDivElement | null)[]>([]);
/**瀑布流每个模块的宽度。随着窗口大小变化而变化 */
const imgWidth = useCalculativeWidth(listRef, marginX, cols);
// ...
其中用到了一个hook: (可能优化的点: 是否使用节流函数?)
// /hooks/clculativeWidth.ts
import { RefObject, useEffect, useState } from "react";
/**根据父节点的ref和子元素的列数等数据,计算出子元素的宽度。用于响应式布局
* @param fatherRef 父节点的ref
* @param marginX 子元素的水平间距
* @param cols 一行个数 (一行有几列)
* @param callback 根据浏览器宽度自动计算大小后的回调函数,参数是计算好的子元素宽度
* @returns 返回子元素宽度的响应式数据
*/
const useCalculativeWidth = (fatherRef: RefObject, marginX: number, cols: number, callback?: (nowWidth: number) => void) => {
const [itemWidth, setItemWidth] = useState(200);
useEffect(() => {
/**计算单个子元素宽度,根据list的宽度计算 */
const countWidth = () => {
const width = fatherRef.current?.offsetWidth;
if (width) {
const _width = (width - marginX * (cols + 1)) / cols;
setItemWidth(_width);
callback && callback(_width)
}
};
countWidth(); //先执行一次,后续再监听绑定
window.addEventListener("resize", countWidth);
return () => window.removeEventListener("resize", countWidth);
}, []);
return itemWidth
}
export default useCalculativeWidth
这里需要注意的点:
1. 为了获取到最新的colList,需要等待 list 变化后才能执行计算,否则拿到的将一直是初始值。
2. 在listToColList 函数中,需要把数据插入到高度最短的列中。每插入一个数据,就需要给这一列加上预估的高度,这样才能让下一个数据插入到正确的位置上
3. 全部插入结束后,需要使用ref重新计算一下当前每一列的真实高度(因为第二点的操作,只是一个预估的高度,并不一定准确,如果不校正,可能会影响后面的插入) (一定要在colList渲染后才去计算!使用useEffect监听它即可)
// Waterfall.tsx
// ...承上
const [list, setList] = useState([]); //用来暂时存储获取到的最新list。
const [colList, setColList] = useState(Array.from({ length: cols }, () => new Array())); //要展示的图片列表,二维数组
const [colHeight, setColHeight] = useState(new Array(cols).fill(0)); //当前每一列的高度
/**获取列表数据 */
const _getList = async () => {
const res = await getList();
setList(res);
};
/**把获取到的列表,按照规律放入二维数组中。 注,需要监听list的变化,再做这个函数,否则无法获取到最新的colList */
const listToColList = (list: T[]) => {
const _colList = deep_JSON(colList); //进行深拷贝
const _colHeight = deep_JSON(colHeight);
for (let i = 0; i < list.length; i++) {
//获取当前最短的列表
let minHeight = Infinity;
let minHeightindex = 0;
_colHeight.forEach((k, i) => {
if (k < minHeight) {
minHeight = k;
minHeightindex = i;
}
});
//加上预估的高度,便于下一个元素正确插入
_colHeight[minHeightindex] += imgWidth * list[i].scale; //预估的图片高度,后面会更换为真实高度
_colList[minHeightindex].push(list[i]);
}
setColList(_colList);
//tip: 计算真实高度的函数,在下面的useEffect中,这样才能保证获取到渲染后的数据
};
useEffect(() => {
_getList();
}, []);
useEffect(() => {
listToColList(list); // 需要监听list的变化,再做这个函数,否则无法获取到最新的colList
}, [list]);
useEffect(() => {
//当数据渲染后,再去计算真实高度
if (colRef.current) {
const newHeight = colRef.current.map((k) => k?.offsetHeight || 0);
setColHeight(newHeight);
}
}, [colList]);
// ...
其中用到了一个深拷贝函数
// lib/util.ts
/**使用JSON做深拷贝 */
export function deep_JSON(data: T): T {
return JSON.parse(JSON.stringify(data));
}
//....
瀑布流离不开触底加载。但是在监听元素滚动的时候,每一次滚动都会触发数十次事件,所以我们有必要使用节流函数,对其进行限制,优化性能。
但是还有一个可以优化的点:在handler函数中,频繁使用了this.scrollHeight,即使有节流函数的限制,仍然会每200ms读取一次,造成页面的重排重绘!
如何优化:因为在没有加载新数据的时候,scrollHeight是不会变动的,所以我们可以使用一个useState来保存当前的scrollHeight (在渲染完 colList 的新数据之后去获取保存)。这样就能避免频繁的重排重绘 (本文代码没有做这个优化,可以自行添加, 很简单) (注:window.innerHeight也基本是一个固定的值,可以缓存起来)
// Waterfall.tsx
// ...承上
useEffect(() => {
//这里的this是 scrollRef.current 。 scrollRef是props中传递过来的
const handler = function (this: HTMLElement, e: Event) {
//scrollHeight是可滚动区域的总高度, innerHeight是可视窗口的高度, scrollTop是盒子可视窗口的最顶部,到盒子可滚动上限的距离
// 还有一个可以性能优化的点, this.scrollHeight 在没有获取新数据时,是固定的,可以存起来成一个变量,获取新数据再更新,减少重排重绘
if (this.scrollHeight - window.innerHeight - this.scrollTop < 10) {
console.log("触底了");
_getList();
}
};
/**利用节流函数,避免频繁的获取元素导致重排重绘,且可以防止触底瞬间多次调用请求函数 */
const throttleHandler = throttle(handler, 200);
scrollRef.current?.addEventListener("scroll", throttleHandler);
return () => scrollRef.current?.removeEventListener("scroll", throttleHandler);
}, []);
// ...
其中使用了一个节流函数: throttle
// lib/utils.ts
/**使用JSON做深拷贝 */
export function deep_JSON(data: T): T {
return JSON.parse(JSON.stringify(data));
}
/**任意参数个数,任意返回值的函数 */
type anyFn = (...param: any[]) => any;
/**节流。思想是一段时间内多次触发,只执行一次 (按很多下平a只触发一次)
* @param func 要触发的函数。调用return的函数时传的参,可以在这里接收到
* @param time 时间间隔。毫秒
* @returns 返回一个函数,可以用于绑定事件。调用时可以给这个函数传参
*/
export const throttle = (func: anyFn, time: number): anyFn => {
/**节流阀 */
let flag = false;
return function (this: any, ...argu) {
if (flag) return;
const context = this;
flag = true;
func.apply(context, argu); //通过剩余参数的形式传递
setTimeout(() => {
//指定时间间隔后关闭节流阀
flag = false;
}, time);
};
};
这里就是注意ref的绑定。 主要是列ref是一个数组,我们需要通过函数的形式绑定ref
ref={(r) => (colRef.current[listI] = r)}
// Waterfall.tsx
//...承上
return (
{colList.map((list, listI) => {
return (
(colRef.current[listI] = r)} style={{ width: imgWidth }}>
{list.map((k, i) => {
return itemRender(k, i);
})}
);
})}
);
在任意一个地方使用:
这里提供了一个两列的测试数据。 (如果想放更多列,为了更好的下拉触底效果,需要添加更多测试数据,以免因为数据太少无法滚动)
(图片链接来源于网络,未用于商业用途,侵权请联系删除)
import Waterfall, { waterfallItem } from "@/components/Waterfall";
import { useRef } from "react";
export default function Home() {
const scrollRef = useRef(null);
/**测试的列表数据 */
interface item extends waterfallItem {
/**图片路径 */
src: string;
/**图片描述 */
text: string;
}
const getList = () => {
const newList: item[] = [
{
src: "https://p2.music.126.net/va9D07KDeS1ovOYAsoXE9A==/7929677859630995.jpg",
text: "测试文字",
scale: 1,
},
{
src: "https://ts1.cn.mm.bing.net/th/id/R-C.b0ea268fa1be279d112489ce83ad4696?rik=qItsh%2fBiy33hlg&riu=http%3a%2f%2fwww.quazero.com%2fuploads%2fallimg%2f140303%2f1-140303215009.jpg&ehk=S6PLWamt%2bMzQV8uO9ugcU5d5M19BpXtCpNz2cRJ7q9M%3d&risl=&pid=ImgRaw&r=0",
text: "测试文字2",
scale: 572 / 982,
},
{
src: "https://scpic.chinaz.net/files/pic/pic9/202009/apic27858.jpg",
text: "测试文字3",
scale: 581 / 434,
},
{
src: "https://ts1.cn.mm.bing.net/th/id/R-C.5245459c4835900f30183bebecb3cb55?rik=koS%2bxytGvrBRHw&riu=http%3a%2f%2fpic.zsucai.com%2ffiles%2f2013%2f0723%2fsdidjj4.jpg&ehk=WJLRakwfHBZS2aO2sK%2bCdh4ijkXwyYijy5Z2BFUdnz4%3d&risl=&pid=ImgRaw&r=0",
text: "测试文字4",
scale: 575 / 356,
},
{
src: "https://ts1.cn.mm.bing.net/th/id/R-C.f40ba86561918519b95431a5921e4f5d?rik=9AIbo9AhOYel0w&riu=http%3a%2f%2fwww.quazero.com%2fuploads%2fallimg%2f131210%2f1-131210210248.jpg&ehk=v81JiWKphT%2baLBzbhrxRkTUUwwnhJ5F2PFkm4xn4nEM%3d&risl=&pid=ImgRaw&r=0",
text: "测试文字5",
scale: 578 / 327,
},
{
src: "https://ts1.cn.mm.bing.net/th/id/R-C.0dee2228031e4ef5b03d0c5734aef866?rik=BD%2bnjbFbllVmEQ&riu=http%3a%2f%2fimg.zcool.cn%2fcommunity%2f01cf02554336f10000019ae9df1dad.jpg%403000w_1l_2o_100sh.jpg&ehk=zvcYgjHlqK2U2x9ploUbmiBIk7BewUd6lyA0AIswegQ%3d&risl=&pid=ImgRaw&r=0",
text: "测试文字6",
scale: 581 / 868,
},
];
//使用定时器模拟HTTP请求,延时1s返回数据
return new Promise- ((resolve) => setTimeout(() => resolve(newList), 1000));
};
return (
瀑布流
{
return (
{item.text}
);
}}
/>
);
}
下面代码复制粘贴到VScode中,因为本文绝大部分都使用了 /** xxx */ 进行注释,鼠标悬浮到变量上,就能看到当前变量的含义,更加容易理解
位于 components/Waterfall.tsx
"use client";
import useCalculativeWidth from "@/hooks/calculativeWidth";
import { deep_JSON, throttle } from "@/lib/util";
import { ReactNode, RefObject, useEffect, useRef, useState } from "react";
/**瀑布流的元素,必须含有这个scale数据 (可以自己改个名)*/
export interface waterfallItem {
/**高:宽的大致比例,用于每一轮获取数据时的估计高度 */
scale: number;
}
/**瀑布流组件的props */
export interface waterfallProps {
/**本组件的外层的ref。用于监听元素的滚动(谁滚动就填谁) */
scrollRef: RefObject;
/**一行个数(要多少列)默认5*/
cols?: number;
/**每列之间的间距,默认30 */
marginX?: number;
/**下拉触底、组件初次渲染时,触发的函数。用来获取新一轮的数据,需要return出新列表 */
getList: () => Promise;
/**元素的渲染函数 */
itemRender: (item: T, i: number) => ReactNode;
}
/**展示瀑布流的组件 */
export default function Waterfall(props: waterfallProps) {
const { scrollRef, cols = 5, marginX = 30, getList, itemRender } = props;
/**瀑布流最外层的ref */
const listRef = useRef(null);
/**每一列的ref。是个数组 */
const colRef = useRef<(HTMLDivElement | null)[]>([]);
/**瀑布流每个模块的宽度。随着窗口大小变化而变化 */
const imgWidth = useCalculativeWidth(listRef, marginX, cols);
const [list, setList] = useState([]); //用来暂时存储获取到的最新list。
const [colList, setColList] = useState(Array.from({ length: cols }, () => new Array())); //要展示的图片列表,二维数组
const [colHeight, setColHeight] = useState(new Array(cols).fill(0)); //当前每一列的高度
/**获取列表数据 */
const _getList = async () => {
const res = await getList();
setList(res);
};
/**把获取到的列表,按照规律放入二维数组中。 注,需要监听list的变化,再做这个函数,否则无法获取到最新的colList */
const listToColList = (list: T[]) => {
const _colList = deep_JSON(colList); //进行深拷贝
const _colHeight = deep_JSON(colHeight);
for (let i = 0; i < list.length; i++) {
//获取当前最短的列表
let minHeight = Infinity;
let minHeightindex = 0;
_colHeight.forEach((k, i) => {
if (k < minHeight) {
minHeight = k;
minHeightindex = i;
}
});
//加上预估的高度,便于下一个元素正确插入
_colHeight[minHeightindex] += imgWidth * list[i].scale; //预估的图片高度,后面会更换为真实高度
_colList[minHeightindex].push(list[i]);
}
setColList(_colList);
//tip: 计算真实高度的函数,在下面的useEffect中,这样才能保证获取到渲染后的数据
};
//初始化列表
useEffect(() => {
_getList();
}, []);
//监听滚动事件,绑定触底加载函数
useEffect(() => {
//这里的this是 scrollRef.current 。 scrollRef是props中传递过来的
const handler = function (this: HTMLElement, e: Event) {
//scrollHeight是可滚动区域的总高度, innerHeight是可视窗口的高度, scrollTop是盒子可视窗口的最顶部,到盒子可滚动上限的距离
// 还有一个可以性能优化的点, this.scrollHeight 在没有获取新数据时,是固定的,可以存起来成一个变量,获取新数据再更新,减少重排重绘
if (this.scrollHeight - window.innerHeight - this.scrollTop < 10) {
console.log("触底了");
_getList();
}
};
/**利用节流函数,避免频繁的获取元素导致重排重绘,且可以防止触底瞬间多次调用请求函数 */
const throttleHandler = throttle(handler, 200);
scrollRef.current?.addEventListener("scroll", throttleHandler);
return () => scrollRef.current?.removeEventListener("scroll", throttleHandler);
}, []);
//监听list的变化,变化了就执行插入二维数组函数
useEffect(() => {
listToColList(list);
}, [list]);
//当数据渲染后,再去计算真实高度
useEffect(() => {
if (colRef.current) {
const newHeight = colRef.current.map((k) => k?.offsetHeight || 0);
setColHeight(newHeight);
}
}, [colList]);
return (
{colList.map((list, listI) => {
return (
(colRef.current[listI] = r)} style={{ width: imgWidth }}>
{list.map((k, i) => {
return itemRender(k, i);
})}
);
})}
);
}
位于 hooks/calculativeWidth.ts
这里是响应式计算图片宽度的hooks
import { RefObject, useEffect, useState } from "react";
/**根据父节点的ref和子元素的列数等数据,计算出子元素的宽度。用于响应式布局
* @param fatherRef 父节点的ref
* @param marginX 子元素的水平间距
* @param cols 一行个数 (一行有几列)
* @param callback 根据浏览器宽度自动计算大小后的回调函数,参数是计算好的子元素宽度
* @returns 返回子元素宽度的响应式数据
*/
const useCalculativeWidth = (fatherRef: RefObject, marginX: number, cols: number, callback?: (nowWidth: number) => void) => {
const [itemWidth, setItemWidth] = useState(200);
useEffect(() => {
/**计算单个子元素宽度,根据list的宽度计算 */
const countWidth = () => {
const width = fatherRef.current?.offsetWidth;
if (width) {
const _width = (width - marginX * (cols + 1)) / cols;
setItemWidth(_width);
callback && callback(_width)
}
};
countWidth(); //先执行一次,后续再监听绑定
window.addEventListener("resize", countWidth);
return () => window.removeEventListener("resize", countWidth);
}, []);
return itemWidth
}
export default useCalculativeWidth
位于 lib/util.ts
深拷贝函数基于JSON,所以不支持不可序列化的对象,比如函数等
其中节流函数是自己手敲的,可能有不准确的地方,但是目前来看没什么问题
/**使用JSON做深拷贝 */
export function deep_JSON(data: T): T {
return JSON.parse(JSON.stringify(data));
}
/**任意参数个数,任意返回值的函数 */
type anyFn = (...param: any[]) => any;
/**节流。思想是一段时间内多次触发,只执行一次 (按很多下平a只触发一次)
* @param func 要触发的函数。调用return的函数时传的参,可以在这里接收到
* @param time 时间间隔。毫秒
* @returns 返回一个函数,可以用于绑定事件。调用时可以给这个函数传参
*/
export const throttle = (func: anyFn, time: number): anyFn => {
/**节流阀 */
let flag = false;
return function (this: any, ...argu) {
if (flag) return;
const context = this;
flag = true;
func.apply(context, argu); //通过剩余参数的形式传递
setTimeout(() => {
//指定时间间隔后关闭节流阀
flag = false;
}, time);
};
};
瀑布流是很常见的一种需求,今天终于自己实现出来了。
在实现的过程中,一直在考虑优化问题,比如节流、减少重排、图片加载占位符、懒加载等,因为篇幅的问题本文没有详细写出代码,大家可以自行完成
如果有哪些地方有问题,或者有待优化的,欢迎在评论区指出~