持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情
Vue3实现瀑布流组件
- 瀑布流的核心就是:通过
relative和absolute定位的方式,来控制每个item的位置 - 影响瀑布流高度的主要元素,通常都是
img标签 - 有些服务端会返回 关键
img的高度,有些不会,所以我们需要分别处理:- 当服务端 不返回 高度时:我们需要等待
img加载完成之后,再来计算高度,然后通过得到的高度计算定位。否则则会出现高度计算不准确导致定位计算不准确的问题。 - 当服务端 返回 高度时:开发者则必须利用此高度为
item进行高度设定。一旦item具备指定高度,那么我们就不需要等待img加载的过程,这样效率更高,并且可以业务的逻辑会变得更加简单。
- 当服务端 不返回 高度时:我们需要等待
- 当进行响应式切换时,同样需要区分对应场景:
- 当服务端 不返回 高度时:我们需要 重新执行整个渲染流程 ,虽然会耗费一些性能,但是这样可以最大可能的避免出现逻辑错误。让组件拥有更强的普适性。
- 当服务端 返回 高度时:我们同样需要重新计算 列宽 和 定位 ,但是因为
item具备明确的高度,所以我们可以直接拿到具体的高度,而无需重复整个渲染流程,从而可以实现更多的交互逻辑。比如:位移动画、将来的图片懒加载占位…
<template>
<div
class="relative"
ref="containerRef"
:style="{ height: containerHeight + 'px' }">
<!-- 数据渲染 -->
<template v-if="columnWidth && data.length">
<div
v-for="(item, index) in data"
:key="nodeKey ? item[nodeKey] : index"
class="m-waterfall-item absolute duration-300"
: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 lang="ts">
import {
defineProps,
ref,
computed,
onMounted,
watch,
nextTick,
onUnmounted
} from "vue";
import {
getImgElements,
getAllImg,
onImgComplete,
getMinHeightColumn,
getMinHeight,
getMaxHeight
} from "./utils";
const {
data,
nodeKey,
column = 2,
columnSpacing = 20,
rowSpacing = 20,
picturePreReading = true
} = defineProps<{
// 数据源
data: any[];
// 唯一标识
nodeKey?: string;
// 列数
column?: number;
// 列间距
columnSpacing?: number;
// 行间距
rowSpacing?: number;
// 是否需要图片预加载
picturePreReading?: boolean;
}>();
// 容器 ref
const containerRef = ref<HTMLDivElement>();
// 容器总高度
const containerHeight = ref(0);
// 记录每列容器的高度
const columnHeightObj = ref<{ [key: number]: number }>({});
const useColumnHeightObj = () => {
columnHeightObj.value = {};
for (let i = 0; i < column; i++) {
columnHeightObj.value[i] = 0;
}
};
// 容器总宽度 不包含 padding margin border
const containerWidth = ref(0);
// 容器左边距 用来计算item的left
const containerLeft = ref(0);
const useContainerWidth = () => {
const { paddingLeft, paddingRight } = getComputedStyle(
containerRef.value!,
null
);
// 容器左边距
containerLeft.value = parseFloat(paddingLeft);
// 容器的宽度
containerWidth.value =
containerRef.value!.offsetWidth -
parseFloat(paddingLeft) -
parseFloat(paddingRight);
};
// 列宽
const columnWidth = ref(0);
// 列间距的合计
const columnSpacingTotal = computed(() => columnSpacing * (column - 1));
// 计算列宽
const useColumnWidth = () => {
// 计算容器宽度
useContainerWidth();
// 计算列宽
columnWidth.value =
(containerWidth.value - columnSpacingTotal.value) / column;
};
// 挂载后计算列宽
onMounted(useColumnWidth);
// item高度集合
let itemHeights: any[];
/**
* 监听图片加载完成的方法 需要预加载
*/
const waitImgComplete = () => {
itemHeights = [];
// 拿到所有元素
const itemElements: HTMLDivElement[] = Array.from(
document.getElementsByClassName("m-waterfall-item")
) as HTMLDivElement[];
// 获取到元素的img标签
const imgElements = getImgElements(itemElements);
// 获取 所有的 img标签的图片
const allImg = getAllImg(imgElements);
// 等待图片加载完成
onImgComplete(allImg).then(() => {
// 图片加载完成 可以获取高度
itemElements.forEach(el => itemHeights.push(el.offsetHeight));
// 渲染图片位置
useItemLocation();
});
};
/**
* 不需要图片预加载
*/
const useItemHeight = () => {
itemHeights = [];
// 拿到所有元素
const itemElements: HTMLDivElement[] = Array.from(
document.getElementsByClassName("m-waterfall-item")
) as HTMLDivElement[];
// 计算高度
itemElements.forEach(el => itemHeights.push(el.offsetHeight));
// 渲染图片位置
useItemLocation();
};
const useItemLocation = () => {
// 处理数据源
data.forEach((item, index) => {
// 避免重复计算
if (item._style) return;
item._style = {};
item._style.left = getItemLeft();
item._style.top = getItemTop();
// 指定的列 进行高度的自增
incrementingHeight(index);
});
// 指定容器的高度 最大高度
const maxHeight = getMaxHeight(columnHeightObj.value);
containerHeight.value = maxHeight;
};
/**
* 返回下一个item的left
*/
const getItemLeft = () => {
// 拿到第几列
const col = getMinHeightColumn(columnHeightObj.value);
return col * (columnWidth.value + columnSpacing) + containerLeft.value;
};
/**
* 获取下一个item的top
*/
const getItemTop = () => {
// 拿到当前项排列位置的列的上一张图片的高度
return getMinHeight(columnHeightObj.value);
};
/**
* 指定列的高度递增
*/
const incrementingHeight = (index: number) => {
// 最小高度所在列
const minCol = getMinHeightColumn(columnHeightObj.value);
// 递增 当前列高度 + 当前图片高度 + 行间距
columnHeightObj.value[minCol] += itemHeights[index] + rowSpacing;
};
/**
* 触发计算 当数据发生改变时 重新计算位置
*/
watch(
() => data,
newData => {
nextTick(() => {
// /第一次获取数据时 构建高度记录容器
const resetColumnHeight = newData.every(item => !item._style);
if (resetColumnHeight) {
// 重新构建
useColumnHeightObj();
}
if (picturePreReading) waitImgComplete();
else useItemHeight();
});
},
{
deep: true,
immediate: true
}
);
/**
* 屏幕尺寸发生改变后重新计算
*/
const reset = () => {
setTimeout(() => {
useColumnWidth();
// 重置所有定位数据
data.forEach(item => delete item._style);
}, 0);
};
/**
* 监听列数的改变
*/
watch(
() => column,
() => {
if (picturePreReading) {
// 重新计算列宽 先重置
columnWidth.value = 0;
} else {
// 不需要预加载 重新计算图片位置即可
// reset();
}
reset();
}
);
onUnmounted(() => {
// 清除所有item项上的 _style
data.forEach(item => delete item._style);
});
</script>
<style scoped></style>