在后台项目中,需要陈列大量数据时,我们大多采用的是分页展示,当用户获取额外数据时,需要在页脚点击翻页操作。而在前台或移动端项目里,则采用的是瀑布流布局,用户只需一滑到底,交互方式更加便捷,同时降低了界面的复杂度,节省页面空间。目前,在电商应用、短视频平台、图片网站中瀑布流布局,更是随处可见。今天学习如何建立瀑布流组件?
1. 通用组件:瀑布流
如上图所示,我们把每个照片看是一个item
组件,当获取数据后,item
需要先横向排列铺满,铺满后下一行item
顺序连接到当前最短的列中。 所以,每个item
就需要使用absolute
绝对布局,通过 top
和left
来手动控制位置。同时,当在移动端下,item
的渲染列数也要发生变化,以及在不知道图片高度的情况下,是否需要图片的预渲染。
综上分析后,瀑布流组件的构建需要分成以下几个部分:
-
通过
props
传递关键数据:data
:数据源nodeKey
:唯一标识column
:渲染的列数picturePreReading
:是否需要图片预渲染
-
瀑布流渲染机制:通过
absolute
配合relative
完成布局,布局逻辑为:每个item
应该横向排列,第二行的item
顺序连接到当前最短的列中 -
通过作用域插槽 ,将每个
item
中涉及到的关键数据,传递到item
视图中。
js
<waterfall
:data="" // 数据源
:nodeKey="" // 唯一标识的 key
:column="" // 渲染的列数
:picturePreReading="" // 是否需要图片预渲染(在不知道图片高度的情况下)
>
<template v-slot="{ item, width }">
// 对应的item
</template>
</waterfall>
1.1. 获取容器宽度与列宽
- 定义组件接收参数:
js
const props = defineProps({
// 数据源
data: {
type: Array,
required: true
},
// 唯一标识的 key
nodeKey: {
type: String
},
// 列数
column: {
default: 2,
type: Number
},
// 列间距
columnSpacing: {
default: 20,
type: Number
},
// 行间距
rowSpacing: {
default: 20,
type: Number
},
// 是否需要进行图片预读取
picturePreReading: {
type: Boolean,
default: true
}
})
- 构建对应的基础视图:
- 因为当前为 relative 布局,所以需要主动指定高度
- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染
- 通过动态的 style 来去计算对应的列宽、left、top
js
<template>
<div class="relative" ref="containerTarget" :style="{ height: containerHeight + 'px' }">
<!-- 数据渲染 -->
<template v-if="columnWidth && data.length">
<div
class="m-waterfall-item absolute duration-300"
:style="{
width: columnWidth + 'px',
left: item._style?.left + 'px',
top: item._style?.top + 'px'
}"
v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index"
>
<slot :item="item" :width="columnWidth" :index="index" />
</div>
</template>
<!-- 加载中 -->
<div v-else>加载中...</div>
</div>
</template>
-
根据以上基础视图,我们需要生成对应的:
containerHeight
:总高度columnWidth
:列宽item._style.left
:每个item
对应的left
item._style.right
:每个item
对应的right
-
想要计算 总高度 ,那么需要计算出 每一列的高度 ,最高的一列为 总高度
ini
// 容器的总高度
const containerHeight = ref(0)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。
*/
const useColumnHeightObj = () => {
columnHeightObj.value = {}
for (let i = 0; i < props.column; i++) {
columnHeightObj.value[i] = 0
}
}
-
想要计算列宽,那么首先需要有容器的总宽度:
-
getComputedStyle()
这个方法来获取当前元素的样式- 方法是window的方法,可以直接使用
- 需要两个参数:
- 第一个:要获取样式的元素
- 第二个:可以传递一个伪元素,一般传 null
-
parseFloat()
函数可解析一个字符串,并返回一个浮点数- 该函数指定字符串中的首个字符是否是数字。如果是,则对字符串进行解析,直到达到数字的末端为止,然后以数字返回,而不是字符串。
-
js
// 容器实例
const containerTarget = ref(null)
// 容器总宽度 (不包含 padding、margin、border)
const containerWidth = ref(0)
// 容器左边距,计算 item left 时,需要使用定位
const containerLeft = ref(0)
/**
* 计算容器宽度
*/
const useContainerWidth = () => {
const { paddingLeft, paddingRight } = getComputedStyle(containerTarget.value, null)
// 容器左边距
containerLeft.value = parseFloat(paddingLeft)
// 容器宽度
containerWidth.value =
containerTarget.value.offsetWidth - parseFloat(paddingLeft) - parseFloat(paddingRight)
}
- 计算列宽
- 列宽 = (容器宽度 - 列间距) / 列数
js
// 列宽
const columnWidth = ref(0)
// 列间距合计
const columnSpacingTotal = computed(() => {
// 如果是5列,则存在 4 个列间距
return (props.column - 1) * props.columnSpacing
})
/**
* 开始计算
*/
const useColumnWidth = () => {
// 获取容器宽度
useContainerWidth()
// 计算列宽
columnWidth.value = (containerWidth.value - columnSpacingTotal.value) / props.column
}
onMounted(() => {
// 计算列宽
useColumnWidth()
})
1.2. 区分图片预加载,获取元素关键属性
接下来需要获取每一个item
的高度,因为只有有了每个item
高,才可以判断下一列的第一个item
位置。
同时我们根据picturePreReading
,可分为两种情况:
- 需要图片预加载时:图片高度未知
- 不需要图片预加载时:图片高度已知
1.2.1. 需要图片预加载时(图片高度不知)
获取图片高度信息时,需要使用一些通用工具方法,创建utils.js
进行封装
js
/**
* 从 itemElement 中抽离出所有的 imgElements
*/
export const getImgElements = (itemElements) => {
const imgElements = []
itemElements.forEach((el) => {
imgElements.push(...el.getElementsByTagName('img'))
})
return imgElements
}
/**
* 生成所有的图片链接数组
*/
export const getAllImg = (imgElements) => {
return imgElements.map((imgElement) => {
return imgElement.src
})
}
/**
* 监听图片数组加载完成(通过 promise 完成)
*/
export const onComplateImgs = (imgs) => {
// promise 集合
const promiseAll = []
// 循环构建 promiseAll
imgs.forEach((img, index) => {
promiseAll[index] = new Promise((resolve, reject) => {
const imageObj = new Image()
imageObj.src = img
imageObj.onload = () => {
resolve({
img,
index
})
}
})
})
return Promise.all(promiseAll)
}
等图片加载完成,获取item
高度集合
js
import { getImgElements, getAllImg, onComplateImgs } from './utils.js'
// item 高度集合
let itemHeights = []
/**
* 监听图片加载完成
*/
const waitImgComplate = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('waterfall-item')]
// 获取所有元素的 img 标签
const imgElements = getImgElements(itemElements)
// 获取所有 img 标签的图片
const allImgs = getAllImg(imgElements)
// 图片加载完成,获取高度
onComplateImgs(allImgs).then(() => {
itemElements.forEach((el) => {
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
})
}
1.2.2. 不需要图片预加载时(图片高度已知)
js
/**
* 图片不需要预加载时,计算 item 高度
*/
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
1.2.3. 触发计算,定位item
位置
- 监听数据获取时,触发对应的计算:等页面渲染完成后,根据图片是否需要预加载,获取
item
高度集合
js
// 触发计算
watch(
() => props.data,
(newVal) => {
// 页面渲染完成后
nextTick(() => {
props.picturePreReading ? waitImgComplate() : useItemHeight()
})
},
{
immediate: true,
deep: true
}
)
- 为每个
item
生成位置属性
js
const useItemLocation = () => {
// 遍历数据源
props.data.forEach((item, index) => {
// 避免重复计算
if (item._style) {
return
}
// 生成 _style 属性
item._style = {}
// left
item._style.left = getItemLeft()
// top
item._style.top = getItemTop()
// 指定列高度自增
increasingHeight(index)
})
// 指定容器高度
containerHeight.value = getMaxHeight(columnHeightObj.value)
}
- 创建
getItemLeft
方法:- 要获取当前
item
的左边距,要先找到最小高度列 - 先找到最小高度,在根据最小高度从列高对象中确定所在列
- 要获取当前
js
/**
* 返回列高对象中的最小的高度
*/
export const getMinHeight = (columnHeightObj) => {
const columnHeightArr = Object.values(columnHeightObj)
return Math.min(...columnHeightArr)
}
/**
* 返回列高对象中的最小高度所在的列
*/
export const getMinHeightColumn = (columnHeightObj) => {
const minHeight = getMinHeight(columnHeightObj)
return Object.keys(columnHeightObj).find((key) => {
return columnHeightObj[key] === minHeight
})
}
- 左边距 = (列宽 + 列间距)* 列数 + 容器左边距
js
import { getMinHeightColumn } from './utils.js'
/**
* 返回下一个 item 的 left
*/
const getItemLeft = () => {
// 最小高度所在的列 * (列宽 + 间距)
const column = getMinHeightColumn(columnHeightObj.value)
return (
column * (columnWidth.value + props.columnSpacing) + containerLeft.value
)
}
- 创建
getItemTop
方法getMinHeight
方法,列高对象中的最小的高度
javascript
import { getMinHeight } from './utils.js'
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
- 创建
increasingHeight
方法:- 当前
item
插入后,列高对象数据需要更新 - 最小高度列的数据 =
item
高度 + 行间距
- 当前
js
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] += itemHeights[index] + props.rowSpacing
}
- 创建
getMaxHeight
方法- 容器高度 = 列高对象中的最大高度
js
/**
* 返回列高对象中的最大的高度
*/
export const getMaxHeight = (columnHeightObj) => {
const columnHeightArr = Object.values(columnHeightObj)
return Math.max(...columnHeightArr)
}
- 在组件销毁时,清除所有的
_style
js
/**
* 在组件销毁时,清除所有的 _style
*/
onUnmounted(() => {
props.data.forEach((item) => {
delete item._style
})
})
- 最后,在第一次获取数据时,构建高度记录容器
js
// 触发计算
watch(
() => props.data,
(newVal) => {
// 重置数据源
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
// 构建高度记录容器
useColumnHeightObj()
}
...
},
{
immediate: true,
deep: true
}
)
1.2.4. 适配移动端,动态列
- 判断当前是否为移动设备:
js
/**
* 判断当前是否为移动设备
*/
export const isMobileTerminal = computed(() => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
})
- 列数的变化
js
:column="isMobileTerminal ? 2 : 5"
- 列宽和定位
- 当列数变化后,重新构建瀑布流
js
/**
* 监听列数变化,重新构建瀑布流
*/
const reset = () => {
// 延迟 100 毫秒,否则会导致宽度计算不正确
setTimeout(() => {
// 重新计算列宽
useColumnWidth()
// 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触发 data 的 watch
props.data.forEach((item) => {
item._style = null
})
}, 300)
}
/**
* 监听列数变化
*/
watch(
() => props.column,
() => {
if (props.picturePreReading) {
// 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染
columnWidth.value = 0
// 等待页面渲染之后,重新执行计算。否则在 item 没有指定过高度的前提下,计算出的 item 高度会不正确
nextTick(reset)
} else {
reset()
}
}
)
1.2.5. 无需图片预加载时,优化功能处理
此时当我们把 picturePreReading
修改为 false
时,我们发现瀑布流展示会出现错误。
出现这个错误的原因是因为:当我们不去进行图片预加载时,会直接 获取 waterfall-item
,得到 waterfall-item
的高度 。但是因为 图片还没有获取完成 ,所以得到的高度 不包含 图片高度,从而导致计算的高度错误。
需要利用服务端给我们返回的图片高度这个数据,来构建图片高度,从而跳过图片预加载的过程。
- 作用域插槽中,拿到列宽
- 将列宽传给
item
js
<template v-slot="{ item, width }">
<itemVue :data="item" :width="width" />
</template>
- 利用列宽, 按照宽度比例, 算出缩放比, 在根据图片高度 ,算出
item
高度
html
<img
class="w-full rounded bg-transparent"
:src="data.photo"
:style="{
height: (width / data.photoWidth) * data.photoHeight + 'px'
}"
/>
1.3. 总结
整个瀑布流的构建过程:
- 瀑布流的核心就是:通过
relative
和absolute
定位的方式,来控制每个item
的位置 - 给每个
item
添加位置属性left
top
,再通过动态绑定style
实现 left
top
的值需要获取每个item
的高度 ,item
高度主要由img
决定- 当服务端 不返回
img
的高度时,我们需要等待img
加载完成之后,计算高度 - 当服务端 返回
img
高度时:利用此高度为item
进行高度设定。
- 当服务端 不返回
- 拿到
item
的高度后遍历item
,通过 高度记录容器 ,获取当前item
的top
left
, 同时给最小列高度自增。 - 当进行响应式切换 时,需要重新计算 列宽 和 定位