《中前台通用解决方案》课程里有若干细碎的知识点,其中瀑布流和无限加载算是页面布局中的典型,而且这两者也一般是同时使用的。
本博客主要记录了Vue3中瀑布流和无限加载组件的搭建过程。
瀑布流
瀑布流组件常见于C端产品,且多针对图片、视频等多媒体文件,例如淘宝、小红书、B站。
和传统的布局相比,瀑布流布局有这些优点
- 非对称,不是常规的“自上而下,从左到右”,更适用于长度不固定的页面内容
- 元素布局位置灵活,会动态根据之前元素所占空间动态调整后续元素的位置,样式更好看
组件基础设计
组件的搭建一般应从实际使用出发
即:先考虑“希望怎么使用组件”,再考虑如何设计参数
组件参数props设计
依据上面的设计思路,瀑布流组件的参数(基础)应该包括:
- 数据
- 列数
- 列间距(瀑布流元素间)
- 行间距(瀑布流元素间)
按照如上的参数,设计了如下的props
以及一些默认值
const props = defineProps({
// 数据
data: {
type: Array,
required: true,
},
// 列数
columns: {
type: Number,
default: 2,
},
// 列间距
columnSpace: {
type: Number,
default: 20,
},
// 行间距
rowSpace: {
type: Number,
default: 20,
},
});
组件模板
Waterfall
组件的模板很清晰,就是外层一个容器,内部一个循环生成每一项即可,如果没有数据,展示一个“无数据”
<template>
<div
ref="container"
class="relative"
:style="{ height: containerHeight + 'px' }"
>
<template v-if="data.length">
<div
v-for="(item, index) of data"
:key="index"
class="m-waterfall-item absolute"
:style="{
width: columnWidth + 'px',
top: item._style?.top + 'px',
left: item._style?.left + 'px',
}"
>
<slot :item="item" :index="index" :width="columnWidth.value" />
</div>
</template>
<div v-else>无数据</div>
</div>
</template>
组件渲染逻辑设计
整个组件渲染的逻辑包括瀑布流容器和内部每一项Item
组件内部Item
使用插槽渲染,每一个Item可以拿到列表的每一项数据,以及每一项的计算得出的宽度
瀑布流内每一项宽度 = (瀑布流总宽度 - 列间距 * (列数 - 1)) / 列数
插槽还用到了v-slot
传参的方式
<!-- waterfall 组件中 -->
<slot :item="item" :index="index" :width="itemWidth" />
<!-- 使用时 -->
<template v-slot="{ item, width }">
<Item :data="item" :width="width" />
</template>
每一项渲染位置
具体到每一项的渲染位置,需要根据瀑布流的列高计算
因为采用绝对定位放置元素,需要注意的包括如下:
-
需要记录瀑布流每一列的高度。每增加新的一项,就要看当前高度最低的瀑布流列是哪一列,把这一项放在这一列底部,并把当前列的高度增加
-
因为用了绝对定位,所以瀑布流的每一项都会脱离文本流,因此需要用
top
和left
两个CSS属性定位每一项的位置
考虑到以上,先封装了三个通用的方法,即:
- 获取最短列高度:
getMinHeight
- 获取最高列高度:
getMaxHeight
- 获取最短列的下标:
getMinHeightColumn
// 获取最短列高度
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);
};
// 返回列高对象中最小高度所在列
export const getMinHeightColumn = (columnHeightObj) => {
const minHeight = getMinHeight(columnHeightObj);
return Object.keys(columnHeightObj).find(
(key) => columnHeightObj[key] === minHeight
);
};
此外,每一项元素的top
和left
位置也需要两个方法获取,可以基于以上这三个方法再度封装
- 下一个元素的
top
值:getItemTop
- 下一个元素的
left
值:getItemLeft
// 下一个元素top:当前最小列高
const getItemTop = () => {
return getMinHeight(columnHeight.value)
}
// 下一个元素left:当前最小列高所在列的index,以及列间距计算得出
const getItemLeft = () => {
const column = getMinHeightColumn(columnHeight.value)
return (
column * (columnWidth.value + props.columnSpacing) + containerLeft.value
)
}
最后,基于以上这些方法,封装一个自动渲染Item位置的useItemLocation
const useItemLocation = () => {
props.data.forEach((item, index) => {
if (item._style) {
return
}
item._style = {}
item._style.left = getItemLeft()
item._style.top = getItemTop()
// 指定列高度自增
increasingHeight(index)
})
// 指定容器高度
containerHeight.value = getMaxHeight(columnHeight.value)
}
// 指定列高度自增
const increasingHeight = (index) => {
// 最小高度所在列
const minHeightColumn = getMinHeightColumn(columnHeight.value)
columnHeight.value[minHeightColumn] += itemHeights[index] + props.rowSpacing
}
瀑布流宽高
瀑布流的高度包括了每一列的高度,以及整个组件的高度
宽度主要是整个组件的宽度,这个是根据容器大小决定的,而每一项的宽度则可以通过容器宽度和列间距计算得到
列高度
瀑布流每一列在最开始时候都应该初始化一下,高度默认给0
// 记录每一列高度的容器
const columnHeight = ref({})
// 高度初始化
const useColumnHeightObj = () => {
// 根据列数,初始化每一列的高度为0
columnHeight.value = {}
for (let i = 0; i < props.column; i++) {
columnHeight.value[i] = 0
}
}
因为高度是要在DOM
渲染之后才知道的,而作为数据驱动视图的框架,每一列的高度就跟数据挂钩了,所以应该对数据做监听,在第一次获取数据的时候,初始化每一列高度的记录容器
watch(
() => props.data,
(newVal) => {
nextTick(() => {
// 第一次获取数据,构建高度记录容器
// 只有当数据的每一项都没有style样式的时候,才说明是第一次渲染
const resetColumnHeight = newVal.every((item) => !item._style)
if (resetColumnHeight) {
useColumnHeightObj()
}
useItemLocation()
})
},
{
deep: true,
immediate: true
}
)
容器高度
因为是绝对定位,所以容器高度必须自己定义,这里取的就是瀑布流所有列中最高的,而初始状态高度是0
// 容器总高度:初始为0
const containerHeight = ref(0)
容器宽度
容器的宽度可以用getComputedStyle
这个window
对象的方法获取,在组件渲染完成的时候执行一次(mounted
钩子)
// 容器总宽度(这里说的是内容总宽度)
const containerWidth = ref(0)
// 容器左边距
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(() => {
return props.columnSpacing * (props.column - 1)
})
// 计算列宽
const useColumnWidth = () => {
// 获取容器宽度
useContainerWidth()
// 列宽 = 容器宽度 - 列间距合计 / 列数
columnWidth.value =
(containerWidth.value - columnSpacingTotal.value) / props.column
}
onMounted(() => {
// 计算列宽
useColumnWidth()
})
组件优化
对于瀑布流Waterfall
组件,有几个地方需要考虑优化
- 用户并不能一次就能看到底,所以需要在用户即将看到的时候再去继续加载
- 瀑布流的每一项元素都包含了图片,同样,有些图片一开始并不在用户的可视范围内,应该考虑在用户即将看到的时候再去渲染
- 对于网络图片,可能从请求到拿到数据需要一个时间,这个过程如果让用户看到满屏的白色或者未渲染时候的裂痕图片,很影响体验
- 针对不同宽度的屏幕(PC、移动端),瀑布流可能有不同的列数,需要做适配
针对以上优化点,要考虑的优化方案包括
- 数据懒加载(使用无限加载
Infinite
组件) - 图片懒加载、预加载(用占位颜色替代)
- 移动端适配
图片懒加载
懒加载的核心逻辑:
当元素还没出现在视图区域内,不加载,出现了,再加载
所以懒加载关键就是监听元素是否在可视区域内,这里用到@vueuse/core
里的useIntersectionObserver
该方法包括两个参数
- 第一个参数:监听是否可见的
DOM
元素 - 第二个参数:回调函数,回调的参数里可以判断当前
DOM
元素是否可见- 回调函数返回一个
stop
方法,可以用来结束对该DOM
元素是否可见的监听
- 回调函数返回一个
自定义指令
考虑到瀑布流中的图片元素太多了,我们不可能一个个创建监听,所以考虑封装一个通用处理方案,这里可以考虑Vue的自定义指令,最后也是封装了一个v-lazy
指令
指令里面需要添加生命周期钩子,这里因为图片涉及DOM
渲染,所以用了mounted
import { useIntersectionObserver } from '@vueuse/core'
export default {
mounted(el) {
// 先拿到图片元素的src,一开始给个空值,等到能看到了再赋值渲染
const imgSrc = el.src
el.src = ''
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
if (isIntersecting) {
el.src = imgSrc
// 结束监听
stop()
}
})
}
}
Vue插件
为了在全局任意地方都可以直接调用v-lazy
指令,使用了Vue中的插件,从而可以通过Vue.use
把指令绑定到全局
插件需要一个有install
属性的对象
export default {
// app.use的方式使用
install: (app) => {
app.directive('lazy', lazy)
}
}
图片预加载
图片由接口提供,从请求发出到数据返回,再到最终渲染出来,需要一些时间。而在图片没有完全渲染出来之前,盲目直接使用图片地址,可能会导致白屏,或者展示img
默认的一个裂屏小图标,给用户的体验不是很好。
所以应该做的是,先知道图片的尺寸信息(宽高),预留好这个尺寸的空间,等待图片从服务器真正返回之后,再去把图片放到页面上。
整个预加载过程如下
按照如上流程,封装了三个方法
- 从每一项中抽提图片元素:
getImageElement
- 获取所有图片元素的链接:
getAllImg
- 监听所有图片链接加载完成:
onCompleteImgs
// 从 item 中抽离出所有图片元素
export const getImageElements = (itemElements) => {
const imgElements = []
itemElements.forEach((item) => {
imgElements.push(...item.getElementsByTagName('img'))
})
return imgElements
}
// 生成所有的图片链接
export const getAllImg = (imgElements) => {
return imgElements.map((item) => item.src)
}
// 监听图片数组加载完成
export const onCompleteImgs = (imgs) => {
const promiseAll = []
imgs.forEach((img, index) => {
promiseAll[index] = new Promise((resolve, reject) => {
// 处理 img 加载情况
const imageObj = new Image()
imageObj.src = img
imageObj.onload = () => {
resolve({ img, index })
}
})
})
return Promise.all(promiseAll)
}
Waterfall
组件中,只需要添加处理图片预加载的方法即可,等到图片预加载完成再去渲染每一项的位置
// 监听图片加载完成(需要图片预加载)
const waitImageComplete = () => {
itemHeights = [];
// 拿到所有元素
let itemElements = [...document.getElementsByClassName("m-waterfall-item")];
// 获取元素内的img标签
const imgElements = getImageElements(itemElements);
// 获取所有img标签的图片
const allImgs = getAllImg(imgElements);
// 等待图片加载完成
onCompleteImgs(allImgs).then(() => {
itemElements.forEach((item) => {
itemHeights.push(item.offsetHeight);
});
// 渲染位置
useItemLocation();
});
};
watch(
() => props.data,
(newVal) => {
nextTick(() => {
......
// 根据props决定是否需要预加载
if (props.picturePreLoading) {
waitImageComplete();
} else {
useItemHeight();
}
});
}
);
移动端适配
移动端的主要区别就是列数变化,因此核心就是监听column
属性的变化,并重新调整瀑布流每一项的布局
watch(
() => props.column,
() => {
if (props.picturePreReading) {
columnWidth.value = 0
reset()
} else {
reset()
}
}
)
当列宽变化的时候,重置所有项的style
const reset = () => {
setTimeout(() => {
// 重新计算列宽
useColumnWidth()
// 重置所有定位数据
props.data.forEach((item) => {
item._style = null
})
}, 200)
}
无限加载
无限加载Infinity
组件一般和瀑布流这种长列表组件组合使用,当用户滚动到列表底部,而数据还有多的时候,可以继续加载后续内容
组件参数props设计
无限加载组件主要是要判断三点
- 是否到达长列表的底部
- 是否还有更多数据
- 如果有更多数据,新数据是否加载完成可以继续渲染
关于到达底部,可以考虑在底部放一个元素,使用之前图片懒加载中的useIntersectionObserver
而是否还有更多数据,则需要一个单独变量控制,而且加载更多数据的回调方法要传入
const props = defineProps({
// 是否处于加载状态
modelValue: {
type: Boolean,
required: true
},
// 数据是否加载完成
isFinished: {
type: Boolean,
default: false
}
})
const emits = defineEmits(['onLoad', 'update:modelValue'])
// loading状态放在父组件变化,这里只做联动
const loading = useVModel(props)
组件模板
组件的模板比较简单,上面就是正常渲染的长列表内容,底部添加一个区域监听是否到达底部
如果到达,在数据没加载完成前展示一个loading
状态,加载完成后则继续展示长列表,没有更多数据也单独展示一个“没有更多数据”
<template>
<div class="">
<!-- 内容 -->
<slot />
<div ref="loadingTarget" 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>
组件渲染逻辑设计
无限加载主要考虑列表是否到底部,初始状态下应该是false,而一旦无限加载组件渲染出来就应该监听这个底部元素是否可见,从而继续渲染长列表
// 滚动元素
const loadingTarget = ref(null)
// 记录当前是否在底部
const targetIsIntersecting = ref(false)
const emitLoad = () => {
// 当加载更多的视图可见时,loading中,数据尚未全部加载完,触发加载更多的逻辑
if (targetIsIntersecting.value && !loading.value && !props.isFinished) {
// 修改加载数据的标记
loading.value = true
emits('onLoad')
}
}
useIntersectionObserver(loadingTarget, ([{ isIntersecting }]) => {
targetIsIntersecting.value = isIntersecting
emitLoad()
})
此外还有一种极端情况,就是第一次渲染得到的长列表数据,还并没有超出可视区域,这种情况就需要再次渲染
// 监听loading变化,解决数据加载完成后,首屏未铺满问题
watch(loading, () => {
// 以防数据少的时候多加载一次,延时判断是否需要加载新一次
setTimeout(emitLoad, 100)
})