前言
kits组件已经好久没有更新了,最近终于可以再对组件库下手了(愧疚啊!!!!)
这次选择了以前没有涉及过的瀑布流组件,完成效果如下图
支持用户自定义内容,可以用插槽去在图片上面或者下面添加需要的dom结构
分析环节
瀑布流的内容在排列的过程中并不是根据从左到右依次排列的,是在按照数据顺序的基础上,始终把最新的一块数据添加到高度最小的一列下面并且宽度是相同的,如下图 (草图勿喷...............)

其实在整个排列过程中最重要的在我看来是两件事
- 第一列的排列
- 计算其余排列的方式
第一列确定好后,就可以根据第一列的高度去添加后续内容
其实像这种无规律的排列, 可以想到的就是直接定位操作,对每一块进行一个绝对定位来达到一个瀑布流的目的
既然有想法了那接下来按照定位的方式来深入的分析并进行实践
定位模式
既然使用定位模式进行分析并开发,那么肯定要对每一块内容的位置进行设置,根据整个布局来看,需要进行设置的实际上的每一块的 top 与 left.

以上图为例, 假设每块的宽度为 100px, 间隔为 20px, 第一列的位置信息可以得到如下数据(top, left):
- 0, 0
- 0, 20px + 100px
- 0, 40px + 200px
- 0, 60px + 300px
总结得出: 对于第一列, 那它们的 top 为 0,left为 (每一块的宽度 + 左边的间隔) * 数组的下标
上代码
vue
<template>
<div ref="kWaterfall" class="k-waterfall">
<div
v-for="(item, i) in props.list"
:key="i"
ref="kWaterfallChild"
class="k-waterfall-child"
>
<div class="k-waterfall-content">
<img ref="imgBox" :src="item.src" alt="" />
</div>
</div>
</div>
</template>
js
const kWaterfall = ref<any>();
const kWaterfallChild = ref<any>();
onMounted(() => {
// 获取父盒子宽度
const { width } = kWaterfall.value.getBoundingClientRect();
// 设置子盒子宽度 (父盒子宽度 - (当前的列数 - 1) * 间隔) / 当前列数
colWidth.value = (width - (props.column - 1) * props.gap) / props.column;
// 初始化
init(colWidth.value);
});
const init = (colWidth) => {
for (let i = 0; i < props.list.length; i++) {
// 判断是否第一列
// props.column控制瀑布流组件要展示几列,这里代表的值为4
// props.gap控制瀑布流组件的上下左右
if (i < props.column) {
await setStyle(kWaterfallChild.value[i], {
position: `absolute`,
top: `0px`,
left: `${i * (colWidth + props.gap)}px`,
});
}
}
}
// 批量设置css
const setStyle = (elm: any, json: any) => {
if (Array.isArray(elm)) {
for (let i = 0; i < elm.length; i++) {
for (const item in json) {
elm[i].style[item] = json[item];
}
}
} else {
for (const i in json) {
elm.style[i] = json[i];
}
}
};
然后就得到了一个如下的效果

第一行的图片可以看到已经是按逻辑进行了排列,其余图片还是处于一个正常渲染的情况,没有进行top left的偏移
对非第一列的图片进行排列要知道的是:
- top值
- left值
top计算

根据草图举例: 6的top值为 1的高度 + 间隔; 9的top值为 2的高度 + 间隔 + 7的高度 + 间隔
因此在排列好第一行时就需要将它们的高度进行收集,在后续的排列中进行增加,根据此分析,可以修改代码如下
less
// 声明收集高度的数组
const hArr = ref<any>([]);
...上面代码
if (i < props.column) {
// 收集高度
hArr.value.push(kWaterfallChild.value[i].offsetHeight);
await setStyle(kWaterfallChild.value[i], {
position: `absolute`,
top: `0px`,
left: `${i * (colWidth + props.gap)}px`,
});
}
...下面代码
在收集好高度后,下一步需要做的是:
- 排列其他行的内容,需要找出最小的高度,将内容优先放到最小高度下
- 每排列一个新的内容后,对最新的最小高度进行更新,那么就需要知道当前变更高度的index
根据分析,可以构建一个函数进行计算
ini
const getMinHeight = () => {
// 计算最小高度
const minHeight = Math.min.apply(null, hArr.value);
// 计算最小高度所对应的下标
const minHeightIndex = hArr.value.indexOf(minHeight);
return {
minHeight,
minHeightIndex
};
};
left
对top进行计算后, left也需要进行计算
已知 minHeightIndex ,因此根据 minHeightIndex 可以得出 left = minHeightIndex * (列宽 + 间隔)
排布
在得到了 top 与 left 值后, 对遍历的方法进行更新
less
const init = (colWidth) => {
for (let i = 0; i < props.list.length; i++) {
// 判断是否第一列
if (i < props.column) {
hArr.value.push(kWaterfallChild.value[i].offsetHeight);
setStyle(kWaterfallChild.value[i], {
position: `absolute`,
top: `0px`,
left: `${i * (colWidth + props.gap)}px`,
});
} else {
// 取数组中最小值与最小值所对应的下标
const { minHeight, minHeightIndex } = getMinHeight();
// 设置当前子元素定位
setStyle(kWaterfallChild.value[i], {
position: `absolute`,
top: `${minHeight + props.gap}px`,
left: `${minHeightIndex * (colWidth + props.gap)}px`,
});
// 更新最小高度, 最新的最小高度为 旧的最小高度 + 最新内容的高度 + 间隔
hArr.value[minHeightIndex] =
hArr.value[minHeightIndex] + kWaterfallChild.value[i].offsetHeight + props.gap;
}
}
};
得到的效果如下

看起来好像已经没什么问题了,但是细看的话,背景的高度好像没有被撑开,于是.....如此这般......xxxxx...查找了一番问题,发现是因为瀑布流使用的定位,因此父盒子的高度并没有被撑开..........啊这....
最大高度
实际上这里缺失了一个重要的逻辑, 父盒子的高度实际上应该与瀑布流的 n 列高度中的最大高度相等,可如何获取最大高度? 可以同理最小高度的获取
ini
const getMinHeight = () => {
const minHeight = Math.min.apply(null, hArr.value);
const maxHeight = Math.max.apply(null, hArr.value);
const minHeightIndex = hArr.value.indexOf(minHeight);
return {
minHeight,
minHeightIndex,
maxHeight,
};
};
这样就得到了最大高度,然后再每次更新最小高度时,对父盒子的高度进行更新
javascript
// 更新父盒子高度(最大高度)
setStyle(kWaterfall.value, {
height: `${maxHeight}px`,
});
这样,就得到了一个完整的瀑布流

自定义内容
瀑布流是实现了,但是单纯的图片瀑布流是不太能直接在项目中进行使用的,项目中可能会在图片的上方或者下方进行一些数据的展示.因此,需要对瀑布流进行一些自定义内容的支撑
ruby
<div class="k-waterfall-content">
<slot name="top" :data="props.list" :index="i" :cur-data="item"></slot>
<img ref="imgBox" :src="item.src" alt="" />
<slot name="bottom" :data="props.list" :index="i" :cur-data="item"></slot>
</div>
在使用时
xml
<k-waterfall :list="list" :column="3">
<template #bottom="{ index }">
<div class="customDom">
<p>第{{ index + 1 }}张</p>
</div>
</template>
</k-waterfall>
得到如下的效果

预加载
本来满心欢喜的做完了,在一次硬刷新后得到了这样的玩意

又是一通问题查找与分析,这个无非就是图片加载时机与渲染时机的问题,于是就有是图片的预加载
ini
// 预加载
const initImg = () => {
copyList.value = props.list;
Promise.all(copyList.value.map((item, i) => imgPreload(item, i))).then(() => {
nextTick(() => {
init(colWidth.value);
});
});
};
const imgPreload = (item, index) => {
return new Promise((res) => {
const image = new Image();
image.src = item.src;
image.onload = (e: any) => {
console.log(e.type);
if (e.type === 'load') {
copyList.value[index].height = colWidth.value / (image.width / image.height);
}
res(true);
};
image.onerror = (e: any) => {
if (e.type === 'error') {
copyList.value[index].occupying = true;
}
res(true);
};
});
};
有人会问 image.onerror 的处理是什么玩意... 这个是对加载图片失败时做的一个处理,给数据中插入一个状态,当这个状态为true时,则对加载失败的图片区域以占位块的形式展示
因为初始化放在是预加载后面,因此对块内容的宽度设置需要放到初始化的方法里面
javascript
// 初始化宽高与位置信息
const init = (colWidth) => {
for (let i = 0; i < copyList.value.length; i++) {
// 设置盒子宽度
setStyle(kWaterfallChild.value[i], {
width: `${colWidth}px`,
});
// 判断是否第一列
if (i < props.column) {
...
} else {
...
}
}
};
最后会得到这样的效果

问题
虽然最终实现的效果,但是在硬刷新状态下还是会有问题

在全部图片加载完后才会变为瀑布流
因此为了处理该问题,添加了一个 isShow变量,在图片全加载完成后才会更改isShow的状态去显示内容
vue
<div
v-for="(item, i) in copyList"
:key="i"
ref="kWaterfallChild"
class="k-waterfall-child"
:style="{ opacity: isShow ? 1 : 0 }"
>
<div class="k-waterfall-content">
<slot name="top" :data="copyList" :index="i" :cur-data="item"></slot>
<div v-if="item.occupying" class="occupying"></div>
<img ref="imgBox" :src="item.src" alt="" />
<slot name="bottom" :data="copyList" :index="i" :cur-data="item"></slot>
</div>
</div>
js
nextTick(() => {
init(colWidth.value);
isShow.value = true;
});
归根结底,这个问题的出现是因为布局的变更,因为采用了定位的模式进行排布,因此会导致各种问题产生,那有没有其他方式呢?答案是肯定的
分列模式
分列模式是指根据传入的列值,将整个瀑布流分为几列,分别来进行内容添加. 之前的定位模式是左右子元素都在父元素下,当使用分列模式时,所有子元素根据计算去分布到父元素下的n列元素里,如下图所示

那么就需要在一个二维数组存放每列的内容 [[],[],[],[]],并且对结构以及部分逻辑进行修改,直接上全部代码
vue
<template>
<div ref="kWaterfall" class="k-waterfall">
<div
v-for="(itemArr, index) in dataArrList"
ref="kWaterfallList"
:key="index"
class="k-waterfall-list"
>
<TransitionGroup name="k-waterfall-scale">
<div v-for="(item, i) in itemArr" :key="i" ref="kWaterfallChild" class="k-waterfall-child">
<div class="k-waterfall-content">
<slot name="top" :data="copyList" :index="i" :cur-data="item"></slot>
<div v-if="item.occupying" class="occupying"></div>
<img v-else :src="item.src" alt="" />
<slot name="bottom" :data="copyList" :index="i" :cur-data="item"></slot>
</div>
</div>
</TransitionGroup>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { PropType, ref, nextTick } from 'vue';
import { setStyle } from '../utils/index';
const props = defineProps({
list: {
type: Array as PropType<string[]>,
default: () => [],
},
column: {
type: Number,
default: 3,
},
gap: {
type: Number,
default: 3,
},
});
const kWaterfall = ref<any>();
const kWaterfallChild = ref<any>();
const kWaterfallList = ref<any>();
const copyList = ref<any>([]);
const colWidth = ref<number>(0);
const dataArrList = ref<any>([]);
onMounted(async () => {
// 获取父盒子宽度
const { width } = kWaterfall.value.getBoundingClientRect();
// 设置子盒子宽度
colWidth.value = (width - (props.column - 1) * props.gap) / props.column;
// 预加载
initImg();
});
// 初始化宽高与位置信息
const init = (colWidth) => {
// 分割数组
for (let i = 0; i < props.column; i++) {
dataArrList.value.push([]);
}
for (let i = 0; i < copyList.value.length; i++) {
// 判断是否第一列
if (i < props.column) {
setTimeout(() => {
dataArrList.value[i].push(props.list[i]);
setStyle(kWaterfallList.value[i], {
width: `${colWidth}px`,
gap: `${props.gap}px`,
});
}, 0);
} else {
// 取数组中最小值与最小值所对应的下标
setTimeout(() => {
const { minHeight, minHeightIndex } = getMinHeight();
console.log(minHeight, minHeightIndex);
dataArrList.value[minHeightIndex].push(props.list[i]);
}, 0);
}
}
};
// 预加载
const initImg = () => {
copyList.value = props.list;
Promise.all(copyList.value.map((item, i) => imgPreload(item, i))).then(() => {
nextTick(() => {
init(colWidth.value);
});
});
};
const imgPreload = (item, index) => {
return new Promise((res) => {
const image = new Image();
image.src = item.src;
image.onload = (e: any) => {
console.log(e.type);
if (e.type === 'load') {
copyList.value[index].height = colWidth.value / (image.width / image.height);
}
res(true);
};
// 加载失败设置occupying为true开启占位块显示
image.onerror = (e: any) => {
if (e.type === 'error') {
copyList.value[index].occupying = true;
}
res(true);
};
});
};
const getMinHeight = () => {
const heightArr = kWaterfallList.value.map((item: any) => {
return item.offsetHeight;
});
const minHeight = Math.min.apply(null, heightArr);
const minHeightIndex = heightArr.indexOf(minHeight);
return {
minHeight,
minHeightIndex,
};
};
</script>
最后在加上一些动画效果就完成了,当然这里并没有滚动加载的内容,后续再进行更新.............................................