瀑布流核心功能实现
核心功能需求描述
我们希望瀑布流组件的使用方式如下:
- 瀑布流每张图片区域的内容是可以定制的,所以要有一个slot
- 可以指定列数,所有要有传参column
- 可以指定图片是否需要预渲染,所以要有传参picturePreReading
- 数据源从外部传入
<m-waterfall
:data="" // 数据源
:nodeKey="" // 唯一标识的 key
:column="" // 渲染的列数
:picturePreReading="" // 是否需要图片预渲染(在不知道图片高度的情况下)
>
<template v-slot="{ item, width }">
// 对应的item
</template>
</m-waterfall>
核心功能实现思路
瀑布流必须是绝对定位来实现,因为别的布局方式无法实现此功能,所以每张图片都有left和top,我们的核心任务就是计算每张图片的left和top。
在计算每张图片的位置前,我们必须得到所有图片的宽和高,而图片的宽和高,如果后端没有返回,则必须等待所有的图片都加载完成才能拿到,图片的位置必须等待全部图片加载完以后再开始计算。如果后端有返回,则可以直接开始计算。
核心思路如下:
- 一开始要算出每张图片的宽度,每张图片的宽度 = (容器总宽度 - 所有列间距) / 列数。
- 通过promise.all方式预加载所有的图片,目的是为了拿到所有的图片的宽和高。
- 对于每张图片的left的计算,假设一共有n列,则每一列的left值都是固定的,第n列的left = n * (图片 的宽度 + 列编剧)。
- 对于每张图片的top计算,假设一共有n列,我们需要定义n个变量记录当前列的高度h,每一列的高度初始值都是0。每当新增一张图片,当前列的列高都是添加当前图片的高度 + 行间距。所以,第n列的top值 = 第n列的累计高度。
- 下一张图片应该放在哪一列,则是要计算所有列中高度最短的是哪一列,当有高度一样的列时候,优先选择靠前的列。
核心实现代码
- 所有props
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
}
})
- 通过promis.all预加载所有的图片,以便后续获得每张图片的宽高
// item 高度集合
let itemHeights = []
/**
* 监听图片加载完成
*/
const waitImgComplate = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 获取所有元素的 img 标签
const imgElements = getImgElements(itemElements)
// 获取所有 img 标签的图片
const allImgs = getAllImg(imgElements)
onComplateImgs(allImgs).then(() => {
// 图片加载完成,获取高度
itemElements.forEach((el) => {
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
})
}
/**
* 生成所有的图片链接数组
*/
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)
}
- 计算每一列列宽
// 容器实例
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)
}
// 列宽
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()
console.log(columnWidth.value)
})
- 初始化每一列的高度
// 容器的总高度
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
}
}
- 计算每一张图片的left和top
/**
* 为每个 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 的 left
*/
const getItemLeft = () => {
// 最小高度所在的列 * (列宽 + 间距)
const column = getMinHeightColumn(columnHeightObj.value)
return (
column * (columnWidth.value + props.columnSpacing) + containerLeft.value
)
}
/**
* 返回列高对象中的最小高度所在的列
*/
export const getMinHeightColumn = (columnHeightObj) => {
const minHeight = getMinHeight(columnHeightObj)
return Object.keys(columnHeightObj).find((key) => {
return columnHeightObj[key] === minHeight
})
}
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] +=
itemHeights[index] + props.rowSpacing
}
/**
* 返回列高对象中的最大的高度
*/
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()
}
...
},
{
immediate: true,
deep: true
}
)
到此瀑布流的核心功能已经实现完成。
瀑布流的非核心功能实现
长列表实现瀑布流自动加载
我们希望瀑布流图片滚动到底部的时候,自动加载下一页,所以在这要用到长列表。
我们希望长列表的使用方式如下
<m-infinite-list
v-model="" // 当前是否处于加载状态
:isFinished="" // 数据是否全部加载完成
@onLoad="" // 加载下一页数据的触发事件
>
列表
</m-infinite-list>
实现长列表最主要用到的函数是 IntersectionObserver,该接口可以判断:目标元素与其祖先元素或顶级文档视窗( viewport )的交叉状态(是否可见)
核心原理就是,在列表的最底部,固定写入一个元素,然后利用IntersectionObserver函数监听该元素是否出现在可视区内,如果是,则emit触发加载事件。
实现代码
<template>
<div>
<!-- 内容 -->
<slot />
<div ref="laodingTarget" class="h-6 py-4">
<!-- 加载更多 -->
<m-svg-icon v-show="loading" class="w-4 h-4 mx-auto animate-spin" name="infinite-load"></m-svg-icon>
<!-- 没有更多数据了 -->
<p v-if="isFinished" class="text-center text-base text-zinc-400">
已经没有更多数据了!
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useVModel } from '@vueuse/core'
import { useIntersectionObserver } from '@vueuse/core'
const props = defineProps({
// 是否处于加载状态
modelValue: {
type: Boolean,
required: true
},
// 数据是否全部加载完成
isFinished: {
type: Boolean,
default: false
}
})
const emits = defineEmits(['onLoad', 'update:modelValue'])
// 处理 loading 状态
const loading = useVModel(props)
// 滚动的元素
const laodingTarget = ref(null)
useIntersectionObserver(
laodingTarget,
([{ isIntersecting }], observerElement) => {
// 当加载更多的视图可见时,加载更多数据
if (isIntersecting && !loading.value && !props.isFinished) {
// 修改加载数据标记
loading.value = true
// 触发加载更多行为
emits('onLoad')
}
}
)
</script>
<style></style>
配合瀑布流组件实现自动加载
<template>
<div>
<!-- 列表处理 -->
<m-infinite-list
v-model="isLoading"
:isFinished="isFinished"
@onLoad="getPexelsData"
>
<m-waterfall
...
</m-waterfall>
</m-infinite-list>
</div>
</template>
<script setup>
...
/**
* 构建数据请求
*/
let query = {
page: 1,
size: 20
}
// 数据是否在加载中
const isLoading = ref(false)
// 数据是否全部加载完成
const isFinished = ref(false)
// 数据源
const pexelsList = ref([])
/**
* 加载数据的方法
*/
const getPexelsData = async () => {
// 数据全部加载完成则 return
if (isFinished.value) {
return
}
// 完成第一次请求之后,后续请求让 page 自增
if (pexelsList.value.length) {
query.page += 1
}
// 触发接口请求
const res = await getPexelsList(query)
// 初始请求清空数据源
if (query.page === 1) {
pexelsList.value = res.list
} else {
pexelsList.value.push(...res.list)
}
// 判断数据是否全部加载完成
if (pexelsList.value.length === res.total) {
isFinished.value = true
}
// 修改 loading 标记
isLoading.value = false
}
</script>
图片懒加载功能
我们希望瀑布流的普通,等出现在可视区后,再加载图片。所以需要懒加载指令。
懒加载指定实现思路:
一开始不把图片地址写在src当中,而是写在自定义属性data-src里面,通过IntersectionObserver函数,监听每一张图片是否出现在可视区内,当图片出现在可视区当中,把data-src中的地址,赋值到图片的src内。
代码实现
import { useIntersectionObserver } from '@vueuse/core'
export default {
// 图片懒加载:在用户无法看到图片时,不加载图片,在用户可以看到图片后加载图片
// 如何判断用户是否看到了图片:useIntersectionObserver
// 如何做到不加载图片(网络):img 标签渲染图片,指的是 img 的 src 属性,src 属性是网络地址时,则会从网络中获取该图片资源。那么如果 img 标签不是网络地址呢?把该网络地址默认替换为非网络地址,然后当用户可见时,在替换成网络地址。
mounted(el) {
// 1. 拿到当前 img 标签的 src
const imgSrc = el.src
// 2. 把 img 标签的 src 替换为本地地址,也可以替换为空或者一个透明的图片
el.src = ''
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
if (isIntersecting) {
el.src = imgSrc
stop()
}
})
}
}