使用【React17】【Vue3】两种方式实现【交错式瀑布流】

2,256 阅读7分钟

「本文已参与低调务实优秀中国好青年社群的写作活动」
先上一张效果图: image.png

什么是瀑布流?

瀑布流是前端的一种常见的布局,具体表现形式是多栏布局,宽度固定,高度不定。与此同时,随着页面的滚动,会不断有新的元素添加到瀑布流尾部的最低列。
常见的瀑布流有如下三种样式:

  • 卡片流: 这种样式常见于移动端购物APP,一行只展示一件物品。随着页面的加载逐步出现更多的内容,也就是我们常说的下拉列表,算是最简单的一种瀑布流了:
  • 固定式瀑布流: 这种样式图片区域大小高度保持不变。统一的高度会使整个界面看起来比较整齐。
    PC端淘宝首页:

image.png 移动端淘宝某页面:

  • 交错式瀑布流: 交错式瀑布流是目前各大APP首页最常采用的布局格式,也是用户视觉上最为舒服的一种布局:

实现瀑布流的几种常见布局方式

目前,市面上常见的瀑布流开源插件采用的布局方案主要有以下几种:

  • 绝对定位:这种布局需要在图片渲染之前得知图片的宽高,以便于每次插入图片时插入到高度最矮的那一列。
  • Flex布局:这种布局也是把新增的图片插入到高度最矮的一列中,但不是通过绝对定位。

上手实践

鉴于让后台接口直接返回图片的宽高不太现实&交错式布局难度最大。因此本次实践采用的是Flex布局的方案来实现交错式瀑布流。

Flex布局实现瀑布流的过程

  • 第一步:明确瀑布流有多少列,假设为x
  • 第二步:从瀑布流图片接口列表中取出前x个元素,依次渲染至每一列的第一行
  • 第三步:从第x+1个元素开始(此步是精髓
    • 首先计算每一列的高度
    • 然后将当前要添加的元素添加至高度最低的那一列中
  • 循环第三步,直至所有元素都添加至瀑布流中

实践之前的必备知识

因为在渲染当前图片前,需要得知每一列的高度,因此在添加当前图片至瀑布流之前,我们需要保证前一张添加的图片已出现在页面上。那如何得知前一张图片已经渲染成功呢?并且如何在前一张图片渲染成功之后再添加新的图片呢?
这就涉及到VueReact的生命周期,以及IntersectionObserver API的概念了:

何为IntersectionObserver?

IntersectionObserver结构

IntersectionObserver(交叉观察器)可以自动监听元素是否进入了设备的可视区域之内,而不需要通过频繁的计算(比如我们常见的根据滚动条是否触底来做的一些判断)。由于可见的本质是,目标元素与视口产生一个交叉区,所以这个API叫做“交叉观察器”。
通过上面简述,其实我们常提的图片懒加载就可以采用IntersectionAPI来实现呀,这样也免去了与滚动条的距离计算,才是上上策~

let observerObj = new IntersectionObserver(callback, option);

Intersection是浏览器原生提供的构造函数,接收两个参数:

  • callback: 可见性发生变化时的回调函数,包含changes和observer两个参数
  • option:配置对象(可选) 构造函数的返回值是一个观察器实例。实例一共有4个方法:
  • observe: 开始监听特定元素
  • unobserve: 停止监听特定元素
  • disconnect: 关闭监听工作
  • takeRecords: 返回所有观察目标的对象数组 callback回调函数的第一个参数changes是一个数组,该数组的每一项都是一个IntersectionObserverEntry对象
IntersectionObserverEntry对象

IntersectionObserverEntry对象是我们本文实现尾部追加元素的重要属性之一!该对象内部包含如下几个属性:

  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRatio: 目标元素的可见比例,即intersectionRect占boundingClientRect的比例。完全可见时为1,完全不可见时小于等于0
  • intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
  • isIntersecting: 布尔值,目标元素与交集观察者的根节点是否相交(常用
  • isVisible: 布尔值,目标元素是否可见(该属性还在试验阶段,不建议在生产环境中使用)
  • rootBounds: 根元素的矩形区域额的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
  • target: 被观察的目标元素,是一个DOM节点对象(常用
  • time: 可见性发生变化的时间,是一个高精度时间戳,单位为毫秒 在本文要实现的瀑布流中,IntersectionObserver的使用如下:
// 瀑布流布局:取出数据源中最靠前的一个并添加到瀑布流高度最小的那一列,等图片完全加载后重复该循环
let observerObj = new IntersectionObserver(
    (entries) => {
        for (const entry of entries) {
            const { target, isIntersecting } = entry
            if (isIntersecting) {
                // 添加下一张图片
                addPicture()
                // 取消监听当前已加载的图片
                observerObj.unobserve(target)
            }
        }
    },
    {
      rootMargin: '0px 0px 300px 0px', // 提前加载
    },
)
在Vue中,使用nextTick实现异步添加

nextTickVue中异步调用DOM的解决方案,它可以保证我们前一张图片渲染至页面上之后,再执行下一次渲染的操作,个人认为,有点类似于onMounted钩子函数:

nextTick(() => {
        columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        // 添加交叉监听器
        observerObj.observe(columnArray[columnArray.length - 1])
 })
在React中,使用useEffect实现渲染后添加

React中,useEffect的执行时机是页面渲染完成之后,因此,我们只需要把监听上一张图片是否渲染完成,以及加载下一张图片的函数在useEffect里引入即可:

useEffect(() => {
    if (dataList.length > 0) {// 跳过页面初始化
        console.log('添加图片')
        addPicture()
    }
}, [hasGet])

Vue3利用flex实现瀑布流源码

<template>
    <!-- 行 -->
    <div class="flex-row">
        <!-- 列 -->
        <!-- 一共有4列,每一列里的元素单独填充 -->
        <div class="flex-column" v-for="(item, index) in allColumnData" :key="index">
            <div class="flex-column-ele" v-for="(curItem, index) in item" :key="curItem.id">
                <img :src="curItem.imgUrl">
                <p>{{ curItem.desc }}</p>
            </div>
        </div>
    </div>
</template>

<script lang='ts' setup>
import { axios } from './server'
import { nextTick, reactive, ref, watch, onMounted } from 'vue'
type waterFallItem = {
    id: number,
    imgUrl: string,
    desc: string
}
const columnCount = 4;
let data = await axios('./waterFall.json');
let allColumnData = reactive<waterFallItem[][]>(Array.from(new Array(4), () => new Array()));
for (let i = 0; i < data.length && i < columnCount; i++) {
    allColumnData[i].push(data[i]);
}

// 瀑布流布局:取出数据源中最靠前的一个并添加到瀑布流高度最小的那一列,等图片完全加载后重复该循环
let observerObj = new IntersectionObserver(
    (entries) => {
        for (const entry of entries) {
            const { target, isIntersecting } = entry
            if (isIntersecting) {
                addPicture()
                observerObj.unobserve(target)
            }
        }
    },
    {
      rootMargin: '0px 0px 300px 0px', // 提前加载高度
    },
)

let dataIndex = columnCount;
const addPicture = () => {
    if (dataIndex >= data.length) {
        alert('图片已加载完成')
        return
    }
    
    let columnArray: NodeListOf<HTMLElement> = document.querySelectorAll('.flex-column');
    let eleHeight = [];
    for (let i = 0; i < columnArray.length; i++) {
        eleHeight.push(columnArray[i].offsetHeight)
    }
    
    // 每次找出最小的
    let minEle = Math.min(...eleHeight)
    let index = eleHeight.indexOf(minEle)
    
    // 然后把下一个data元素添加在上面高度最矮的这一列里
    allColumnData[index].push(data[dataIndex++]);
    
    // 为了防止渲染错乱,我们需要等待当前被添加到最低列的元素出现在可视窗口后,再去加载下一个元素
    nextTick(() => {
        columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        observerObj.observe(columnArray[columnArray.length - 1])
    })
}

onMounted(() => {
    addPicture();
})
</script>
 
<style lang = "less" scoped>
.flex-row {
    display: flex;
    flex-direction: row;
    width: 90vw;
    margin-left: 5vw;
    justify-content: space-around;
    align-items: flex-start;
}

// 可以利用meta属性做一个响应式,比如屏幕宽度超过多宽就显示5列,屏幕宽度为多宽就显示4列
.flex-column {
    display: flex;
    flex-direction: column;
    width: 25%;
    margin: 10px;
}

.flex-column-ele {
    img {
        width: 100%;
        max-height: 500px;
        object-fit: contain;
    }

    padding: 5px;
    margin: 5px;
    background-color: #f8f5f5;
    border-radius: 5px;
    box-shadow: 2px 5px 5px 0px #f3f3f3;
}
</style>
// waterFall.json
[{
        "id": 0,
        "imgUrl": "https://qcloud.dpfile.com/pc/q7QsMdJq_DS7J4xCUgesjjeicLbUbAFCPHHb8mBoN9o4jyZZRObLs5ym-WtN-3N1G45IiB1YIyNuDTtqzVRwesm_qA1Pf8rFcayTY-n-rG8.jpg",
        "desc": "1折起🔥成都最大超级折扣店‼️捡相因啦"
    },
    ...
    {
        "id": 10,
        "imgUrl": "https://qcloud.dpfile.com/pc/dHilnjl51w_qQEnsJ83shVOtIGNsQSgLBA8AUgWZrXeipuAflbCJKK6UI9lwcqKpwHHsQ-9MP97gy410T7ZcBMm_qA1Pf8rFcayTY-n-rG8.jpg",
        "desc": "成都市区|地铁直达免费拍绣球花海 🌸好震撼"
    }
]
// server.ts
type waterFallItem = {
    id: number,
    imgUrl: string,
    desc: string
}

export const axios = (url:string):Promise<waterFallItem[]> => {
    return new Promise((resolve) => {
        let xhr: XMLHttpRequest = new XMLHttpRequest()

        xhr.open('GET', url)

        xhr.onreadystatechange = () => {
            if(xhr.readyState === 4 && xhr.status === 200){
                setTimeout(() => {
                    resolve(JSON.parse(xhr.responseText))
                }, 500)
            }
        }

        xhr.send()
    })
}

React17利用flex实现瀑布流源码

import {axios} from "./server";
import {useEffect, useReducer, useState} from "react";
import './index.scss'

interface WaterFallItem {
    id: number;
    imgUrl: string;
    desc: string;
}

export const WaterFall = () => {
    const columnCount = 4;
    const [dataList, setDataList] = useState<WaterFallItem[]>([]);
    const [hasGet, setHasGet] = useState(false)
    const [allColumnData, setAllColumnData] = useState<WaterFallItem[][]>(Array.from(new Array(4), () => new Array()))
    const [_, forceUpdate] = useReducer(x => x + 1, 0)
    const [dataIndex, setDataIndex] = useState(4);
    const getData = async () => {
        return await axios('./waterFall.json');
    }

    useEffect(() => {
        getData().then(data => setDataList(data))
    }, [])

    useEffect(() => { // useEffect在页面初始化以及依赖项改变时执行,页面初始化时不需要追加图片
        if (dataList.length > 0) {
            initFirstRow()
        }
    }, [dataList])

    const initFirstRow = () => {
        let curData = allColumnData;
        for (let i = 0; i < dataList.length && i < columnCount; i++) {
            curData[i].push(dataList[i]);
        }
        setAllColumnData(curData)
        // 此处需要执行强制刷新
        forceUpdate()
        setHasGet(prevState => !prevState)
    }

    useEffect(() => {
        if (dataList.length > 0) {// 跳过页面初始化
            addPicture()
        }
    }, [hasGet])

    const addPicture = () => {
        if (dataIndex >= dataList.length) {
            alert('图片已加载完成')
            return
        }
        let columnArray: NodeListOf<HTMLElement> = document.querySelectorAll('.flex-column');
        let eleHeight = [];
        for (let i = 0; i < columnArray.length; i++) {
            eleHeight.push(columnArray[i].offsetHeight)
        }
        // 每次找出最小的
        let minEle = Math.min(...eleHeight)
        let index = eleHeight.indexOf(minEle)
        // 然后把下一个data元素添加在上面高度最矮的这一列里
        let curData = allColumnData
        curData[index].push(dataList[dataIndex])
        setDataIndex(n => n + 1)
        setAllColumnData(curData)
        forceUpdate()
        startObserve(index)
    }

    const startObserve = (index: number) => {
        let columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
        // 瀑布流布局:取出数据源中最靠前的一个并添加到瀑布流高度最小的那一列,等图片完全加载后重复该循环
        let observerObj = new IntersectionObserver(
            (entries) => {
                for (const entry of entries) {
                    const {target, isIntersecting} = entry
                    if (isIntersecting) {
                        observerObj.unobserve(target)
                        setHasGet(prevState => !prevState)
                    }
                }
            }
        )
        observerObj.observe(columnArray[columnArray.length - 1])
    }

    return (
        <div>
            <div className={'flex-row'}>
                {
                    allColumnData.map((item, index) => (
                        <div className={'flex-column'} key={index}>
                            {
                                item.map((curItem) => (
                                    <div className={'flex-column-ele'} key={curItem.id}>
                                        <img src={curItem.imgUrl}/>
                                        <p>{curItem.desc}</p>
                                    </div>
                                ))
                            }
                        </div>
                    ))
                }
            </div>

        </div>
    );
}

总结

本文利用flex布局,结合IntersectionObserver API以及异步加载DOM的完成了一个简易版的瀑布流。感兴趣的朋友可以在源码的基础上继续完善,通过懒加载和瀑布流的结合来实现无限滚动的瀑布流。
可能有朋友会有疑问,瀑布流不是一个一个的追加元素吗?为什么还需要结合懒加载呢?这是因为考虑到效率问题,后台很有可能会分页返回数据给前台,而瀑布流只是做了当前页的逐个加载,因此还需要配合懒加载去请求每一页的数据。