可自定义设置以下属性:
图片数组(images),类型:Array<{name?: string, src: string}>,默认 []
要划分的列数(columnCount),类型:number,默认 3
各列之间的间隙(columnGap),类型:number,单位 px,默认 30
瀑布流区域的总宽度(width),类型:number | string,单位 px,默认 '100%'
瀑布流区域和图片圆角(borderRadius),类型:number,单位 px,默认 8
瀑布流区域背景填充色(backgroundColor),类型:string,默认 '#F2F4F8'
Spin 组件属性配置(spinProps),类型:object,默认 {},参考 Spin Props,用于配置图片加载中样式
效果如下图:在线预览
其中引用使用了以下组件和工具函数:
①创建瀑布流组件Waterfall.vue:
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Spin from '../spin'
import { useResizeObserver } from '../utils'
/*
宽度固定,图片等比例缩放;使用JS获取每张图片宽度和高度,结合 `relative` 和 `absolute` 定位
计算每个图片的位置 `top`,`left`,保证每张新的图片都追加在当前高度最小的那列末尾
*/
interface Image {
name?: string // 图片名称
src: string // 图片地址
}
interface Props {
images?: Image[] // 图片数组
columnCount?: number // 要划分的列数
columnGap?: number // 各列之间的间隙,单位 px
width?: string | number // 瀑布流区域的总宽度,单位 px
borderRadius?: number // 瀑布流区域和图片圆角,单位 px
backgroundColor?: string // 瀑布流区域背景填充色
spinProps?: object // Spin 组件属性配置,参考 Spin Props,用于配置图片加载中样式
}
const props = withDefaults(defineProps<Props>(), {
images: () => [],
columnCount: 3,
columnGap: 20,
width: '100%',
borderRadius: 8,
backgroundColor: '#F2F4F8',
spinProps: () => ({})
})
const waterfallRef = ref()
const waterfallWidth = ref<number>()
const loaded = ref(Array(props.images.length).fill(false)) // 图片是否加载完成
const imageWidth = ref<number>()
const imagesProperty = ref<{ width: number; height: number; top: number; left: number }[]>([])
const preColumnHeight = ref<number[]>(Array(props.columnCount).fill(0)) // 每列的高度
const totalWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px'
} else {
return props.width
}
})
const height = computed(() => {
return Math.max(...preColumnHeight.value) + props.columnGap
})
const len = computed(() => {
return props.images.length
})
const flag = ref(0)
watch(
() => [props.images, props.columnCount, props.columnGap, props.width],
() => {
waterfallWidth.value = waterfallRef.value.offsetWidth
preColumnHeight.value = Array(props.columnCount).fill(0)
flag.value++
preloadImages(flag.value)
},
{
deep: true, // 强制转成深层侦听器
flush: 'post' // 在侦听器回调中访问被 Vue 更新之后的 DOM
}
)
onMounted(() => {
waterfallWidth.value = waterfallRef.value.offsetWidth
preloadImages(flag.value)
})
function updateWatefall() {
const currentWidth = waterfallRef.value.offsetWidth
// 窗口宽度改变时重新计算瀑布流布局
if (props.images.length && currentWidth !== waterfallWidth.value) {
waterfallWidth.value = currentWidth
flag.value++
preloadImages(flag.value)
}
}
useResizeObserver(waterfallRef, updateWatefall)
async function preloadImages(symbol: number) {
// 计算图片宽高和位置(top,left)
// 计算每列的图片宽度
imageWidth.value = ((waterfallWidth.value as number) - (props.columnCount + 1) * props.columnGap) / props.columnCount
imagesProperty.value.splice(0)
for (let i = 0; i < len.value; i++) {
if (symbol === flag.value) {
await loadImage(props.images[i].src, i)
} else {
return false
}
}
}
function loadImage(url: string, n: number) {
return new Promise((resolve) => {
const image = new Image()
image.src = url
image.onload = function () {
// 图片加载完成时执行,此时可通过image.width和image.height获取到图片原始宽高
const height = image.height / (image.width / (imageWidth.value as number))
imagesProperty.value[n] = {
// 存储图片宽高和位置信息
width: imageWidth.value as number,
height: height,
...getPosition(n, height)
}
resolve('load')
}
})
}
function getPosition(i: number, height: number) {
// 获取图片位置信息(top,left)
if (i < props.columnCount) {
preColumnHeight.value[i] = props.columnGap + height
return {
top: props.columnGap,
left: ((imageWidth.value as number) + props.columnGap) * i + props.columnGap
}
} else {
const top = Math.min(...preColumnHeight.value)
let index = 0
for (let n = 0; n < props.columnCount; n++) {
if (preColumnHeight.value[n] === top) {
index = n
break
}
}
preColumnHeight.value[index] = top + props.columnGap + height
return {
top: top + props.columnGap,
left: ((imageWidth.value as number) + props.columnGap) * index + props.columnGap
}
}
}
function onLoaded(index: number) {
loaded.value[index] = true
}
function getImageName(image: Image) {
// 从图像地址src中获取图像名称
if (image) {
if (image.name) {
return image.name
} else {
const res = image.src.split('?')[0].split('/')
return res[res.length - 1]
}
}
}
</script>
<template>
<div
ref="waterfallRef"
class="m-waterfall"
:style="`--border-radius: ${borderRadius}px; background-color: ${backgroundColor}; width: ${totalWidth}; height: ${height}px;`"
>
<Spin
class="waterfall-image"
:style="`width: ${property.width}px; height: ${property.height}px; top: ${property && property.top}px; left: ${property && property.left}px;`"
:spinning="!loaded[index]"
size="small"
indicator="dynamic-circle"
v-bind="spinProps"
v-for="(property, index) in imagesProperty"
:key="index"
>
<img class="u-image" :src="images[index].src" :alt="getImageName(images[index])" @load="onLoaded(index)" />
</Spin>
</div>
</template>
<style lang="less" scoped>
.m-waterfall {
position: relative;
border-radius: var(--border-radius);
.waterfall-image {
position: absolute;
.u-image {
width: 100%;
height: 100%;
border-radius: var(--border-radius);
display: inline-block;
vertical-align: bottom;
}
}
}
</style>
②在要使用的页面引入:
<script setup lang="ts">
import Waterfall from './Waterfall.vue'
import { ref, onBeforeMount, reactive } from 'vue'
const images = ref<any[]>([])
const state = reactive({
columnCount: 3,
columnGap: 20,
backgroundColor: '#e1faeb',
borderRadius: 12
})
function loadImages() {
for (let i = 1; i <= 10; i++) {
images.value.push({
title: `image-${i}`,
link: '',
src: `https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.5/${i}.jpg`
})
}
}
onBeforeMount(() => {
// 组件已完成响应式状态设置,但未创建DOM节点
loadImages()
})
</script>
<template>
<div>
<h1>{
{ $route.name }} {
{ $route.meta.title }}</h1>
<h2 class="mt30 mb10">基本使用</h2>
<Waterfall :images="images" />
<h2 class="mt30 mb10">瀑布流配置器</h2>
<Row :gutter="24">
<Col :span="6">
<Flex vertical gap="middle">
columnCount:
<Slider :min="1" :max="6" v-model:value="state.columnCount" />
</Flex>
</Col>
<Col :span="6">
<Flex vertical gap="middle">
columnGap:
<Slider :min="10" :max="100" v-model:value="state.columnGap" />
</Flex>
</Col>
<Col :span="6">
<Flex vertical gap="middle">
borderRadius:
<Slider :min="0" :max="100" v-model:value="state.borderRadius" />
</Flex>
</Col>
<Col :span="6">
<Flex vertical>
backgroundColor:
<Input v-model:value="state.backgroundColor" placeholder="backgroundColor" />
</Flex>
</Col>
</Row>
<Waterfall
class="mt30"
:images="images"
:column-count="state.columnCount"
:column-gap="state.columnGap"
:background-color="state.backgroundColor"
:border-radius="state.borderRadius"
/>
</div>
</template>