vue3--实现瀑布流-长列表-懒加载
前言
在这一章我们主要学习:瀑布流、长列表、懒加载等功能
瀑布流组件
数据格式
[
{
"tags": [
"all",
"home",
"desire",
"pets"
],
"_id": "62208123fb7e8b6da85b7dfe",
"photoLink": "https://www.pexels.com/zh-cn/photo/8051987/",
"photo": "https://images.pexels.com/photos/8051987/pexels-photo-8051987.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
"authorLike": "https://www.pexels.com/zh-cn/@ugurcan-ozmen-61083217",
"avatar": "https://images.pexels.com/users/avatars/61083217/ugurcan-ozmen-235.jpeg?auto=compress&fit=crop&h=60&w=60",
"author": "Uğurcan Özmen",
"photoDownLink": "https://www.pexels.com/photo/8051987/download/",
"id": "8051987",
"title": "图片数据来自 pexels ",
"photoWidth": 500,
"photoHeight": 625,
"photoType": "jpg",
"__v": 0
},
...
]
构建基础列表展示
api/pexels.js
import request from '@/utils/request.js'
/**
* 获取图片列表数据
*/
export const getPexelsList = (data) => {
return request({
url: "/api/pexels/list",
params: data,
})
}
views/home/index.vue
<template>
<div class="home-container h-full overflow-auto bg-white dark:bg-zinc-800 duration-500">
<Navigation></Navigation>
<div class=" max-w-screen-xl mx-auto relative m-1 xl:mt-4">
<ListVue></ListVue>
</div>
</div>
</template>
<script setup>
import Navigation from "./components/Navigation/index.vue";
import ListVue from "./components/list/index.vue";
</script>
views/home/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" alt="" />
<!-- 遮罩层 -->
<div
class="hidden opacity-0 w-full h-full bg-zinc-900/50 absolute top-0 right-0 left-0 rounded duration-300 group-hover:opacity-100 xl:block"
>
<!-- 分享按钮 -->
<m-button class="absolute top-1.5 left-1.5">分享</m-button>
<!-- 点赞按钮 -->
<m-button
class="absolute top-1.5 right-1.5"
type="info"
icon="heart"
iconClass="fill-zinc-900 dark:fill-zinc-200"
></m-button>
<!-- 下载按钮 -->
<m-button
class="absolute bottom-1.5 left-1.5 bg-zinc-100/70"
type="info"
icon="download"
size="small"
iconClass="fill-zinc-900 dark:fill-zinc-200"
></m-button>
<!-- 全屏按钮 -->
<m-button
class="absolute bottom-1.5 right-1.5"
type="info"
icon="full"
size="small"
iconClass="fill-zinc-900 dark:fill-zinc-200"
></m-button>
</div>
</div>
<!-- 标题 -->
<p class="text-sm mt-1 font-bold text-zinc-900 dark:text-zinc-300 px-1">
{{ data.title }}
</p>
<!-- 作者信息 -->
<div class="flex items-center mt-1 px-1">
<img class="h-2 w-2 rounded-full" :src="data.avatar" alt="" />
<span class=" text-sm text-zinc-500 ml-1">{{ data.author }}</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
required: true
}
})
</script>
<style lang="scss" scoped></style>
views/home/components/list/index.vue
<template>
<div>
<ItemVue v-for="item in pexelsList" :key="item.id" :data="item"></ItemVue>
</div>
</template>
<script setup>
import { getPexelsList } from '@/api/pexels'
import ItemVue from "./item.vue";
/**
* 构建数据请求
*/
let query = {
page: 1,
size: 20,
categoryId: '',
searchText: ''
}
// 数据是否在加载中
const isLoading = ref(false)
// 数据是否全部加载完成
const isFinished = ref(false)
// 数据源
const pexelsList = ref([])
const getPexlesData = 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
}
getPexlesData()
</script>
<style scoped lang="scss"></style>
瀑布流组件构建分析
在上面我们虽然实现了列表的基本构建,但是相比大家已经发现了,还是存在了很多的问题的。
存在的问题如下:
当前每一个item的排列,应该以横向的形式进行排列,在第二行中,第一个item不会按照上面的逻辑依次排列,而是第二行的第一个item会被放到第一行中高度最短的那个item下面,以此类推。
而这样排列的逻辑正是我们构建瀑布流的逻辑。
而要这样做,那么肯定每个item的排列不会像上面那样正常排列堆叠下去,而是需要使用absolute进行定位排列。
这里为了方便复用,我们会将其抽离封装成一个公共的组件--waterfall
大致使用如下:
<m-waterfall :data="" :nodeKey="" :column="" :picturePreReading="">
<template v-slot="{ item, width }">
<ItemVue :data="item"></ItemVue>
</template>
</m-waterfall>
- data:数据源
- nodeKey:唯一标识
- column:渲染的列数
- picturePreReading:是否需要图片预加载
瀑布流渲染机制:通过 absolute 配合 relative 完成布局,布局逻辑为:每个 item 应该横向排列,第二行的 item 顺序连接到当前最短的列中
通过 作用域插槽 将每个 item 中涉及到的关键数据,传递到 item 视图中
瀑布流组件构建
构建瀑布流布局:获取容器宽度与列宽
libs/waterfall/index.vue
<template>
<!-- 因为当前为 relative 布局,所以需要主动指定高度 -->
<div
class="relative"
ref="containerTarget"
:style="{ height: containerHeight + 'px' }"
>
<!-- 数据渲染 -->
<!-- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 通过动态的 style 来去计算对应的列宽、left、top -->
<div class="m-waterfall-item absolute duration-300" v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index" :style="{width: columnWidth + 'px', left: item._style?.left + 'px', top: item._style?.top + 'px'}">
<slot :item="item" :width="columnWidth" :index="index" />
</div>
</template>
<!-- 加载中的提示 -->
<div v-else>加载中...</div>
</div>
</template>
<script setup>
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)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。
*/
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)
}
// 列宽 列宽=容器的宽度-所有列间距宽度 / 列数
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()
})
</script>
views/home/components/list/index.vue
<template>
<div>
<m-waterfall :data="pexelsList" nodeKey="id" :column="5" :picturePreReading="true">
<template v-slot="{ item, width }">
<ItemVue :data="item"></ItemVue>
</template>
</m-waterfall>
</div>
</template>
<script setup>
import { getPexelsList } from '@/api/pexels'
import ItemVue from './item.vue'
/**
* 构建数据请求
*/
let query = {
page: 1,
size: 20,
categoryId: '',
searchText: ''
}
// 数据是否在加载中
const isLoading = ref(false)
// 数据是否全部加载完成
const isFinished = ref(false)
// 数据源
const pexelsList = ref([])
const getPexlesData = 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
}
getPexlesData()
</script>
区分图片预加载,获取元素关键属性
想要计算每列的 left、right,那么需要拿到每个 item 的高度,因为只有有了每个 item 高,才可以判断下一列的第一个 item 的位置
同时我们根据 picturePreReading 又可以分为两种情况:
- 需要图片预加载时:图片高度未知
- 不需要图片预加载时:图片高度已知
根据以上分析可以得出以下代码
根据两种不同的情况,我们需要有两个不同的方法来进行计算:
首先要去掉 views/home/components/list/item.vue
中,开始给定的宽高: h-[280px] w-[230px]
封装公共工具类
libs/waterfall/utils.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)
}
/**
* 返回列高对象中的最小高度所在的列
*/
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)
}
/**
* 返回列高对象中的最大的高度
*/
export const getMaxHeight = (columnHeightObj) => {
const columnHeightArr = Object.values(columnHeightObj)
return Math.max(...columnHeightArr)
}
需要图片预加载时
图片高度未知
libs/waterfall/index.vue
import {
getImgElements,
getAllImg,
onComplateImgs,
getMinHeightColumn,
getMinHeight,
getMaxHeight
} from './utils
/**
* 需要图片预加载时
*/
// 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()
})
}
不需要图片预加载时
图片高度已知
libs/waterfall/index.vue
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
完整代码
libs/waterfall/index.vue
<template>
<!-- 因为当前为 relative 布局,所以需要主动指定高度 -->
<div
class="relative"
ref="containerTarget"
:style="{ height: containerHeight + 'px' }"
>
<!-- 数据渲染 -->
<!-- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 通过动态的 style 来去计算对应的列宽、left、top -->
<div class="m-waterfall-item absolute duration-300" v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index" :style="{width: columnWidth + 'px', left: item._style?.left + 'px', top: item._style?.top + 'px'}">
<slot :item="item" :width="columnWidth" :index="index" />
</div>
</template>
<!-- 加载中的提示 -->
<div v-else>加载中...</div>
</div>
</template>
<script setup>
import {
getImgElements,
getAllImg,
onComplateImgs,
getMinHeightColumn,
getMinHeight,
getMaxHeight
} from './utils'
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)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。
*/
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)
}
// 列宽 列宽=容器的宽度-所有列间距宽度 / 列数
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 高度集合
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()
})
}
/**
* 不需要图片预加载时,计算 item 高度
*/
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
/**
* 为每个 item 生成位置属性
*/
const useItemLocation = () => {
}
// 触发计算
watch(
() => props.data,
(newVal) => {
nextTick(() => {
if (props.picturePreReading) {
waitImgComplate()
} else {
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
</script>
触发计算,定位item位置
libs/waterfall/index.vue
/**
* 为每个 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
)
}
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] +=
itemHeights[index] + props.rowSpacing
}
/**
* 在组件销毁时,清除所有的 _style
*/
onUnmounted(() => {
props.data.forEach((item) => {
delete item._style
})
})
// 触发计算
watch(
() => props.data,
(newVal) => {
// 重置数据源
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
// 构建高度记录容器
useColumnHeightObj()
}
nextTick(() => {
if (props.picturePreReading) {
waitImgComplate()
} else {
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
完整代码
libs/waterfall/index.vue
<template>
<!-- 因为当前为 relative 布局,所以需要主动指定高度 -->
<div
class="relative"
ref="containerTarget"
:style="{ height: containerHeight + 'px' }"
>
<!-- 数据渲染 -->
<!-- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 通过动态的 style 来去计算对应的列宽、left、top -->
<div class="m-waterfall-item absolute duration-300" v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index" :style="{width: columnWidth + 'px', left: item._style?.left + 'px', top: item._style?.top + 'px'}">
<slot :item="item" :width="columnWidth" :index="index" />
</div>
</template>
<!-- 加载中的提示 -->
<div v-else>加载中...</div>
</div>
</template>
<script setup>
import {
getImgElements,
getAllImg,
onComplateImgs,
getMinHeightColumn,
getMinHeight,
getMaxHeight
} from './utils'
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)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。
*/
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)
}
// 列宽 列宽=容器的宽度-所有列间距宽度 / 列数
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 高度集合
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()
})
}
/**
* 不需要图片预加载时,计算 item 高度
*/
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
/**
* 为每个 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
)
}
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] +=
itemHeights[index] + props.rowSpacing
}
/**
* 在组件销毁时,清除所有的 _style
*/
onUnmounted(() => {
props.data.forEach((item) => {
delete item._style
})
})
// 触发计算
watch(
() => props.data,
(newVal) => {
// 重置数据源
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
// 构建高度记录容器
useColumnHeightObj()
}
nextTick(() => {
if (props.picturePreReading) {
waitImgComplate()
} else {
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
</script>
<style scoped lang="scss"></style>
解决瀑布流展示不全问题
views/layout/index.vue
<template>
<div class="h-screen">
<!-- 需要在 tailwind 中定义 h-header、h-main 高度 -->
<headerVue class="h-header"></headerVue>
++ <div class="h-main">
<mainVue></mainVue>
</div>
<floatingVue></floatingVue>
</div>
</template>
适配移动端,动态列
views/home/components/list/index.vue
<m-waterfall class="px-1 w-full" :data="pexelsList" nodeKey="id" :column="isMobileTerminal ? 2 : 5" :picturePreReading="true">
import { isMobileTerminal } from "@/utils/flexible.js"
解决移动端下 navigationBar 不吸顶问题
App.vue
<template>
<!-- 一级路由出口 -->
<div class="h-screen w-screen fixed top-0 left-0">
<router-view />
</div>
</template>
我们希望瀑布流可以有一个响应式的切换,即瀑布流具备响应式的能力
libs/waterfall/index.vue
/**
* 监听列数变化,重新构建瀑布流
*/
const reset = () => {
setTimeout(() => {
// 重新计算列宽
useColumnWidth()
// 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触发 data 的 watch
props.data.forEach((item) => {
item._style = null
})
}, 100)
}
watch(
() => props.column,
() => {
if (props.picturePreReading) {
// 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染
columnWidth.value = 0
// 等待页面渲染之后,重新执行计算。否则在 item 没有指定过高度的前提下,计算出的 item 高度会不正确
nextTick(reset)
} else {
reset()
}
}
)
无需图片预加载时,优化功能处理
当我们将 :picturePreReading="false"
改为 false,后刷新页面会出现下面的情况
出现这样现象的原因是一旦我们不进行图片预加载时,对于我们的代码而言我们会直接去获取当前item的高度,那么此时,得到的item的高度,因为没有等待图片加载完成就渲染了,因此此时的高度不包含我们图片的高度,从而导致item整个的高度出现了错误,也就出现了上面的现象
如何解决?
其实在服务端返回数据的时候已经给我们返回了图片的高度和宽度信息的
解决的方式就是当 :picturePreReading="false"
改为 false时,我们就需要利用服务端给我们返回的高度来主动的为每个item指定这样的一个高度
views/home/components/list/index.vue
<template v-slot="{ item, width }">
<ItemVue :data="item" :width="width"></ItemVue>
</template>
views/home/components/list/item.vue
<!-- 图片 -->
<img class="w-full rounded bg-transparent" :src="data.photo" :style="{height: (width / data.photoWidth) * data.photoHeight + 'px'}" alt="" />
通过以上这样就可以完美解决该问题了
完整瀑布流组件代码
libs/waterfall/index.vue
<template>
<!-- 因为当前为 relative 布局,所以需要主动指定高度 -->
<div
class="relative"
ref="containerTarget"
:style="{ height: containerHeight + 'px' }"
>
<!-- 数据渲染 -->
<!-- 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染 -->
<template v-if="columnWidth && data.length">
<!-- 通过动态的 style 来去计算对应的列宽、left、top -->
<div class="m-waterfall-item absolute duration-300" v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index" :style="{width: columnWidth + 'px', left: item._style?.left + 'px', top: item._style?.top + 'px'}">
<slot :item="item" :width="columnWidth" :index="index" />
</div>
</template>
<!-- 加载中的提示 -->
<div v-else>加载中...</div>
</div>
</template>
<script setup>
import {
getImgElements,
getAllImg,
onComplateImgs,
getMinHeightColumn,
getMinHeight,
getMaxHeight
} from './utils'
/**
* 使用方式
* <m-waterfall class="px-1 w-full" :data="pexelsList" nodeKey="id" :column="isMobileTerminal ? 2 : 5" :picturePreReading="true">
<template v-slot="{ item, width }">
<ItemVue :data="item"></ItemVue>
</template>
</m-waterfall>
*/
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)
// 记录每列高度的容器。key:所在列 val:列高
const columnHeightObj = ref({})
/**
* 构建记录各列的高度的对象。
*/
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)
}
// 列宽 列宽=容器的宽度-所有列间距宽度 / 列数
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 高度集合
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()
})
}
/**
* 不需要图片预加载时,计算 item 高度
*/
const useItemHeight = () => {
itemHeights = []
// 拿到所有元素
let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
// 计算 item 高度
itemElements.forEach((el) => {
// 依据传入数据计算出的 img 高度
itemHeights.push(el.offsetHeight)
})
// 渲染位置
useItemLocation()
}
/**
* 为每个 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
)
}
/**
* 返回下一个 item 的 top
*/
const getItemTop = () => {
// 列高对象中的最小的高度
return getMinHeight(columnHeightObj.value)
}
/**
* 指定列高度自增
*/
const increasingHeight = (index) => {
// 最小高度所在的列
const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
// 该列高度自增
columnHeightObj.value[minHeightColumn] +=
itemHeights[index] + props.rowSpacing
}
/**
* 在组件销毁时,清除所有的 _style
*/
onUnmounted(() => {
props.data.forEach((item) => {
delete item._style
})
})
// 触发计算
watch(
() => props.data,
(newVal) => {
// 重置数据源
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
// 构建高度记录容器
useColumnHeightObj()
}
nextTick(() => {
if (props.picturePreReading) {
waitImgComplate()
} else {
useItemHeight()
}
})
},
{
immediate: true,
deep: true
}
)
/**
* 监听列数变化,重新构建瀑布流(解决最开始在移动端,点击切换到pc端再切换回移动端后容器宽度不一致问题)
*/
const reset = () => {
setTimeout(() => {
// 重新计算列宽
useColumnWidth()
// 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触发 data 的 watch
props.data.forEach((item) => {
item._style = null
})
}, 100)
}
watch(
() => props.column,
() => {
if (props.picturePreReading) {
// 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染
columnWidth.value = 0
// 等待页面渲染之后,重新执行计算。否则在 item 没有指定过高度的前提下,计算出的 item 高度会不正确
nextTick(reset)
} else {
reset()
}
}
)
</script>
<style scoped lang="scss"></style>
libs/waterfall/utils.js
的代码和上面的--封装公共工具类 里的一样,因此这里省略
瀑布流总结
瀑布流是一个比较复杂的通用组件,因为我们要尽量做到 普适,所以就需要考虑到各种场景下的处理方案,尽量可以满足日常开发的场景,所以这就在原本就复杂的前提下,让这个功能变得更加复杂了。
整体构建过程:
- 瀑布流的核心:通过 relative 和 absolute 定位的方式,来控制每个 item 的位置
- 影响瀑布流高度的主要元素,通常都是 img 标签
- 有些服务端会返回 关键 img 的高度,有些不会,所以我们需要分别处理:
-
- 当服务端 不返回 高度时:我们需要等待 img 加载完成之后,再来计算高度,然后通过得到的高度计算定位。否则会出现高度计算不准确导致定位计算不准确的问题。
- 当服务端 返回 高度时:开发者则必须利用此高度为 item 进行高度设定。一旦 item 具备指定高度,那么我们就不需要等待 img 加载的过程,这样效率更高,并且可以使业务逻辑变得更加简单。
- 当进行响应式切换时,通用需要区分对应的场景:
-
- 当服务端 不返回 高度时:我们需要 重新执行整个渲染的流程,虽然会耗费一些性能,但是这样可以最大可能的避免出现逻辑错误。让组件拥有更强的普适性。
- 当服务端 返回 高度时:我们通用需要重新计算 列宽 和 定位,但是因为 item 具备明确的高度,所以我们可以直接拿到具体的高度,而无需重复整个渲染流程,从而可以实现更多的交互逻辑。比如:位移动画、将来的图片懒加载站位等...
长列表 infinite 组件
构建分析
处理好瀑布流之后,接下来我们就需要来处理对应的长列表功能。
我们知道对于首页中的瀑布流而言,是需要进行长列表展示的,也就是说它是一个分页的数据
那么对于这种分页功能而言,我们又该如何进行实现呢?
想要搞明白这个问题,那么同样我们需要分成两个方面来看:
- 长列表实现的原理?
- 我们使用长列表时,希望如何进行使用?
长列表实现原理
所谓长列表分页加载,其实指的就是:当滚动到列表底部时,加载数据
那么我们想要实现咱们的长列表组件,围绕这的依然是这句话
那么想要实现该功能,我们需要做的核心的一点就是能够 监听到列表滚动到底部
那么想要监听到列表滚动到底部的话,我们可以利用 IntersectionObserver,该接口可以判断:目标元素与其祖先元素或顶级文档视口(viewport)的交叉状态是否可见
那么我们就可以利用这个特性,把一个元素 置于列表底部,当这个元素可见时则表示 列表滚动到了底部
那么原生的 IntersectionObserver 使用起来比较复杂,所以 vueuse 提供了 useIntersectionObserver 方法
我们使用长列表时,希望如何使用
这个的判断和瀑布流时的判断逻辑是一样的,通过这样的逻辑,可以让我们知道这个组件的 prop 应该如何构建
那么我们期望使用他时是这样的:
<m-infinite-list v-model="" :isFinished="" @onload=""></m-infinite-list>
v-model:当前是否处于加载状态
isFinished:数据是否全部加载完毕
onload:加载下一页数据的触发事件
构建 infinite-list 长列表组件
libs/infinite-list/index.vue
<template>
<div>
<!-- 内容 -->
<slot />
<!-- 加载状态 -->
<div ref="laodingTarget" class="h-6 py-4">
<!-- 加载更多图标 -->
<m-svg-icon
v-show="loading"
class-name="infinite-load-icon"
icon-class="infinite-load"
class="w-4 h-4 mx-auto animate-spin"
></m-svg-icon>
<!-- 没有更多数据了 -->
<p v-if="isFinished" class="text-center text-base text-zinc-400">
已经没有更多数据了!
</p>
</div>
</div>
</template>
<script setup>
import { useIntersectionObserver, useVModel } from '@vueuse/core'
const props = defineProps({
// 是否处于加载状态
modelValue: {
type: Boolean,
required: true
},
// 数据是否全部加载完成
isFinished: {
type: Boolean,
default: false
}
})
// 处理 loading 状态
const loading = useVModel(props)
// 滚动的元素
const laodingTarget = ref(null)
// 记录当前是否在底部(是否交叉)
const targetIsIntersecting = ref(false)
useIntersectionObserver(
laodingTarget,
([{ isIntersecting }], observerElement) => {
// 获取当前交叉状态 isIntersecting表示当前视图是否可见
targetIsIntersecting.value = isIntersecting
// 触发 load
emitLoad()
}
)
/**
* 触发 load
*/
const emitLoad = () => {
// 当加载更多的视图可见时,同时loading为false,同时 数据尚未全部加载完成----加载更多数据
if (targetIsIntersecting.value && !loading.value && !props.isFinished) {
// 修改加载数据标记
loading.value = true
// 触发加载更多行为
emits('onLoad')
}
}
/**
* 监听 loading 的变化,解决数据加载完成后,首屏未铺满的问题
*/
watch(loading, (val) => {
// 触发 load,延迟处理,等待 渲染和 useIntersectionObserver 的再次触发
setTimeout(() => {
emitLoad()
}, 100)
})
const emits = defineEmits(['onLoad', 'update:modelValue'])
</script>
<style scoped lang="scss"></style>
应用 infinite-list 结合 waterfall
views/home/components/list/index.vue
<template>
<div>
<!-- 长列表处理 -->
<m-infinite-list
v-model="isLoading"
:isFinished="isFinished"
@onLoad="getPexelsData"
>
<m-waterfall
class="px-1 w-full"
:data="pexelsList"
nodeKey="id"
:column="isMobileTerminal ? 2 : 5"
:picturePreReading="false"
>
<template v-slot="{ item, width }">
<ItemVue :data="item" :width="width"></ItemVue>
</template>
</m-waterfall>
</m-infinite-list>
</div>
</template>
<script setup>
import { getPexelsList } from '@/api/pexels'
import ItemVue from './item.vue'
import { isMobileTerminal } from '@/utils/flexible.js'
/**
* 构建数据请求
*/
let query = {
page: 1,
size: 20,
categoryId: '',
searchText: ''
}
// 数据是否在加载中
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
}
getPexelsData()
</script>
解决首次数据无法铺满全屏时,数据无法继续加载问题
之前我们是一次性加载20条数据,当然看不出问题,那么当我们改为一次加载5条数据时,就会出现该问题了
也就是当首次加载的数据无法铺满全屏时,无法进行继续加载,这是因为在infinite-list组件中,我们通过监听
这个区域是否可见,从而来判断是否需要进行下次的loading,但是当首次加载的数据无法铺满全屏时,isIntersecting
也就是是否可见的变量虽然为true,但是它只会被触发一次,也就是说,我们这里的回调
不会进行第二次触发,也就不会去触发第二次loading,也就会出现该现象
因此我们需要监听loading,只要它发生了变化,为true,时就去加载
libs/infinite-list/index.vue
// 记录当前是否在底部(是否交叉)
const targetIsIntersecting = ref(false)
useIntersectionObserver(
laodingTarget,
([{ isIntersecting }], observerElement) => {
// 获取当前交叉状态 isIntersecting表示当前视图是否可见
targetIsIntersecting.value = isIntersecting
// 触发 load
emitLoad()
}
)
/**
* 触发 load
*/
const emitLoad = () => {
// 当加载更多的视图可见时,同时loading为false,同时 数据尚未全部加载完成----加载更多数据
if (targetIsIntersecting.value && !loading.value && !props.isFinished) {
// 修改加载数据标记
loading.value = true
// 触发加载更多行为
emits('onLoad')
}
}
/**
* 监听 loading 的变化,解决数据加载完成后,首屏未铺满的问题
*/
watch(loading, (val) => {
// 触发 load,延迟处理,等待 渲染和 useIntersectionObserver 的再次触发
setTimeout(() => {
emitLoad()
}, 100)
})
图片懒加载
上面我们已经实现了瀑布流和上列表加载
接下来我们将来处理图片懒加载
懒加载构建原因+实现原理
如果此时将请求里的 size 改为 100,
我们可以看到这里的请求次数会大于我们的100
那么多余的这些请求是不是就会显得比较浪费
我们不想有这种浪费,那么就可以利用 图片懒加载 功能进行实现
图片懒加载原理:
当图片不可见时,不加载图片。盯那个图片可见时,才去加载图片。
如何实现?
我们可以 监听所有图片是否被可见,如果图片处于不可见状态,那么就不加载图片,如果图片处于可见状态,那么就开始加载图片。
而这个功能的实现关键就是 IntersectionObserver。
通用指令:实现图片懒加载
directives/modules/lazy.js
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()
}
})
}
}
directives/index.js
/**
* 全局指令注册
*/
// export default {
// async install(app) {
// // https://cn.vitejs.dev/guide/features.html#glob-import
// // import.meta.globEager 为同步导入 vite3起已废除,改为glob
// const directives = import.meta.glob('./modules/*.js')
// for (const [key, value] of Object.entries(directives)) {
// // 拼接组件注册的 name
// const arr = key.split('/')
// const directiveName = arr[arr.length - 1].replace('.js', '')
// // 完成注册
// app.directive(directiveName, value.default)
// }
// }
// }
import lazy from "./modules/lazy.js";
export default function directive(app) {
app.directive('lazy', lazy)
}
main.js
import directive from './directives'
directive(app);
然后我们需要给涉及到的地方加上懒加载指令--v-lazy
指定彩色占位图
utils/color.js
/**
* 生成随机色值
*/
export const randomRGB = () => {
const r = Math.floor(Math.random() * 255)
const g = Math.floor(Math.random() * 255)
const b = Math.floor(Math.random() * 255)
return `rgb(${r}, ${g}, ${b})`
}
views/home/components/list/item.vue
<div class="relative w-full rounded cursor-zoom-in group" :style="{backgroundColor: randomRGB()}">
<!-- 图片 -->
import { randomRGB } from "@/utils/color.js";