🌬🌬🌬前言:如上图,展示大量图片类数据时,前台页面一一般会采用 瀑布流 的排布方式进行处理,有点像原来玩的很火的一个游戏叫钢琴🎹🎹🎹黑白键,当然这就更需要我们考虑一下如何在兼容性能的情况下提供良好的交互体验,下面我们一起来看看 👀 (ps:冬天真的超级适合吃热量爆棚的东西,吃不到就过一下眼瘾吧,哈哈~🍜🍲🍢🍡🍪🍩🌰)
一、构建基础列表
大致目录结构
---| src
---| libs // 通用组件
---|---| waterfall // 瀑布流组件
---| utils // 工具类
---| api // 接口api集合
---|---| pexels.js // 图片接口api(图片来源pexel)
---|---| views // 视图
---|---|---| main
---|---|---|---| index.vue
---|---|---|---| components
---|---|---|---|--| list // 列表组件
---|---|---|---|--|---| index.vue // 列表展示组件
---|---|---|---|--|---| item.vue // item 的视图组件
1. 定义数据接口 waterfall.js
import request from '@/utils/request'
/**
* 获取图片数据列表
*/
export const getWaterfallList = (data) => {
return request({
url: '/waterfall/list',
params: data
})
}
2. list的基本布局 src/views/main/index.vue
样式基于Tailwind CSS
<template>
<div class="h-full overflow-auto bg-white dark:bg-zinc-800 duration-500">
<div class="max-w-screen-xl mx-auto relative m-1 xl:mt-4">
<list-vue></list-vue>
</div>
</div>
</template>
<script setup>
import listVue from './components/list/index.vue'
</script>
3. 获取data数据,渲染item src/views/main/components/list/index.vue
<template>
<div>
<item-vue v-for="item in waterfallList" :key="item.id" :data="item"></item-vue>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { getWaterfallList } from '@/api/waterfall'
import itemVue from './item.vue'
/*
* 获取data
*/
let query = {
page: 1,
size: 20
}
const waterfallList = ref([])
const getWaterfallData = async () => {
const res = getWaterfallList(query)
waterfallList.value = res.list
}
getWaterfallData()
</script>
4. item基本布局 src/views/main/components/list/item.vue
<template>
<div
class="bg-white dark:bg-zinc-900 xl:dark:bg-zinc-800 rounded pb-1 h-[280px] w-[230px]"
>
<div class="relative w-full rounded cursor-zoom-in group">
<!-- 图片 -->
<img class="w-full rounded bg-transparent" :src="data.photo" />
<!-- 遮罩层 -->
<div
class="hidden opacity-0 w-full h-full bg-zinc-900/50 absolute top-0 left-0 rounded duration-300 group-hover:opacity-100 xl:block"
>
<!-- 操作按钮 预览、分享... -->
...
</div>
</div>
<!-- 标题 -->
<p
class="text-sm mt-1 font-bold text-zinc-900 dark:text-zinc-300 line-clamp-2 px-1" alt>
{{ data.title }}
</p>
<!-- 其他数据... -->
...
</div>
</template>
<script setup>
defineProps({
data: {
type: Object,
required: true
}
})
</script>
分析一波🥸: item是横向排列,第二排的item是不是应该顺序排在最短的的列下面,那通过什么可以实现呢,思考🤔一下,下面我们来揭晓答案
bingo🧐:如果想让item精准晓得自己该往哪去,目前可以想到的方法就是
定位📌
,利用absolute绝对定位,通过top和left来手动控制位置,那我们来看看怎么才能计算出来这个位置呢?🫨
5. 瀑布流组件 src/libs/waterfall/index.vue
- 基础视图
<template>
<div
class="relative"
ref="containerTarget"
:style="{
height: containerHeight + 'px' // 因为当前为 relative 布局,所以需要主动指定高度
}"
>
<!-- 因为列数不确定,需要根据列数计算每列的宽度,因此需要等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 通过动态的 style 来去计算对应的列宽、left、top -->
<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>
</template>
- 所需参数
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
}
})
- 列高
const containerHeight = ref(0) // 容器的总高度
const columnHeightObj = ref({}) // 记录每列高度的容器。key:所在列 val:列高
/**
* 构建记录各列的高度的对象
*/
const useColumnHeightObj = () => {
columnHeightObj.value = {}
for (let i = 0; i < props.column; i++) {
columnHeightObj.value[i] = 0
}
}
-
容器宽度
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)
}
- 计算列宽
import { ref, onMounted } from 'vue'
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()
})
☃️ 到此我们已知
列宽
,但是我们要计算每个item的位置,还需要知道每个item的高,这样我们才可以判断下一个item的位置应该在哪里
picturePreReading 可以分为两种情况:
- 需要图片预加载时:图片高度未知
- 图片高度集合处理
// item 高度集合
let itemHeights = []
/**
* 监听图片加载完成
*/
const waitImgComplate = () => {
itemHeights = []
// 所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 所有img元素
const imgElements = getImgElements(itemElements)
onComplateImgs(allImgs).then(() => {
// 图片加载完成,获取高度
itemElements.forEach((el) => {
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
})
}
- 工具类
src/libs/waterfall/utils.js
/**
* 从 itemElement 中抽离出所有的 imgElements
*/
export const getImgElements = (itemElements) => {
const imgElements = []
itemElements.forEach((el) => {
imgElements.push(...el.getElementsByTagName('img'))
})
return imgElements
}
/**
* 生成所有的图片src数组
*/
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 高度
*/
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
- 监听数据获取时,触发对应的计算
watch(
() => props.data,
(newVal) => {
nextTick(() => {
if (props.picturePreReading) {
// true 需要预加载
waitImgComplate()
} else {
// false 不需要
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
- 定位item
/**
* 为每个 item 生成位置属性
*/
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)
}
- 获取item距离左侧的距离
/**
* 返回下一个 item 的 left
*/
const getItemLeft = () => {
// 最小高度所在的列 * (列宽 + 间距)
const column = getMinHeightColumn(columnHeightObj.value)
return (
column * (columnWidth.value + props.columnSpacing) + containerLeft.value
)
src/libs/waterfall/utils.js
/**
* 返回列高对象中的最小高度所在的列
*/
export const getMinHeightColumn = (columnHeightObj) => {
const minHeight = getMinHeight(columnHeightObj)
return Object.keys(columnHeightObj).find((key) => {
return columnHeightObj[key] === minHeight
})
}
/**
* 返回列高对象中的最小的高度
*/
export const getMinHeight = (columnHeightObj) => {
const columnHeightArr = Object.values(columnHeightObj)
return Math.min(...columnHeightArr)
}
- 获取item距离顶部的距离
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
这样我们可以通过以上的方法可以得到每一列中最小高度的元素对应的
left
和top
值啦🎊
- increasingHeight 最小高度元素的所在列进行自增
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] +=
itemHeights[index] + props.rowSpacing
}
src/libs/waterfall/utils.js
/**
* 返回列高对象中的最大的高度
*/
export const getMaxHeight = (columnHeightObj) => {
const columnHeightArr = Object.values(columnHeightObj)
return Math.max(...columnHeightArr)
}
- 在第一次获取数据时,构建高度记录容器 🛎️
watch(
() => props.data,
(newVal) => {
// 重置数据源
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
// 构建高度记录容器
useColumnHeightObj()
}
nextTick(() => {
if (props.picturePreReading) {
// true 需要预加载
waitImgComplate()
} else {
// false 不需要
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
- 在组件销毁时,清除所有的 _style 🧹🧹🧹
/**
* 在组件销毁时,清除所有的 _style
*/
onUnmounted(() => {
props.data.forEach((item) => {
delete item._style
})
})
小结一下⛄️: 至此,瀑布流呈现的形态大致是我们想要的效果,但是还有一些细节的点还需要优化,还有如果我们还要适配移动端的话,应该怎么做呢?下面我们来展开说说🎆
6. 适配移动端
区分
pc端
和移动端
,这里强烈推荐使用Vue的自定义钩子集合useWindowSize | VueUse,非常方便👍👍👍👍👍👍utils
import { computed } from "vue";
import { useWindowSize } from "@vueuse/core";
const { width } = useWindowSize();
const PC_DEVICE_WIDTH = 1280;
// 判断当前设备是否为移动设备,判断依据是屏幕宽度是否大于指定值(1280)
export const isMobileTerminal = computed(() => {
// return document.documentElement.clientWidth < PC_DEVICE_WIDTH;
return width.value < PC_DEVICE_WIDTH;
});
- 根据 isMobileTerminal 传入不同的列数
src/views/main/components/list/index.vue
<template>
<div>
<m-waterfall
:data="pexelsList"
nodeKey="id"
:column="isMobileTerminal ? 2 : 5"
class="w-full px-1"
:picturePreReading="false"
>
<template v-slot="{ item, width }">
<item-vue :data="item" :width="width"></item-vue>
</template>
</m-waterfall>
</div>
</template>
<script setup>
import { isMobileTerminal } from "@/utils/flexible.js";
</script>
- 列数变化时,重新计算
- 未知高度时
src/libs/waterfall/index.vue
// 监听列数变化,重新构建瀑布流 const reset = () => { // 延迟 100 毫秒,否则会导致宽度计算不正确 setTimeout(() => { // 重新计算列宽 useColumnWidth() // 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触data 的 watch props.data.forEach((item) => { item._style = null }) }, 100) } // 监听列数变化 watch( () => props.column, () => { // 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染 columnWidth.value = 0 reset() } )
- 未知高度时
- 高度已知时
按照宽高比,计算出高度值src/views/main/components/list/item.vue
不需要重置视图<img class="w-full rounded bg-transparent" :src="data.photo" :style="{ height: (width / data.photoWidth) * data.photoHeight + 'px' }" />
src/libs/waterfall/index.vue
// 监听列数变化,重新构建瀑布流 const reset = () => { // 重新计算列宽 useColumnWidth() // 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触发 data 的 watch props.data.forEach((item) => { item._style = null }) } watch( () => props.column, () => { if (props.picturePreReading) { // 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染 columnWidth.value = 0 // 等待页面渲染之后,重新执行计算。否则在 item 没有指定过高度的前提下,计算出的 item 高度会不正确 nextTick(reset) } else { reset() } } )
完事👏👏👏👏👏👏