10分钟带你认识瀑布流,并封装一个瀑布流组件🌈🌈

526 阅读8分钟

1. 起步

瀑布流是一种流行的网站页面布局方式,其特点是多栏等宽不等高的排列,随着用户滚动页面,数据块不断加载并附加至当前尾部

注意上面的加粗部分,这就是瀑布流的主要特征,本次我们要实现瀑布流组件的封装,并针对h5端和web端适配,效果如下:

1.jpg

2.png

2. 实现

关于瀑布流布局的实现方案,我总共调研出来了三种方案:

  • JavaScript+绝对定位实现:使用JavaScript计算每个元素的水平和垂直位置,并通过position: absolute;进行定位。
  • Flexbox布局:将外层设置为display: flex; flex-direction: row;,内层每个列设置为display: flex; flex-direction: column;
  • column属性:通过CSS3的column-count属性,可以指定列数,并利用break-inside: avoid;防止内容被拆分到多列。这种方式简单易用,适合静态内容布局。

本次实现我们采用第一种实现方式,其它两种实现方式如果感兴趣的话可以去查找其它具体的资料。

在正式写代码前一定要捋清写代码的思路,使用Javascript+绝对定位的方式来实现我们要考虑的是,如何确定每个图片元素的位置(即确定lefttop的值)?只要确定了这两个值,我们就能很简单的将每个图片定位到正确的位置了。例如我们有下面一组包含6张图片已知信息的数据,我们要来实现一个两列的瀑布流布局,图片的宽度都是200,但是高度不定,我们要如何确定每个元素要在放在哪里?下面我们通过画图的方式来一起模拟下这个过程,看看是如何计算每张图片的topleft值的。

let dataSource = [
  {
    key:1,
    imgUrl:"xxxxxx",
    width:200,
    height:200,
    left:?,
    top:?
  },
  {
    key:2,
    imgUrl:"xxxxxx",
    width:200,
    height:300,
    left:?,
    top:?
  },
  {
    key:3,
    imgUrl:"xxxxxx",
    width:200,
    height:400,
    left:?,
    top:?
  },
  {
    key:4,
    imgUrl:"xxxxxx",
    width:200,
    height:200,
    left:?,
    top:?
  },
  {
    key:5,
    imgUrl:"xxxxxx",
    width:200,
    height:300,
    left:?,
    top:?
  },
  {
    key:6,
    imgUrl:"xxxxxx",
    width:200,
    height:400,
    left:?,
    top:?
  },
]

首先我们正常来遍历dataSource数组,既然是要实现一个两列的瀑布流,我们就先把前两个元素放到容器中。前两个图片都是贴着顶部的,所以它们的top都是0。第一张图片的left为0,第二个图片的left为第一个图片的宽度,即200:

3.jpg

接下来思考的是如何放第三个图片?通过上面的图,我们可以很直观的观察到,第一张图片相对来说更矮点,根据瀑布流的特点,我们应该把第三张图片放到第一张图片的下面,并且可以看出,第三张图片的left应该是0,top应该是200(也就是第一张图片的高度),于是我们能得到

4.jpg

依次类推,我们每循环到一张图片,就把它放到最短的那一列,这样当我们的图片全部循环完,我们的瀑布流布局也就完成了。

所以现在问题又来了,这是通过画图的方式,我们可以很清晰的看出哪一列更短,那么写代码的话我们要如何确认哪一列更短呢?很简单,我们可以定义一个长度为瀑布流列数的数组,数组中的每项就表示当前列的高度,这样当我们每次循环我们就知道要往哪里列去插入图片了,所以针对前面分析的两步,我们结合这个数组再来分析下

5.png

6.png

所以分析到这里,思路其实就已经很清晰了,下面我们来做一下代码的实现,我们先创建一个组件Watefall,他要求用户传入一个图片数组的数组dataSource,每一项的格式是这样的:

// 图片数据
export interface waterfallData {
  // 图片url地址
  imgUrl: string;
  // 插槽区域(选填)
  slot?: React.ReactNode;
}

但是这种数据格式的数组只包含了图片的地址,我们还需要知道每个图片的宽度和高度以及图片的lefttop,所以我们还需要处理一下,来把数据处理成这样的格式。

// 瀑布流组件处理后的每项数据
export interface WaterfallItem extends waterfallData {
  // 图片宽度
  width: number;
  // 图片高度
  height: number;
  // 图片水平位置
  left: number;
  // 图片垂直位置
  top: number;
}

首先第一步,由于接收到的数据只有图片的url,所以我们要来写一个getImageSize方法来获取到图片的尺寸信息

// 每张图片的宽度
let itemWidth = 200
// 获取图片尺寸
const getImageSize = (url: string) => {
  return new Promise<{ width: number; height: number }>((resolve, reject) => {
    if (!url) {
      reject({width: 0, height: 0});
    }
    let img = new Image();
    img.src = url;
    // 等待图片加载完成
    img.onload = () => {
      let width = img.width;
      let height = img.height;
      // 为了确保图片的宽度都是200,我们要针对图片宽度大于200和小于200的情况,分别重新计算
      // 如果真实宽带大于每项的宽度,要等比例减少高度
      if (width > itemWidth) {
        height = Math.floor((itemWidth / width) * height);
      }
      // 如果真实宽度小于每项的宽度,要等比增加宽度
      if (width < itemWidth) {
        width = Math.floor((itemWidth / height) * width);
      }
      resolve({width, height});
    };
  });
};

实现完上面的方法后,我们在来实现生成们想要的数据格式方法generateDataSource,并定义一个_dataSource的state来保存生成的结构

  // 生成dataSource数据
  const generateDataSource = async (imgList: waterfallData[] = []): Promise<WaterfallItem[]> => {
    if (imgList.length === 0) return Promise.resolve([]);
    // 最终数据
    let list: WaterfallItem[] = [];
    list = imgList.map(item => {
      return {
        imgUrl: item.imgUrl,
        width: itemWidth,
        height: 0,
        left: 0,
        top: 0,
        slot: item?.slot,
      };
    });

    const result = await Promise.all(list.map(item => getImageSize(item.imgUrl)));
    return list.map((item, index) => {
      return {
        ...item,
        height: result[index].height,
      };
    });
  };

  useEffect(() => {
    generateDataSource(dataSource).then(result => {
      setDataSource(result);
    });
  }, [dataSource]);

最后,就来到最重要的一步了,我们要实现一个方法generateImageMap动态的去计算每项图片的位置(top和left),最终生成我们想要的结构

  // 保存最终生成的结构
  let [imgMap, setImgMap] = useState<any>();
  const generateImageMap = () => {
    // 定义一个数组保存每列的高度
    let tempArr: number[] = [];
    // wrapRef.current为容器的dom对象
    if (wrapRef.current) {
      // 通过计算容器的宽度除以每个图片的宽度,确定这个容器能放几列
      let _column = Math.floor(wrapRef.current.offsetWidth / itemWidth);
      // 计算剩余空间来确定每列的间距
      let _space = Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / _column);
      // 遍历计算每张图片的left和top
      _dataSource!.forEach((item, index) => {
        // 如果小于容器内存放图片的列数,则表明当前图片在第一列
        if (tempArr.length < _column) {
          item.top = 0;
          item.left = index * (itemWidth + _space);
          tempArr.push(item.height);
        } else {
          // 找到最短一列
          let min = Math.min(...tempArr)
          let minIndex = tempArr.indexOf(min);
          
          // 计算当前遍历图片的left和top值
          item.left = (minIndex % _column) * (itemWidth + _space);
          item.top = min + _space;
          // 累加当前列高度
          tempArr[minIndex] = min + item.height + _space;
        }
      });
      // 生成需要渲染的jsx结构
      setImgMap(
        _dataSource!.map((item, index) => {
          return (
            <div
              className={styles.item}
              key={index}
              style={{
                width: item.width + "px",
                height: item.height + "px",
                top: item.top + "px",
                left: item.left + "px",
              }}
            >
              <img src={item.imgUrl} alt={item.imgUrl}/>
              {item?.slot ? <div>{item.slot}</div> : null}
            </div>
          );
        }),
      );
    }
  };

最后把imgMap放到需要渲染的区域就大功告成了!至于h5端的适配,我们只需要监听页面的resize方法,当页面变化,重新调用generateImageMap方法即可,由于组件的封装要考虑到props的传参,下面的完整代码可能和上面我们写的最基本的实现不太一样,有兴趣的可以看下完整代码的实现。

3. 完整代码

目录结构如下

7.jpg

index.module.less

.wrap {
  width: 60%;
  height: 95%;
  margin: 0 auto;
  position: relative;
  overflow: auto;
  overflow-x: hidden;

  .item {
    position: absolute;
    background: gray;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .img {
    transition: all .3s;
  }

  .img:hover {
    transform: scale(1.1);
  }
}

index.tsx

import styles from "./index.module.less";
import { FC, useEffect, useRef, useState } from "react";
import { Props, waterfallData, WaterfallItem } from "./type";
import { isUtils } from "@m/utils";
import ImgPreview from "@/components/ImgPreview/ImgPreview.tsx";

const Waterfall: FC<Props> = ({
                                column,
                                space,
                                wrapWidth,
                                wrapHeight,
                                bgColor = 'transparent',
                                itemWidth = 200,
                                autoCenter = true,
                                dataSource,
                                onReachBottom
                              }) => {
  const wrapRef = useRef<HTMLDivElement>(null);
  // 生成dataSource数据
  const generateDataSource = async (
    imgList: string[] | waterfallData[] = [],
  ): Promise<WaterfallItem[]> => {
    if (imgList.length === 0) return Promise.resolve([]);

    // 获取图片尺寸
    const getImageSize = (url: string) => {
      return new Promise<{ width: number; height: number }>((resolve, reject) => {
        if (!isUtils.isString(url) || !url) {
          reject({width: 0, height: 0});
        }
        let img = new Image();
        img.src = url;
        img.onload = () => {
          // console.log("height", img.getBoundingClientRect(), img.width, img.height);
          let width = img.width;
          let height = img.height;
          // 如果宽带大于每项的宽度,要等比例减少高度
          if (width > itemWidth) {
            height = Math.floor((itemWidth / width) * height);
          }
          // 如果宽度小于每项的宽度,要等比增加宽度
          if (width < itemWidth) {
            width = Math.floor((itemWidth / height) * width);
          }
          resolve({width, height});
        };
      });
    };

    let list: WaterfallItem[] = [];
    // 如果每一项都是字符串,说明插槽区域不需要定制
    if (imgList.every(item => isUtils.isString(item))) {
      list = imgList.map(item => {
        return {imgUrl: item, width: itemWidth, height: 0, left: 0, top: 0};
      });
    } else {
      // 否则按照每项都是数据处理
      list = imgList.map(item => {
        return {
          imgUrl: item.imgUrl,
          width: itemWidth,
          height: 0,
          left: 0,
          top: 0,
          slot: item?.slot ?? null,
        };
      });
    }

    const result = await Promise.all(list.map(item => getImageSize(item.imgUrl)));
    return list.map((item, index) => {
      return {
        ...item,
        height: result[index].height,
      };
    });
  };

  // 生成图片列表
  let [_dataSource, setDataSource] = useState<WaterfallItem[]>();
  let [imgMap, setImgMap] = useState<any>();
  const generateImageMap = () => {
    let tempArr: number[] = [];
    if (wrapRef.current) {
      let _column = column ?? Math.floor(wrapRef.current.offsetWidth / itemWidth);
      let _space =
        space ?? Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / _column);
      // 计算左侧留空距离
      let leftSideWidth = 0;
      if (autoCenter) {
        leftSideWidth = Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / 2)
      }
      _dataSource!.forEach((item, index) => {
        if (tempArr.length < _column) {
          item.top = 0;
          item.left = index * (itemWidth + _space) + leftSideWidth;
          tempArr.push(item.height);
        } else {
          let min = Math.min(...tempArr);
          let minIndex = tempArr.indexOf(min);
          item.left = (minIndex % _column) * (itemWidth + _space) + leftSideWidth;
          item.top = min + _space;
          tempArr[minIndex] = min + item.height + _space;
        }
      });
      setImgMap(
        _dataSource!.map((item, index) => {
          return (
            <div
              className={styles.item}
              key={index}
              style={{
                width: item.width + "px",
                height: item.height + "px",
                top: item.top + "px",
                left: item.left + "px",
              }}
            >
              <div style={item.slot ? {height: "75%"} : {height: "100%"}}>
                <ImgPreview
                  imgUrl={item.imgUrl}
                  className={styles.img}
                  style={{cursor: "default"}}
                ></ImgPreview>
              </div>
              {item?.slot ? <div style={{height: "100%"}}>{item.slot}</div> : null}
            </div>
          );
        }),
      );
    }
  };

  useEffect(() => {
    generateDataSource(dataSource).then(result => {
      setDataSource(result);
    });
  }, [dataSource]);

  useEffect(() => {
    if (_dataSource && _dataSource.length > 0) {
      let wrap = document.querySelector("#photoWall-wrap");
      const resizeHandle = () => {
        generateImageMap();
      };

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

      if (wrap) {
        wrap.addEventListener("scroll", () => {
          if (wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - itemWidth) {
            onReachBottom && onReachBottom();
          }
        });
      }

      return () => {
        window.removeEventListener("resize", resizeHandle);
      };
    }
  }, [_dataSource]);

  return (
    <div
      ref={wrapRef}
      className={styles.wrap}
      id={"photoWall-wrap"}
      style={{
        width: wrapWidth ? wrapWidth.toString() : "60%",
        height: wrapHeight ? wrapHeight.toString() : "100%",
        background: bgColor
      }}
    >
      {imgMap}
    </div>
  );
};

export default Waterfall;

type.d.ts

// 瀑布流组件接收参数
import React from "react";

export interface Props {
  // 列数
  column?: number;
  // 间距
  space?: number;
  // 容器宽度
  wrapWidth?: string;
  // 容器高度
  wrapHeight?: string;
  // 每项的宽度 default: 200
  itemWidth?: number;
  // 当容器宽度两边留白时,是否将元素剧中
  autoCenter?: boolean;
  // 图片数据
  dataSource: waterfallData[];
  // 容器背景颜色,default:transparent
  bgColor?: string;
  // 触底操作,当容器滚动到底部时触发
  onReachBottom?: () => void;
}

// 图片数据
export interface waterfallData {
  // 图片地址
  imgUrl: string;
  // 插槽区域
  slot?: React.ReactNode;
}

// 瀑布流组件处理后的每项数据
export interface WaterfallItem extends waterfallData {
  // 图片宽度
  width: number;
  // 图片高度
  height: number;
  // 图片水平位置
  left: number;
  // 图片垂直位置
  top: number;
}

使用方法

// 导包
import Waterfall from "@/components/Watefall";

//----------------------------------------------------
// 使用
<Waterfall
  dataSource={imgList}
  onReachBottom={() => {
    console.log('---触底了---');
  }}
/>