React 实现一个瀑布流组件(TypeScript) 支持宽度响应式,支持任意列数,支持触底加载,良好的TS类型支持

一、前言

        瀑布流是很常见的布局,移动端、PC端均可使用,主要特点是每个元素等宽,但是不等高

(比如我们逛淘宝,商品的展示就是瀑布流)瀑布流只使用css的话,效果非常不好,且限制特别多,一般都是使用JS进行高度计算

        注:本文的实现,需要提前知道图片的大致宽高比例,可以让后端返回(因为在Img加载出来后,前端才能获取到真实的宽高,不划算)

本文代码示例效果:支持宽度响应式,支持任意列数,支持触底加载,良好的TS类型支持

(图片来源于网络,未用于商业用途,侵权请联系删除)

二、基本原理

        实现瀑布流有很多种思路。这里简要说一下我的思路。

        不要把整个瀑布流看成一个整体,而是每一列拆开看。比如我想分成三列,那么就可以看成下面这样的盒子布局: (黑色的三列)。这样就好办了,不用考虑太多CSS方面的问题只需要让每一列都从上往下排列就行。 也就是需要把原本的列表,一个个的插入到二维数组中。

React 实现一个瀑布流组件(TypeScript) 支持宽度响应式,支持任意列数,支持触底加载,良好的TS类型支持_第1张图片

        但是现在问题来了,我们不能均匀的把每一项分别插入列中,因为如果均匀插入的话,就会出现一个问题:有的列特别长,有的列特别短。 这时候我们就需要知道图片的大致宽高比例,这样才方便我们选择插入 (每一项都选择当前最短的列插入

三、具体实现

        Tip:下面的代码因为断断续续,可能看不明白,可以翻到本文最下面的“完整代码”栏目,复制粘贴到VScode中,本文绝大部分都使用了 /** xxx */ 进行注释,鼠标悬浮到变量上,就能看到当前变量的含义

1.组件基本Props

// 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;
  //....

2.根据窗口大小,响应式计算元素宽度

// 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

3.把每一项插入到合适的位置

这里需要注意的点:

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

//....

 4.下拉触底加载 及 节流

        瀑布流离不开触底加载。但是在监听元素滚动的时候,每一次滚动都会触发数十次事件,所以我们有必要使用节流函数,对其进行限制,优化性能。

        但是还有一个可以优化的点:在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);
    };
};

5.HTML部分

这里就是注意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 */ 进行注释,鼠标悬浮到变量上,就能看到当前变量的含义,更加容易理解

1. 组件源码

位于 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); })}
); })}
); }

2. 响应式图片宽度的Hooks

位于 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

3. 节流函数、深拷贝函数 

位于 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);
    };
};

六、结语

        瀑布流是很常见的一种需求,今天终于自己实现出来了。

        在实现的过程中,一直在考虑优化问题,比如节流、减少重排、图片加载占位符、懒加载等,因为篇幅的问题本文没有详细写出代码,大家可以自行完成

        如果有哪些地方有问题,或者有待优化的,欢迎在评论区指出~

你可能感兴趣的:(React.JS,react.js,前端,typescript)