引言:瀑布流布局的魅力与应用场景
在当今富媒体内容主导的网络环境中,瀑布流布局已成为展示图片商品等内容的流行方式。它通过动态布局算法在有限空间内最大化内容展示,提供视觉连续性和流畅浏览体验。本文将深入探讨如何使用Vue 3实现一个功能完备的瀑布流组件,并解决图片懒加载等关键问题。
瀑布流组件核心实现
1. 组件设计思路
我们的瀑布流组件解决了以下关键问题:
- 响应式布局:根据容器宽度自动调整列数
- 高效图片加载:支持懒加载和预加载模式
- 视觉优化:平滑的位置过渡动画
- 错误处理:优雅的加载失败处理机制
- 高定制性:通过插槽支持自定义内容
2. 核心算法实现
列数与列宽计算
const calculateColumns = () => {
wrapperWidth.value = waterfallWrapper.value.clientWidth;
// 响应式断点处理
const sortedBreakpoints = Object.keys(props.breakpoints)
.map(Number)
.sort((a, b) => b - a);
// 根据断点确定列数
for (const breakpoint of sortedBreakpoints) {
if (wrapperWidth.value >= breakpoint) {
cols.value = props.breakpoints[breakpoint].rowPerView;
break;
}
}
// 计算列宽(考虑间距和对齐方式)
if (props.hasAroundGutter) {
colWidth.value = (wrapperWidth.value - props.gutter * 2 -
(cols.value - 1) * props.gutter) / cols.value;
offsetX.value = props.gutter;
} else {
colWidth.value = (wrapperWidth.value -
(cols.value - 1) * props.gutter) / cols.value;
offsetX.value = 0;
}
// 处理对齐方式
if (props.align === 'center') {
const totalWidth = cols.value * colWidth.value +
(cols.value - 1) * props.gutter;
offsetX.value = (wrapperWidth.value - totalWidth) / 2;
} else if (props.align === 'right') {
const totalWidth = cols.value * colWidth.value +
(cols.value - 1) * props.gutter;
offsetX.value = wrapperWidth.value - totalWidth;
}
};
瀑布流布局算法
const calculateLayout = () => {
const columnHeights = new Array(cols.value).fill(0);
let maxHeight = 0;
items.forEach((item, index) => {
// 寻找高度最小的列
let minColHeight = columnHeights[0];
let colIndex = 0;
for (let i = 1; i < cols.value; i++) {
if (columnHeights[i] < minColHeight) {
minColHeight = columnHeights[i];
colIndex = i;
}
}
// 计算元素位置
const x = colIndex * (colWidth.value + props.gutter) + offsetX.value;
const y = columnHeights[colIndex];
// 应用位置变换
item.style.transform = `translate3d(${x}px, ${y}px, 0)`;
item.style.width = `${colWidth.value}px`;
// 更新列高度
const itemHeight = item.offsetHeight || 200;
columnHeights[colIndex] += itemHeight + props.gutter;
// 更新容器高度
if (columnHeights[colIndex] > maxHeight) {
maxHeight = columnHeights[colIndex];
}
});
wrapperHeight.value = maxHeight;
};
3. 高级功能实现
智能图片懒加载
const initLazyLoad = () => {
observer.value = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// 设置loading占位图
if (props.loadingImg) img.src = props.loadingImg;
// 延迟加载实际图片
setTimeout(() => {
loadImage(img);
// 图片加载完成触发布局更新
img.onload = () => debouncedLayout();
// 错误处理
if (props.errorImg) {
img.onerror = () => {
img.src = props.errorImg;
debouncedLayout(); // 错误时也更新布局
};
}
img.removeAttribute('data-src');
}, props.delay);
observer.value.unobserve(img);
}
});
}, { threshold: 0.01 });
// 观察所有懒加载图片
const lazyImages = waterfallWrapper.value.querySelectorAll('img[data-src]');
lazyImages.forEach(img => observer.value.observe(img));
};
防抖优化性能
const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
};
// 使用防抖优化布局计算
const debouncedLayout = debounce(() => {
calculateColumns();
calculateLayout();
}, props.posDuration);
性能优化策略
GPU加速动画
.waterfall-item { transition: transform 0.3s ease; will-change: transform; }
智能加载策略
- 优先加载视口内图片
- 设置加载延迟避免卡顿
- 使用占位图保持布局稳定
高效的事件处理
onMounted(() => { window.addEventListener('resize', debouncedLayout); }); onUnmounted(() => { window.removeEventListener('resize', debouncedLayout); if (observer.value) observer.value.disconnect(); });
响应式断点设计
breakpoints: { 1200: { rowPerView: 4 }, 800: { rowPerView: 3 }, 500: { rowPerView: 2 } }
常见问题解决方案
图片加载导致布局抖动问题
- 使用固定比例的占位图容器
- 预先设置图片尺寸属性
- 添加加载过渡动画
白屏问题处理
// 确保初始渲染可见 nextTick(() => { calculateColumns(); calculateLayout(); });
大量数据性能优化
- 虚拟滚动技术
- 分页加载
- 回收不可见DOM节点
总结
通过本文,我们实现了一个高性能可定制的Vue 3瀑布流组件,它具有以下特点:
- 智能化布局:自动计算最佳列数和位置
- 多种加载模式:支持懒加载和预加载
- 响应式设计:完美适配不同屏幕尺寸
- 优雅的错误处理:提供自定义占位图
- 平滑动画:GPU加速的位置过渡效果
插件完整代码:
<template>
<div ref="waterfallWrapper" class="waterfall-list" :style="{ height: `${wrapperHeight}px` }">
<div v-for="(item, index) in list" :key="getKey(item, index)" class="waterfall-item">
<slot name="item" :item="item" :index="index" :url="getRenderURL(item)" />
</div>
</div>
</template>
<script>
import { ref, watch, onMounted, onUnmounted, provide, nextTick } from "vue";
export default {
name: 'WaterfallList',
props: {
list: {
type: Array,
required: true,
default: () => []
},
rowKey: {
type: String,
default: "id"
},
imgSelector: {
type: String,
default: "src"
},
width: {
type: Number,
default: 200
},
breakpoints: {
type: Object,
default: () => ({
1200: { rowPerView: 4 },
800: { rowPerView: 3 },
500: { rowPerView: 2 }
})
},
gutter: {
type: Number,
default: 10
},
hasAroundGutter: {
type: Boolean,
default: true
},
posDuration: {
type: Number,
default: 300
},
align: {
type: String,
default: "center",
validator: (value) => ['left', 'center', 'right'].includes(value)
},
lazyLoad: {
type: Boolean,
default: true
},
crossOrigin: {
type: Boolean,
default: true
},
delay: {
type: Number,
default: 300
},
loadingImg: {
type: String,
default: ''
},
errorImg: {
type: String,
default: ''
}
},
setup(props, { emit }) {
const waterfallWrapper = ref(null);
const wrapperWidth = ref(0);
const colWidth = ref(0);
const cols = ref(0);
const offsetX = ref(0);
const wrapperHeight = ref(0);
const observer = ref(null);
// 计算列数和列宽
const calculateColumns = () => {
if (!waterfallWrapper.value) return;
wrapperWidth.value = waterfallWrapper.value.clientWidth;
// 根据断点确定列数
const sortedBreakpoints = Object.keys(props.breakpoints)
.map(Number)
.sort((a, b) => b - a); // 从大到小排序
let foundCols = 1;
for (const breakpoint of sortedBreakpoints) {
if (wrapperWidth.value >= breakpoint) {
foundCols = props.breakpoints[breakpoint].rowPerView;
break;
}
}
cols.value = foundCols;
// 计算列宽
if (props.hasAroundGutter) {
colWidth.value = (wrapperWidth.value - props.gutter * 2 - (cols.value - 1) * props.gutter) / cols.value;
offsetX.value = props.gutter;
} else {
colWidth.value = (wrapperWidth.value - (cols.value - 1) * props.gutter) / cols.value;
offsetX.value = 0;
}
// 处理对齐方式
if (props.align === 'center') {
const totalWidth = cols.value * colWidth.value + (cols.value - 1) * props.gutter;
offsetX.value = (wrapperWidth.value - totalWidth) / 2;
} else if (props.align === 'right') {
const totalWidth = cols.value * colWidth.value + (cols.value - 1) * props.gutter;
offsetX.value = wrapperWidth.value - totalWidth;
}
};
// 加载图片
const loadImage = (img) => {
const url = img.dataset?.src || img.getAttribute('data-src');
if (url) {
// 创建临时Image对象预加载
const tempImage = new Image();
tempImage.onload = () => {
img.src = url;
if (props.crossOrigin) img.crossOrigin = 'anonymous';
img.removeAttribute('data-src');
debouncedLayout(); // 关键:加载完成后触发布局更新
};
tempImage.onerror = () => {
if (props.errorImg) img.src = props.errorImg;
img.removeAttribute('data-src');
debouncedLayout(); // 关键:加载失败时也触发布局更新
};
tempImage.src = url;
return true;
}
return false;
};
// 加载所有图片(修改后)
const loadAllImages = () => {
if (!waterfallWrapper.value) return;
const images = waterfallWrapper.value.querySelectorAll('img[data-src]');
images.forEach(img => {
// 设置loading占位图
if (props.loadingImg) img.src = props.loadingImg;
// 加载实际图片并监听加载完成事件
const loaded = loadImage(img);
// 错误处理
if (loaded && props.errorImg) {
img.onerror = () => {
img.src = props.errorImg;
debouncedLayout(); // 关键:错误时也触发布局更新
};
}
});
};
// const loadAllImages = () => {
// if (!waterfallWrapper.value) return;
// const images = waterfallWrapper.value.querySelectorAll('img');
// images.forEach(img => {
// // 如果已经是加载状态则跳过
// if (img.src && !img.src.includes(props.loadingImg)) return;
// // 尝试加载图片
// const loaded = loadImage(img);
// // 设置错误处理
// if (loaded && props.errorImg) {
// img.onerror = () => {
// img.src = props.errorImg;
// };
// }
// });
// };
// 初始化懒加载
const initLazyLoad = () => {
if (!waterfallWrapper.value) return;
// 清理旧的观察器
if (observer.value) {
observer.value.disconnect();
}
// 创建新的观察器
observer.value = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset?.src || img.getAttribute('data-src')) {
// 设置loading占位图
if (props.loadingImg) {
img.src = props.loadingImg;
}
// 延迟加载实际图片
setTimeout(() => {
loadImage(img);
img.onload = () => debouncedLayout();
if (props.errorImg) {
img.onerror = () => {
img.src = props.errorImg;
};
}
// 移除data-src属性
img.removeAttribute('data-src');
}, props.delay);
}
observer.value.unobserve(img);
}
});
}, { threshold: 0.01 });
// 观察所有懒加载图片
const lazyImages = waterfallWrapper.value.querySelectorAll('img[data-src]');
lazyImages.forEach(img => {
observer.value.observe(img);
});
};
// 计算布局
const calculateLayout = () => {
if (!waterfallWrapper.value || cols.value === 0) return;
const items = waterfallWrapper.value.querySelectorAll('.waterfall-item');
if (items.length === 0) return;
const columnHeights = new Array(cols.value).fill(0);
let maxHeight = 0;
items.forEach((item, index) => {
let minColHeight = columnHeights[0];
let colIndex = 0;
for (let i = 1; i < cols.value; i++) {
if (columnHeights[i] < minColHeight) {
minColHeight = columnHeights[i];
colIndex = i;
}
}
const x = colIndex * (colWidth.value + props.gutter) + offsetX.value;
const y = columnHeights[colIndex];
item.style.transform = `translate3d(${x}px, ${y}px, 0)`;
item.style.width = `${colWidth.value}px`;
item.style.position = 'absolute';
item.style.left = '0';
item.style.top = '0';
item.style.visibility = 'visible';
// 计算项目高度(包含所有图片)
const itemHeight = item.offsetHeight || 200;
columnHeights[colIndex] += itemHeight + props.gutter;
// 更新最大高度
if (columnHeights[colIndex] > maxHeight) {
maxHeight = columnHeights[colIndex];
}
});
wrapperHeight.value = maxHeight;
emit('afterRender');
};
// 防抖函数
const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
};
const debouncedLayout = debounce(() => {
calculateColumns();
calculateLayout();
}, props.posDuration);
// 初始化
onMounted(() => {
if (!waterfallWrapper.value) return;
calculateColumns();
nextTick(() => {
if (props.lazyLoad) {
initLazyLoad();
} else {
loadAllImages(); // 非懒加载模式直接加载图片
}
calculateLayout(); // 初始布局
});
window.addEventListener('resize', debouncedLayout);
});
// 清理
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
window.removeEventListener('resize', debouncedLayout);
});
// 监听数据变化(修改部分)
watch(() => props.list, () => {
debouncedLayout();
nextTick(() => {
if (props.lazyLoad) {
initLazyLoad();
} else {
// 延迟加载确保DOM更新完成
setTimeout(loadAllImages, 0);
}
});
}, { deep: true });
// 提供刷新方法
provide('refreshWaterfall', debouncedLayout);
const getRenderURL = (item) => {
return item[props.imgSelector];
};
const getKey = (item, index) => {
return item[props.rowKey] || index;
};
return {
waterfallWrapper,
wrapperHeight,
getRenderURL,
getKey
};
}
};
</script>
<style scoped>
.waterfall-list {
position: relative;
width: 100%;
margin: 0 auto;
}
.waterfall-item {
position: absolute;
visibility: hidden;
transition: transform 0.3s ease;
will-change: transform;
box-sizing: border-box;
}
</style>
调用示例:
<template>
<div class="container">
<h1>图片瀑布流懒加载示例</h1>
<!-- 瀑布流组件 -->
<waterfall-list :list="imageList" :lazy-load="false" :cross-origin="true" :delay="300"
loading-img="https://via.placeholder.com/300x200?text=Loading..."
error-img="https://via.placeholder.com/300x200?text=Error" :width="300" :gutter="15" @afterRender="onAfterRender">
<template #item="{ item, url }">
<div class="image-card">
<!-- 使用data-src实现懒加载 -->
<img :data-src="url" :alt="item.title" class="image" @load="onImageLoad" />
<div class="info">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</div>
</template>
</waterfall-list>
<!-- 加载更多按钮 -->
<button class="load-more" @click="loadMoreImages" :disabled="isLoading">
{{ isLoading ? '加载中...' : '加载更多' }}
</button>
</div>
</template>
<script>
import { ref } from 'vue';
import WaterfallList from '../components/vWaterfall.vue'; // 根据实际路径调整
export default {
components: {
WaterfallList
},
setup() {
// 模拟图片数据
const generateImages = (count, startIndex = 0) => {
return Array.from({ length: count }, (_, i) => ({
id: startIndex + i,
title: `图片 ${startIndex + i + 1}`,
description: `这是第 ${startIndex + i + 1} 张图片的描述`,
src: `https://picsum.photos/id/${startIndex + i + 10}/300/200`
}));
};
const imageList = ref(generateImages(12));
const isLoading = ref(false);
// 图片加载完成回调
const onImageLoad = (e) => {
console.log('图片加载完成', e.target);
};
// 瀑布流渲染完成回调
const onAfterRender = () => {
console.log('瀑布流布局完成');
};
// 加载更多图片
const loadMoreImages = () => {
isLoading.value = true;
setTimeout(() => {
const newImages = generateImages(6, imageList.value.length);
imageList.value = [...imageList.value, ...newImages];
isLoading.value = false;
}, 1000);
};
return {
imageList,
isLoading,
onImageLoad,
onAfterRender,
loadMoreImages
};
}
};
</script>
<style scoped>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.image-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.image-card:hover {
transform: translateY(-5px);
}
.image {
width: 100%;
height: auto;
display: block;
background: #f5f5f5;
transition: opacity 0.3s ease;
}
.info {
padding: 15px;
}
.info h3 {
margin: 0 0 8px 0;
font-size: 16px;
color: #333;
}
.info p {
margin: 0;
font-size: 14px;
color: #666;
}
.load-more {
display: block;
width: 200px;
margin: 30px auto;
padding: 12px 24px;
background: #4a8cff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s ease;
}
.load-more:hover {
background: #3a7be0;
}
.load-more:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>