React实现瀑布流

4,533 阅读8分钟

这篇文章的目的在于总结实现瀑布流Web应用的方法,内容主要包括CSS实现与JS实现,其中在JS实现部分选用React+TS,使用无限列表模式提升性能。

代码仓库

github.com/fl427/mason…

瀑布流布局介绍

Pinterest是瀑布流页面布局(Masonry Layouts)的代表,表现为参差不齐的多栏布局,用户滚动时动态加载新的数据块并填充在页面尾部。瀑布流自2012年由Pinterest引入,此后成为了众多网站的外在表现形式。

瀑布流的优势在于:

  • 节约空间。横向空间往往比纵向空间更宝贵,尤其是对于移动端场景,因而限制宽度同时放开高度的显式方式更加合理。
  • 统一规格。需要展示的区块宽高不定,不加以约束就无法高效管理和展示海量图片,而统一限制宽高又会不可避免的进行裁剪或压缩,瀑布流的方式限制宽度而放开高度,保证了图片的原始比例,让内容最大程度的显示给用户,同时不会显得杂乱。
  • 参与感强。用户的注意力被画面中视觉上最显著的部分吸引,交互复杂度的降低让用户能更高效地选择到优质的视觉内容,视觉体验(当然这主要来自于浏览的内容本身)驱使用户沉浸在探索与浏览当中。

Image.png

CSS实现

1. column-count + column-gap

// css
.App {
  padding: 40px;
  column-count: 4;
  column-gap: 1rem;

  .card {
    background-color: aqua;
    margin-bottom: 1rem;
    break-inside: avoid; // 关键属性,用来防止断点,防止card被内部截断
  }
}

// 随机生成高度
const genHeight = (): number => {
  return Math.floor(Math.random() * 200 + 100);
}

// 布局

function App() {
  return (
    <div className="App">
      {Array(16).fill(0).map((_, idx) => {
        const height = genHeight();
        return (
          <div key={idx} className='card' style={{ height }}>
            idx: {idx} height: {height}
          </div>
        )
      })}
    </div>
  );
}

Image.png

从代码实现难度来说,这是最简易的类瀑布流实现方式了。CSS的三个关键属性为column-count,column-gap和break-inside。其中:

  • column-count: 描述元素的列数
  • column-gap: 描述元素每列之间的间隙
  • break-inside: break-inside属性描述了多列布局下内容盒子如何中断,将其设置为avoid来避免内容跨列

这种实现方式的弊端很明显,可以发现这些区块是由上至下排列的,不能做到由左至右排列,并且也不能智能识别哪块图片应该放在哪个合适的位置,这一点是非常致命的。瀑布流场景通常和动态加载绑定,不能识别图片应该放在哪个位置的特性搭配上动态加载的需求将会导致很糟糕的显式效果。

2. flexBox

使用第一种multi-columns方法时我们发现盒子是由上至下进行排列的,接下来试一试flex布局的wrap属性来让盒子折行。

flex-flow: row wrap

.App {
  padding: 40px;
  display: flex;
  justify-content: space-between;
  flex-flow: row wrap;

  .card {
    background-color: aqua;
    margin-bottom: 1rem;
    width: 20vw;
  }
}

Image.png

以这种方式进行布局无法让新一行的盒子填充在上一行的空隙下方,显然是不符合要求的。这是因为flex布局是一个一维的布局系统,而瀑布流布局必然要求二维的布局系统,在flex布局中,折行的元素只能来到新的一行,而无法放到同一行的其他元素下方。

flex-flow: column wrap

以行为轴行不通,以列为轴呢?答案是依然不行。

.App {
  padding: 40px;
  height: 600px;
  display: flex;
  flex-flow: column wrap;

  .card {
    background-color: aqua;
    margin-bottom: 1rem;
    width: 20vw;
  }
}

Image.png

在这种情况下,盒子由上至下排列,同时必须指定父容器的高度,否则flex不知道什么时候要折行。而且父容器的撑开方向是平行方向,而不是垂直方向,这并不是瀑布流应该有的撑开方向(向pinterest那样撑开高度,而非宽度)。

由上可知,结论是flex布局不能实现瀑布流。

3. CSS实现:Grid布局

grid布局是一个二维布局方法,理论上它能够实现瀑布流,限制在于我们需要知道内部盒子的高度,这一点就断掉了我们我们利用Grid实现瀑布流的想法。不过,我们还是来看一下如果内部元素高度已知,grid能实现的效果。

// css
.App {
  padding: 40px;
  display: grid;
  grid-auto-rows: 100px;
  grid-gap: 10px;
  grid-template-columns: repeat(auto-fill, minmax(20%, 1fr));

  .card {
    background-color: aqua;
    grid-row: span 1;
  }

  .short {
    grid-row: span 1;     
  }
  .tall {
    grid-row: span 2;
  }
  .taller {
    grid-row: span 3;
  }
}

// 三种高度的盒子
const genClass = (): string => {
  const classes = ['short', 'tall', 'taller'];
  const idx = Math.floor(Math.random() * 3);
  return classes[idx];
}

// 布局
function App() {
  return (
    <div className="App">
      {Array(10).fill(0).map((_, idx) => {
        const height = genHeight();
        return (
          <div key={idx} className={`card ${genClass()}`}>
            idx: {idx} height: {height}
          </div>
        )
      })}
    </div>
  );
}

Image.png

4. CSS实现:Grid masonry

grid-template-rows: masonry
grid-template-columns: masonry

CSS有一个新的提案,使用masonry实现瀑布流,目前仅仅在Firefox的开发模式中启用。MDN链接。

JS实现

无限列表

为了实现瀑布流布局,无限列表是必不可少的。所谓无限列表指的是页面实际渲染的只是可视窗口加上上下缓冲区的部分元素,其余用户感知不到的元素不要渲染在DOM树中,这样可以避免渲染了几千上万条数据带来的卡顿问题。

import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import './App.css';

// todo: 极快速滚动出现白屏,DOM来不及添加 -> 加入防抖,后续使用IntersectionObserver

/**
 * 核心变量:容器高度、当前可视区域起始索引、当前可视区域结束索引、当前可视区域的数据
 * @constructor
 */

const itemHeight = 100;
const total = 10000;
let id = 0;

function App() {
    const ref = useRef<HTMLDivElement | null>(null);

    // 可视区域高度
    const containerHeight = document.body.clientHeight;
    // 可显示的列表项数
    const visibleCount = Math.floor(containerHeight / itemHeight);

    // 所有的数据
    const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]);
    // 偏移量
    const [startOffset, setStartOffset] = useState(0);
    // 起始索引
    const [start, setStart] = useState(0);
    // 结束索引
    // 列表宗高度
    const listHeight = useMemo(() => {
        return listData.length * itemHeight;
    }, [listData]);

    // 获取真实显示列表数据
    const visibleData = useMemo(() => {
        // 前后设置缓冲区域
        const visibleStart = Math.max(0, start - visibleCount);
        const visibleEnd = Math.min(listData.length, start + visibleCount * 2);
        return listData.slice(visibleStart, visibleEnd);
    }, [listData, start, visibleCount]);

    // 产生随机数据
    const genTenListData = useCallback((offset = 0) => {
        if (listData.length >= total) {
            return [];
        }

        return new Array(10).fill({}).map((_, idx) => ({
            id: id++,
            content: Math.random() * 1000,
            top: idx * itemHeight + offset,
        }));
    }, [listData]);

    useEffect(() => {
        const data = genTenListData();
        setListData(data);
    }, []);

    const handleScroll = useCallback(() => {
        const dom = ref.current;
        if (dom) {
            const scrollTop = dom.scrollTop;
            const listTotalHeight = dom.scrollHeight;

            const start = Math.floor(scrollTop / itemHeight);
            const end = start + visibleCount;
            setStart(start);
            if (end >= listData.length) {
                // 代表滚动到底部,需要增加元素
                const data = listData.concat(genTenListData(listData.length * itemHeight));
                setListData(data);
            }

            setStartOffset(scrollTop);
        }

    }, [containerHeight, genTenListData, listData, visibleCount]);

    useEffect(() => {
        const dom = ref.current;
        if (dom) {
            dom.addEventListener('scroll', handleScroll);
        }
        return () => {
            if (dom) {
                dom.removeEventListener('scroll', handleScroll);
            }
        }
    }, [handleScroll]);

    // 之后再瀑布流中,我们将会维护一个table,以页数pageIdx作为key,记录当前页的startIdx和endIdx
    // 滚动时,通过scrollTop判断当前所处的页数(用scrollTop / containerHeight,进而拿到startIdx和endIdx
    // 用transform调用gpu而非top来提升性能
    return (
        <div className="App" ref={ref} >
            <div className={'list-wrapper'} style={{ height: Math.max(listHeight, containerHeight + 1) }}>
                <div className={'list'}>
                    {visibleData.map((data) => (
                        <div key={data.id} className={'list-item'} style={{ height: `${itemHeight}px`, background: 'aqua', transform: `translateY(${data.top}px)` }}>
                            <h1>{data.id}</h1>
                            {data.top}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
}

export default App;


.App {
  text-align: center;
  height: 100vh;
  width: 100vw;
  overflow: scroll;
  position: relative;
}

.list-wrapper {
  position: relative;
}

.list {
  position: relative;
}


.list-item {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  border: 1px solid red;
}

每一条数据保存自己的位置信息,我们在滚动过程中找到当前视口对应的startIdx,从总的数据列表中选出渲染的内容。

从中可以看出,比较重要的在于判断需要渲染的元素的起始和结束,start和end。

有两种可能的解法

  1. IntersectionObserver
  2. 分页思想,维护一个表格,pageIdx作为key,滚动过程中记录该页的start和end

分页思想

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { pageMap } from "./page-map";

import Card from "../../components/card";

import './index.scss';

const itemHeight = 100;
const total = 10000;
let id = 0;

const MasonryPage: React.FC = () => {
    const ref = useRef<HTMLDivElement | null>(null);

    // 存储当前容器内的高度
    const [heights, setHeights] = useState<number[]>([0]);
    // 当前是第几页
    const [pageIdx, setPageIdx] = useState<number>(0);

    // 可视区域高度
    const containerHeight = document.body.clientHeight;
    // 可显示的列表项数
    const visibleCount = Math.floor(containerHeight / itemHeight);

    // 所有的数据
    const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]);
    // 偏移量
    const [startOffset, setStartOffset] = useState(0);
    // 起始索引
    const [start, setStart] = useState(0);
    // 结束索引
    const [end, setEnd] = useState(0);
    // 获取真实显示列表数据
    const visibleData = useMemo(() => {
        console.log('数据变化', start);
        // 前后设置缓冲区域
        const visibleStart = Math.max(0, start - visibleCount);
        const visibleEnd = Math.min(listData.length, end + visibleCount * 2);
        return listData.slice(visibleStart, visibleEnd);
    }, [listData, start, end, visibleCount]);

    // 产生随机数据
    const genTenListData = useCallback((offset = 0) => {
        if (listData.length >= total) {
            return [];
        }
        let currHeights = [...heights];

        const dataArr = new Array(10).fill({}).map((_, idx) => {
            currHeights[0] += itemHeight;
            return {
                id: id++,
                content: Math.random() * 1000,
                top: idx * itemHeight + offset,
            }
        });
        setHeights(currHeights);
        return dataArr;
    }, [heights, listData.length]);

    useEffect(() => {
        const data = genTenListData();
        setListData(data);
    }, []);

    const handleScroll = useCallback(() => {
        const dom = ref.current;
        if (dom) {
            const scrollTop = dom.scrollTop;
            const listTotalHeight = dom.scrollHeight;

            // 判断当前pageIdx
            const currPageIdx = Math.floor(scrollTop / containerHeight)
            setPageIdx(currPageIdx);
            console.log('info', currPageIdx, pageMap.getInfo(currPageIdx));

            if (pageMap.has(currPageIdx)) {
                // 存在记录时直接取到start和end,不要再计算
                const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!;
                const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!;
                console.log('已经有了', storedCurrStartIdx, storedCurrEndIdx)
                setStart(storedCurrStartIdx);
                setEnd(storedCurrEndIdx);
            } else {
                // 不存在记录,需要找到当前页的start和end并存储
                // 由于我们由上至下滚动,当滚动到currPageIdx时,我们一定存储过currPageIdx - 1的信息,不存在就意味着这是第一页
                let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                for (let i = tempStartIdx + 1; i < listData.length; i++) {
                    if (listData[i].top <= containerHeight * (currPageIdx + 1)) {
                        tempEndIdx = i;
                    }
                }
                pageMap.setInfo(currPageIdx, {startIdx: tempStartIdx, endIdx: tempEndIdx});
                setStart(tempStartIdx);
                setEnd(tempEndIdx);
                console.log('添加有几次判断+++再次', listData.length - tempStartIdx - 1, tempStartIdx, tempEndIdx)
            }

            if (listTotalHeight - scrollTop <= 1.5 * containerHeight) {
                // 滚动到页面的一半程度,家在新的数据,使得用户无感知
                console.log('继续加载', listTotalHeight, scrollTop)
                const data = listData.concat(genTenListData(listData.length * itemHeight));
                setListData(data);
            }

            // 这里利用React的状态更新机制,相当于不断将上一轮的状态进行赋值,由于scroll是连续的事件,就可以在滚到需要的位置之前将容器高度撑开到正确的值。
            // 但实际上我们使用了绝对定位,将容器高度设置为0也是一样的效果,只是为了在DOM结构上显示更好,还是设置了一个高度值
            setStartOffset(listTotalHeight);
        }

    }, [containerHeight, genTenListData, listData, visibleCount]);

    useEffect(() => {
        const dom = ref.current;
        if (dom) {
            dom.addEventListener('scroll', handleScroll);
        }
        return () => {
            if (dom) {
                dom.removeEventListener('scroll', handleScroll);
            }
        }
    }, [handleScroll]);

    return (
        <div className="masonry-page" ref={ref} >
            <div className={'masonry'}
                 style={{
                     height: `${startOffset}px`
                 }}
            >
                <div className={'masonry-list'}>
                    {visibleData.map((data) => (
                        <Card className={'masonry-list-item'} key={data.id} data={data} itemHeight={itemHeight} />
                    ))}
                </div>
            </div>
        </div>
    )
};

export default MasonryPage;

// pageMap.ts
export interface Info {
    startIdx: number;
    endIdx: number;
}

class PageMap {
    map = new Map<number, Info>();

    getInfo(idx: number): Info | undefined {
        return this.map.get(idx);
    }

    setInfo(idx: number, info: Info): void {
        this.map.set(idx, info);
    }

    has(idx: number): boolean {
        return this.map.get(idx) !== undefined;
    }
}

export const pageMap = new PageMap();


// Card.ts
import React, {useEffect, useRef} from 'react';

interface IProps {
    data: {
        content: number;
        id: number;
        top: number;
    };
    itemHeight: number;
    className: string;
}
const Card: React.FC<IProps> = ({ data , itemHeight, className}) => {
    const ref = useRef<HTMLDivElement | null>(null);

    return (
        <div ref={ref} id={data.id.toString()} key={data.id} className={`${className} card`}
            style={{
                height: `${itemHeight}px`,
                background: 'aqua',
                transform: `translateY(${data.top}px)`
            }}
        >
            <h1>{data.id}</h1>
            {data.top}
        </div>
    )
};

export default Card;

在之前的基础上,我们引入分页的思想,利用以pageIdx为key的map管理每一页所对应的卡片的startIdx和endIdx,滚动时只需要在第一次构造每一页的内容,之后就可以直接使用。

滚动时,利用scrollTop判断出当前属于哪一页,然后就可以找出当前页应当渲染的卡片的范围[startIdx ~ endIdx]。

完整代码

Masonry-Page

// masonry-page.tsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// 类
import { pageMap } from "./page-map";
import { MasonryImage } from "./masonry-image";
// 组件
import Card from "../../components/card";
// 常量
import { fakeImages } from "../../constants/images";
// CSS
import './index.scss';

const xAxisGap = 4, yAxisGap = 10

const itemWidth = 235; // 每一项子元素的宽度,即图片在瀑布流中显示宽度,保证每一项等宽不等高
// 获取当前的页面宽度和计算得出的列数
const getColumnAndPageWidth = (): {
    pageWidth: number;
    pageHeight: number;
    column: number;
} => {
    const pageWidth = global.innerWidth;
    const pageHeight = global.innerHeight;
    return {
        pageWidth,
        pageHeight,
        column: Math.floor(pageWidth / (itemWidth + xAxisGap)),
    };
}

// 获取heightArr数组中最小列的索引
const getMinIndex = (array: number[]): number => {
    const min = Math.min(...array);
    return array.indexOf(min);
}

const itemHeight = 100;
let id = 0;

// 从接口获取图片src列表
const handleGetImages = (params: { start: number, end: number }): Promise<string[]> => {
    return new Promise((resolve) => {
        if (params.start < fakeImages.length) {
            resolve(fakeImages.slice(params.start, params.end));
        } else {
            resolve(fakeImages.slice(params.start % fakeImages.length, params.start % fakeImages.length + 10));
        }

    });
};

// 并行加载,获取每一个图片的高度
const loadImgHeights = (images: string[], itemWidth: number): Promise<MasonryImage[]> => {
    return new Promise((resolve, reject) => {
        const length = images.length
        const masonryImages: MasonryImage[] = [];

        let count = 0
        const load = (index: number) => {
            const img = new Image();
            img.src = images[index];
            const checkIfFinished = () => {
                count++
                if (count === length) {
                    resolve(masonryImages)
                }
            }
            img.onload = () => {
                // 显示在瀑布流中的高度按照固定宽度以及图片比例计算
                const itemHeight = itemWidth * (img.height / img.width)
                const masonryImageIns = new MasonryImage(images[index], img, { sourceWidth: img.width, sourceHeight: img.height, masonryWidth: itemWidth, masonryHeight: itemHeight }, index + id);
                masonryImages[index] = masonryImageIns;
                checkIfFinished()
            }
            img.onerror = () => {
                masonryImages[index] = new MasonryImage('');
                checkIfFinished()
            }
            img.src = images[index]
        }
        images.forEach((img, index) => load(index));

    })
}

const MasonryPage: React.FC = () => {
    const ref = useRef<HTMLDivElement | null>(null);

    // 存储当前容器内的高度
    const [heights, setHeights] = useState<number[]>([]);
    // 当前是第几页
    const [pageIdx, setPageIdx] = useState<number>(0);

    // 可视区域高度
    const containerHeight = document.body.clientHeight;
    // 可显示的列表项数
    const visibleCount = Math.floor(containerHeight / itemHeight);

    // 所有的数据
    // const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]);
    const [images, setImages] = useState<MasonryImage[]>([]);
    // 偏移量
    const [startOffset, setStartOffset] = useState(0);
    // 起始索引
    const [start, setStart] = useState(0);
    // 结束索引
    const [end, setEnd] = useState(0);
    // 获取真实显示列表数据
    const visibleData = useMemo(() => {
        console.log('数据变化', start, end, images);
        // 前后设置缓冲区域
        const visibleStart = Math.max(0, start - visibleCount);
        const visibleEnd = Math.min(images.length, end + visibleCount * 2);
        return images.slice(visibleStart, visibleEnd);
    }, [start, visibleCount, images, end]);

    // 获取图片
    const genTenListImages = useCallback(async () => {
        isAdding.current = true;
        const imagesFromApi = await handleGetImages({start: images.length, end: images.length + 10});

        const masonryImages = await loadImgHeights(imagesFromApi, itemWidth);
        id += masonryImages.length;

        const { pageWidth, column } = getColumnAndPageWidth();

        // 当前的高度列表
        let heightArr = [...heights];
        if (heights.length === 0) {
            heightArr = Array(column).fill(0); // 如果heights.length === 0,意味着我们没有初始的heights数组,需要初始化
        }
        // 修改masonryImages的属性,按照heights数据修改每一个元素的宽高和位置 --> masonry重点
        for (let i = 0; i < masonryImages.length; i++) {
            const masonryImageInstance = masonryImages[i];
            const minIndex = getMinIndex(heightArr);
            // 定位这张图片的top
            const imgTop = heightArr[minIndex] + yAxisGap;
            masonryImageInstance && masonryImageInstance.setAttributes('offsetY', imgTop);
            // 定位这张图片的left
            const leftOffset = (pageWidth - (column * (itemWidth + xAxisGap) - xAxisGap)) / 2; // 左边padding,确保内容居中
            const imgLeft = leftOffset + minIndex * (itemWidth + xAxisGap);
            masonryImageInstance && masonryImageInstance.setAttributes('offsetX', imgLeft);

            heightArr[minIndex] = imgTop + (masonryImageInstance.masonryHeight || 0);
        }
        setHeights(heightArr);
        // 将新产生的image放入状态数组中
        setImages((prev) => ([...prev, ...masonryImages]));
        isAdding.current = false;
    }, [heights, images]);

    useEffect(() => {
        genTenListImages().then();
    }, []);

    const isAdding = useRef(false);

    const handleScroll = useCallback(async () => {

        const dom = ref.current;
        if (dom) {
            const scrollTop = dom.scrollTop;
            const listTotalHeight = dom.scrollHeight;

            // 判断当前pageIdx
            const currPageIdx = Math.floor(scrollTop / containerHeight)
            setPageIdx(currPageIdx);
            console.log('info', currPageIdx, pageMap.getInfo(currPageIdx));

            if (pageMap.has(currPageIdx)) {
                // 存在记录时直接取到start和end,不要再计算
                const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!;
                const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!;
                console.log('已经有了', storedCurrStartIdx, storedCurrEndIdx)
                setStart(storedCurrStartIdx);
                setEnd(storedCurrEndIdx);
            } else {
                // 不存在记录,需要找到当前页的start和end并存储
                // 由于我们由上至下滚动,当滚动到currPageIdx时,我们一定存储过currPageIdx - 1的信息,不存在就意味着这是第一页
                let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                for (let i = tempStartIdx + 1; i < images.length; i++) {
                    if ((images[i].offsetY || Infinity) <= containerHeight * (currPageIdx + 1)) {
                        tempEndIdx = i;
                    }
                }
                pageMap.setInfo(currPageIdx, {startIdx: tempStartIdx, endIdx: tempEndIdx});
                setStart(tempStartIdx);
                setEnd(tempEndIdx);
            }
            console.log('继续加载scroll', images.length, listTotalHeight - scrollTop, 1.5 * containerHeight)
            // 问题在于这里,我们必须要让高度被及时撑开,否则会多次请求数据,之后就全乱了
            if (listTotalHeight - scrollTop <= 1.5 * containerHeight) {
                // 这里我们先手动阻断(之后再想想有没有更好的办法)
                // 由于瀑布流布局需要时间,当新的内容还在布局的过程中时,它们还未被添加到DOM结构中,因此listTotalHeight没有更新
                // 此时if判断成立,genTenListImages被持续触发,就出错了。因此在添加时手动加一个判断条件,直到当前的新元素添加完成才能继续添加
                // 这样带来的问题就是用户滚动的速度超过元素添加的速度,那么更新就不及时。
                // TODO 优化:1. 从后端直接返回图片高度可以省去图片预加载的步骤,直接完成布局,图片加载慢慢来
                // 2. 以及更加早得加载新的数据,以让用户无感知
                // 3. 添加'加载中'的过渡样式,以让用户有心理预期
                if (isAdding.current) {
                    console.log('继续加载scroll正在添加');
                    return;
                }
                // 滚动到页面的一半程度,家在新的数据,使得用户无感知
                console.log('继续加载-1', listTotalHeight - scrollTop, 1.5 * containerHeight, Math.max(...heights));
                await genTenListImages();
            }

            // 这里利用React的状态更新机制,相当于不断将上一轮的状态进行赋值,由于scroll是连续的事件,就可以在滚到需要的位置之前将容器高度撑开到正确的值。
            // 但实际上我们使用了绝对定位,将容器高度设置为0也是一样的效果,只是为了在DOM结构上显示更好,还是设置了一个高度值
            setStartOffset(listTotalHeight);
        }

    }, [containerHeight, genTenListImages, heights, images]);

    useEffect(() => {
        const dom = ref.current;
        if (dom) {
            dom.addEventListener('scroll', handleScroll);
        }
        return () => {
            if (dom) {
                dom.removeEventListener('scroll', handleScroll);
            }
        }
    }, [handleScroll]);

    return (
        <div className="masonry-page" ref={ref}>
            <div className={'masonry'}
                 style={{
                     height: `${startOffset}px`
                 }}
            >
                <div className={'masonry-list'}>
                    {visibleData.map((data) => (
                        <Card className={'masonry-list-item'} key={data.id} image={data} itemHeight={itemHeight} />
                    ))}
                </div>
            </div>
        </div>
    )
};

export default MasonryPage;


// masonry-page.css
.masonry-page {
  text-align: center;
  height: 100vh;
  width: 100vw;
  overflow: scroll;
  position: relative;

  .masonry {
    position: relative;
    height: 100%;

    &-list {
      position: relative;
      height: 100%;

      &-item {
        position: absolute;
        left: 0;
        top: 0;
        //width: 100%;
        //border: 1px solid red;
      }
    }
  }
}

Card

// card.tsx
import React, {useEffect, useRef} from 'react';
import {MasonryImage} from "../../containers/masonry-page/masonry-image";

import './index.scss';

interface IProps {
    data?: {
        content: number;
        id: number;
        top: number;
    };
    image?: MasonryImage;
    itemHeight: number;
    className: string;
}
const Card: React.FC<IProps> = ({ data , itemHeight, className, image}) => {
    const ref = useRef<HTMLDivElement | null>(null);

    return (
        <div ref={ref} key={image?.id} className={`${className} card`}
             style={{
                 // height: `${itemHeight}px`,
                 // background: 'aqua',
                 width: `${image?.masonryWidth}px`,
                 height: `${image?.masonryHeight}px`,
                 transform: `translate(${image?.offsetX}px, ${image?.offsetY}px)`
             }}
        >
            <div className={'card-info'}>
                <h1>{image?.id}</h1>
                {image?.offsetY}
            </div>


            <img className={'card-img'} src={image?.src}/>
        </div>
    )
};

export default Card;


// card.css
.card {

  &-info {
    position: absolute;
    left: 0;
    top: 0;
    color: red;
  }

  &-img {
    width: 100%;
    height: 100%;
  }
}

PageMap

// page-map.ts
export interface Info {
    startIdx: number;
    endIdx: number;
}

class PageMap {
    map = new Map<number, Info>();

    getInfo(idx: number): Info | undefined {
        return this.map.get(idx);
    }

    setInfo(idx: number, info: Info): void {
        this.map.set(idx, info);
    }

    has(idx: number): boolean {
        return this.map.get(idx) !== undefined;
    }
}

export const pageMap = new PageMap();

Image.png

从服务端获取内容

在瀑布流应用中,获取图片的宽高是一个大问题,因为图片加载是需要时间的,不知道图片的宽高就没办法给图片进行定位。在上述实现中,我们使用promise.all并行预加载图片,避免了串行加载每一张图片带来的过长加载时间。而一般来讲,瀑布流应用的服务端可以由我们自行控制,服务端返回图片的宽高信息就可以省下预加载的步骤,快速得到页面的布局。

和之前代码的主要不同点在于这里直接拿到了图片信息,于是省去了创建new Image()的步骤,较快速的完成页面布局。

// masonry-page

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import axios from '../../service';

// 类
import { pageMap } from "./page-map";
import { MasonryImage } from "./masonry-image";
// 组件
import Card from "../../components/card";
// 常量
import { fakeImages } from "../../constants/images";
// CSS
import './index.scss';

type LocalImageType = {
    src: string;
    width: number;
    height: number;
}

const xAxisGap = 4, yAxisGap = 10

const itemWidth = 235; // 每一项子元素的宽度,即图片在瀑布流中显示宽度,保证每一项等宽不等高
// 获取当前的页面宽度和计算得出的列数
const getColumnAndPageWidth = (): {
    pageWidth: number;
    pageHeight: number;
    column: number;
} => {
    const pageWidth = global.innerWidth;
    const pageHeight = global.innerHeight;
    return {
        pageWidth,
        pageHeight,
        column: Math.floor(pageWidth / (itemWidth + xAxisGap)),
    };
}

// 获取heightArr数组中最小列的索引
const getMinIndex = (array: number[]): number => {
    const min = Math.min(...array);
    return array.indexOf(min);
}

const itemHeight = 100;
let id = 0;

// 从接口获取本地图片src和宽高信息
const handleGetLocalImages = async (params: { start: number, end: number }): Promise<LocalImageType[]> => {
    const result = await axios.get('/api', {
        params: {
            start: params.start,
            end: params.end,
        }
    });
    // console.log('result', result);
    return result.data;
}


const loadLocalImgHeights = (images: LocalImageType[], itemWidth: number): MasonryImage[] => {
    const masonryImages: MasonryImage[] = [];

    images.forEach((img, index) => {
        const itemHeight = itemWidth * (img.height / img.width);
        const masonryImageIns = new MasonryImage(img.src, new Image(), { sourceWidth: img.width, sourceHeight: img.height, masonryWidth: itemWidth, masonryHeight: itemHeight }, index + id);
        masonryImages[index] = masonryImageIns;
    });

    return masonryImages;
}

这里是主要的组件,图片数据来源是我本地的一个node服务,数据结构为

type LocalImageType = {
    src: string;
    width: number;
    height: number;
}
// masonry-page

const MasonryPage: React.FC = () => {

    const ref = useRef<HTMLDivElement | null>(null);

    // 存储当前容器内的高度
    const [heights, setHeights] = useState<number[]>([]);

    // 可视区域高度
    const containerHeight = document.body.clientHeight;
    // 可显示的列表项数
    const visibleCount = Math.floor(containerHeight / itemHeight);

    // 所有的数据
    // const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]);
    const [images, setImages] = useState<MasonryImage[]>([]);
    // 偏移量
    const [startOffset, setStartOffset] = useState(0);
    // 起始索引
    const [start, setStart] = useState(0);
    // 结束索引
    const [end, setEnd] = useState(0);
    // 获取真实显示列表数据
    const visibleData = useMemo(() => {
        // console.log('数据变化', start, end, images);
        // 前后设置缓冲区域
        const visibleStart = Math.max(0, start - visibleCount);
        const visibleEnd = Math.min(images.length, end + visibleCount * 2);
        return images.slice(visibleStart, visibleEnd);
    }, [start, visibleCount, images, end]);
    
    // 获取本地图片
    const getLocalListImages = useCallback(async () => {
        isAdding.current = true;
        const imagesFromApi = await handleGetLocalImages({ start: images.length, end: images.length + 10 });

        const masonryImages = loadLocalImgHeights(imagesFromApi, itemWidth);
        id += masonryImages.length;

        const { pageWidth, column } = getColumnAndPageWidth();

        // 当前的高度列表
        let heightArr = [...heights];
        if (heights.length === 0) {
            heightArr = Array(column).fill(0); // 如果heights.length === 0,意味着我们没有初始的heights数组,需要初始化
        }
        // 修改masonryImages的属性,按照heights数据修改每一个元素的宽高和位置 --> masonry重点
        for (let i = 0; i < masonryImages.length; i++) {
            const masonryImageInstance = masonryImages[i];
            const minIndex = getMinIndex(heightArr);
            // 定位这张图片的top
            const imgTop = heightArr[minIndex] + yAxisGap;
            masonryImageInstance && masonryImageInstance.setAttributes('offsetY', imgTop);
            // 定位这张图片的left
            const leftOffset = (pageWidth - (column * (itemWidth + xAxisGap) - xAxisGap)) / 2; // 左边padding,确保内容居中
            const imgLeft = leftOffset + minIndex * (itemWidth + xAxisGap);
            masonryImageInstance && masonryImageInstance.setAttributes('offsetX', imgLeft);

            heightArr[minIndex] = imgTop + (masonryImageInstance.masonryHeight || 0);
        }
        setHeights(heightArr);
        // 将新产生的image放入状态数组中
        setImages((prev) => ([...prev, ...masonryImages]));
        isAdding.current = false;
    }, [heights, images]);

    useEffect(() => {
        getLocalListImages().then();
    }, []);

    const isAdding = useRef(false);

    const handleScroll = useCallback(async () => {

        const dom = ref.current;
        if (dom) {
            const scrollTop = dom.scrollTop;
            const listTotalHeight = dom.scrollHeight;

            // 判断当前pageIdx
            const currPageIdx = Math.floor(scrollTop / containerHeight)
            // console.log('info', currPageIdx, pageMap.getInfo(currPageIdx));

            if (pageMap.has(currPageIdx)) {
                // 存在记录时直接取到start和end,不要再计算
                const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!;
                const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!;
                // console.log('已经有了', storedCurrStartIdx, storedCurrEndIdx)
                setStart(storedCurrStartIdx);
                setEnd(storedCurrEndIdx);
            } else {
                // 不存在记录,需要找到当前页的start和end并存储
                // 由于我们由上至下滚动,当滚动到currPageIdx时,我们一定存储过currPageIdx - 1的信息,不存在就意味着这是第一页
                let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0;
                for (let i = tempStartIdx + 1; i < images.length; i++) {
                    if ((images[i].offsetY || Infinity) <= containerHeight * (currPageIdx + 1)) {
                        tempEndIdx = i;
                    }
                }
                pageMap.setInfo(currPageIdx, { startIdx: tempStartIdx, endIdx: tempEndIdx });
                setStart(tempStartIdx);
                setEnd(tempEndIdx);
            }
            // console.log('继续加载scroll', images.length, listTotalHeight - scrollTop, 1.5 * containerHeight)
            if (listTotalHeight - scrollTop <= 1.5 * containerHeight) {
                if (isAdding.current) {
                    // console.log('继续加载scroll正在添加');
                    return;
                }
                // 滚动到页面的一半程度,家在新的数据,使得用户无感知
                // console.log('继续加载-1', listTotalHeight - scrollTop, 1.5 * containerHeight, Math.max(...heights));
                await getLocalListImages();
            }

            // 这里利用React的状态更新机制,相当于不断将上一轮的状态进行赋值,由于scroll是连续的事件,就可以在滚到需要的位置之前将容器高度撑开到正确的值。
            // 但实际上我们使用了绝对定位,将容器高度设置为0也是一样的效果,只是为了在DOM结构上显示更好,还是设置了一个高度值
            setStartOffset(listTotalHeight);
        }

    }, [containerHeight, getLocalListImages, heights, images]);

    useEffect(() => {
        const dom = ref.current;
        if (dom) {
            dom.addEventListener('scroll', handleScroll);
        }
        return () => {
            if (dom) {
                dom.removeEventListener('scroll', handleScroll);
            }
        }
    }, [handleScroll]);

    // console.log('result-image', visibleData);

    return (
        <div className="masonry-page" ref={ref}>
            <div className={'masonry'}
                style={{
                    height: `${startOffset}px`
                }}
            >
                <div className={'masonry-list'}>
                    {visibleData.map((data) => (
                        <Card className={'masonry-list-item'} key={data.id} image={data} itemHeight={itemHeight} />
                    ))}
                </div>
            </div>
        </div>
    )
};

export default MasonryPage;

Image.png

这里的实现还是存在一定问题,在滚动时会出现白屏的情况,同时缺少resize的能力。所以大家把这篇文章看做一个大致的参考就好,我计划去学习NestJS、Flutter、工程化相关的知识,或许几个月后我的能力有进一步的提升,会对这里的Web瀑布流应用做一次重构,解决现有的问题,写出更加规范,更加'工程化'的代码。

参考

www.favori.cn/react-virtu…

juejin.cn/post/684490…

www.zhihu.com/question/19…

www.cnblogs.com/goloving/p/…

stackoverflow.com/questions/4…

juejin.cn/post/684490…

juejin.cn/post/684490…

juejin.cn/post/684490…

www.noobyard.com/article/p-o…

cloud.tencent.com/developer/a…

juejin.cn/post/701292…

blog.csdn.net/thickhair_c…

juejin.cn/post/696307…

juejin.cn/post/684490…