瀑布流是前端的一种常见的布局,具体表现形式是多栏布局,宽度固定,高度不定。与此同时,随着页面的滚动,会不断有新的元素添加到瀑布流尾部的最低列。
常见的瀑布流有如下三种样式:
目前,市面上常见的瀑布流开源插件采用的布局方案主要有以下几种:
鉴于让后台接口直接返回图片的宽高不太现实&交错式布局难度最大。因此本次实践采用的是Flex布局的方案来实现交错式瀑布流。
因为在渲染当前图片前,需要得知每一列的高度,因此在添加当前图片至瀑布流之前,我们需要保证前一张添加的图片已出现在页面上。那如何得知前一张图片已经渲染成功呢?并且如何在前一张图片渲染成功之后再添加新的图片呢?
这就涉及到Vue
和React
的生命周期,以及IntersectionObserver
API的概念了:
IntersectionObserver(交叉观察器)
可以自动监听元素是否进入了设备的可视区域之内,而不需要通过频繁的计算(比如我们常见的根据滚动条是否触底来做的一些判断)。由于可见的本质是,目标元素与视口产生一个交叉区,所以这个API叫做“交叉观察器”。
通过上面简述,其实我们常提的图片懒加载就可以采用Intersection
API来实现呀,这样也免去了与滚动条的距离计算,才是上上策~
let observerObj = new IntersectionObserver(callback, option);
Intersection
是浏览器原生提供的构造函数,接收两个参数:
callback
: 可见性发生变化时的回调函数,包含changes和observer两个参数option
:配置对象(可选)observe
: 开始监听特定元素unobserve
: 停止监听特定元素disconnect
: 关闭监听工作takeRecords
: 返回所有观察目标的对象数组IntersectionObserverEntry对象
IntersectionObserverEntry对象
是我们本文实现尾部追加元素的重要属性之一!该对象内部包含如下几个属性:
boundingClientRect
:目标元素的矩形区域的信息intersectionRatio
: 目标元素的可见比例,即intersectionRect占boundingClientRect的比例。完全可见时为1,完全不可见时小于等于0intersectionRect
:目标元素与视口(或根元素)的交叉区域的信息isIntersecting
: 布尔值,目标元素与交集观察者的根节点是否相交(常用)isVisible
: 布尔值,目标元素是否可见(该属性还在试验阶段,不建议在生产环境中使用)rootBounds
: 根元素的矩形区域额的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回nulltarget
: 被观察的目标元素,是一个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', // 提前加载
},
)
nextTick
是Vue
中异步调用DOM的解决方案,它可以保证我们前一张图片渲染至页面上之后,再执行下一次渲染的操作,个人认为,有点类似于onMounted
钩子函数:
nextTick(() => {
columnArray = document.querySelectorAll('.flex-column')[index].querySelectorAll('.flex-column-ele');
// 添加交叉监听器
observerObj.observe(columnArray[columnArray.length - 1])
})
在React
中,useEffect
的执行时机是页面渲染完成之后,因此,我们只需要把监听上一张图片是否渲染完成,以及加载下一张图片的函数在useEffect
里引入即可:
useEffect(() => {
if (dataList.length > 0) {// 跳过页面初始化
console.log('添加图片')
addPicture()
}
}, [hasGet])
<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()
})
}
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的完成了一个简易版的瀑布流。感兴趣的朋友可以在源码的基础上继续完善,通过懒加载和瀑布流的结合来实现无限滚动的瀑布流。
可能有朋友会有疑问,瀑布流不是一个一个的追加元素吗?为什么还需要结合懒加载呢?这是因为考虑到效率问题,后台很有可能会分页返回数据给前台,而瀑布流只是做了当前页的逐个加载,因此还需要配合懒加载去请求每一页的数据。