瀑布流布局
Vue 瀑布流布局组件,支持虚拟滚动和响应式布局。
@yeger/vue-masonry-wall
基本信息
- 简介: 轻量级、响应式的 Vue 3 瀑布流组件
- 链接: https://vue-masonry-wall.yeger.eu/
- GitHub: https://github.com/DerYeger/yeger/tree/main/packages/vue-masonry-wall
- NPM: @yeger/vue-masonry-wall
特点
- ✅ 完整的 TypeScript 支持
- ✅ 响应式列数调整
- ✅ SSR 支持
- ✅ 轻量级(~2KB gzipped)
- ✅ 零依赖
- ✅ 支持 Vue 3 Composition API
- ✅ 流畅的动画过渡
安装
bash
npm install @yeger/vue-masonry-wallbash
pnpm add @yeger/vue-masonry-wallbash
yarn add @yeger/vue-masonry-wallbash
bun add @yeger/vue-masonry-wall基础用法
vue
<script setup lang="ts">
import { ref } from 'vue'
import MasonryWall from '@yeger/vue-masonry-wall'
interface Item {
id: number
title: string
height: number
image: string
}
const items = ref<Item[]>([
{ id: 1, title: '图片 1', height: 300, image: 'https://picsum.photos/400/300' },
{ id: 2, title: '图片 2', height: 200, image: 'https://picsum.photos/400/200' },
{ id: 3, title: '图片 3', height: 400, image: 'https://picsum.photos/400/400' },
{ id: 4, title: '图片 4', height: 250, image: 'https://picsum.photos/400/250' },
{ id: 5, title: '图片 5', height: 350, image: 'https://picsum.photos/400/350' },
])
</script>
<template>
<MasonryWall :items="items" :column-width="300" :gap="16">
<template #default="{ item }">
<div class="rounded-lg overflow-hidden shadow-md transition-transform duration-300 hover:-translate-y-1">
<img :src="item.image" :alt="item.title" class="w-full block" />
<h3 class="p-3 m-0">{{ item.title }}</h3>
</div>
</template>
</MasonryWall>
</template>响应式列数
vue
<script setup lang="ts">
import { ref } from 'vue'
import MasonryWall from '@yeger/vue-masonry-wall'
const items = ref([
// ... 数据
])
</script>
<template>
<MasonryWall
:items="items"
:column-width="300"
:gap="16"
:rtl="false"
>
<template #default="{ item, index }">
<div class="item">
<span>{{ index + 1 }}</span>
<p>{{ item.content }}</p>
</div>
</template>
</MasonryWall>
</template>动态加载数据
vue
<script setup lang="ts">
import { ref } from 'vue'
import MasonryWall from '@yeger/vue-masonry-wall'
interface Photo {
id: number
url: string
title: string
}
const photos = ref<Photo[]>([])
const loading = ref(false)
const loadMore = async () => {
loading.value = true
try {
// 模拟 API 请求
const newPhotos = await fetchPhotos()
photos.value.push(...newPhotos)
} finally {
loading.value = false
}
}
const fetchPhotos = (): Promise<Photo[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const start = photos.value.length
const newItems = Array.from({ length: 10 }, (_, i) => ({
id: start + i,
url: `https://picsum.photos/400/${200 + Math.random() * 300}`,
title: `照片 ${start + i + 1}`,
}))
resolve(newItems)
}, 1000)
})
}
// 初始加载
loadMore()
</script>
<template>
<div>
<MasonryWall :items="photos" :column-width="300" :gap="16">
<template #default="{ item }">
<div class="relative overflow-hidden rounded-lg cursor-pointer group">
<img
:src="item.url"
:alt="item.title"
loading="lazy"
class="w-full block transition-transform duration-300 group-hover:scale-105"
/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent text-white p-4 translate-y-full transition-transform duration-300 group-hover:translate-y-0">
<h4>{{ item.title }}</h4>
</div>
</div>
</template>
</MasonryWall>
<div class="text-center py-8">
<button
@click="loadMore"
:disabled="loading"
class="px-8 py-3 text-base border-0 rounded bg-[#42b883] text-white cursor-pointer transition-colors duration-300 hover:bg-[#35a372] disabled:opacity-60 disabled:cursor-not-allowed"
>
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>API 参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | T[] | 必填 | 要显示的数据数组 |
columnWidth | number | 300 | 列的最小宽度(px) |
gap | number | 0 | 列之间和项目之间的间距(px) |
rtl | boolean | false | 是否启用从右到左布局 |
ssrColumns | number | 0 | SSR 时的列数 |
结合虚拟滚动使用
将 @yeger/vue-masonry-wall 和 DynamicScroller 结合使用,实现大数据量的虚拟滚动瀑布流:
vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import MasonryWall from '@yeger/vue-masonry-wall'
import { useIntersectionObserver } from '@vueuse/core'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Photo {
id: number
url: string
title: string
height: number
loaded?: boolean
}
const allPhotos = ref<Photo[]>([])
const loading = ref(false)
const hasMore = ref(true)
const loadMoreTrigger = ref<HTMLElement | null>(null)
// 分组数据,每组显示一行瀑布流
const ITEMS_PER_ROW = 20
const groupedPhotos = computed(() => {
const groups = []
for (let i = 0; i < allPhotos.value.length; i += ITEMS_PER_ROW) {
groups.push({
id: i,
items: allPhotos.value.slice(i, i + ITEMS_PER_ROW),
})
}
return groups
})
// 模拟 API 请求加载数据
const fetchPhotos = async (page: number, pageSize: number = 40): Promise<Photo[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const start = page * pageSize
const newItems = Array.from({ length: pageSize }, (_, i) => ({
id: start + i,
url: `https://picsum.photos/400/${200 + Math.floor(Math.random() * 400)}?random=${start + i}`,
title: `照片 ${start + i + 1}`,
height: 200 + Math.floor(Math.random() * 400),
loaded: false,
}))
resolve(newItems)
}, 1000)
})
}
// 加载更多数据
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const currentPage = Math.floor(allPhotos.value.length / 40)
const newPhotos = await fetchPhotos(currentPage)
allPhotos.value.push(...newPhotos)
// 模拟最多加载 200 张图片
if (allPhotos.value.length >= 200) {
hasMore.value = false
}
} catch (error) {
console.error('加载失败:', error)
} finally {
loading.value = false
}
}
// 图片加载完成
const onImageLoad = (photo: Photo) => {
photo.loaded = true
}
// 图片加载失败
const onImageError = (photo: Photo) => {
photo.loaded = true
photo.url = 'https://via.placeholder.com/400x300?text=加载失败'
}
// 监听底部元素,触发加载更多
useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting && !loading.value && hasMore.value) {
loadMore()
}
},
{ threshold: 0.1 }
)
// 初始加载
onMounted(() => {
loadMore()
})
</script>
<template>
<div class="p-5">
<h1 class="text-center text-[#2c3e50] mb-6">
虚拟滚动瀑布流 ({{ allPhotos.length }} 张图片)
</h1>
<DynamicScroller
:items="groupedPhotos"
:min-item-size="400"
class="h-[calc(100vh-180px)] border border-gray-300 rounded-lg overflow-auto"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
>
<MasonryWall
:items="item.items"
:column-width="250"
:gap="16"
class="p-4"
>
<template #default="{ item: photo }">
<div class="bg-white rounded-lg overflow-hidden shadow-md transition-all duration-300 hover:-translate-y-1 hover:shadow-xl">
<!-- 图片占位符 -->
<div class="relative w-full" :style="{ paddingBottom: '75%' }">
<div
v-if="!photo.loaded"
class="absolute inset-0 bg-gray-200 animate-pulse flex items-center justify-center"
>
<svg
class="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<img
:src="photo.url"
:alt="photo.title"
loading="lazy"
class="absolute inset-0 w-full h-full object-cover"
:class="{ 'opacity-0': !photo.loaded }"
@load="onImageLoad(photo)"
@error="onImageError(photo)"
/>
</div>
<div class="p-3">
<h4 class="m-0 mb-1 text-[#2c3e50] text-sm font-medium">
{{ photo.title }}
</h4>
<span class="text-xs text-gray-500">ID: {{ photo.id }}</span>
</div>
</div>
</template>
</MasonryWall>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<!-- 底部加载提示 -->
<div
ref="loadMoreTrigger"
class="py-8 text-center"
>
<div v-if="loading" class="flex items-center justify-center gap-2 text-gray-600">
<svg
class="animate-spin h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>加载中...</span>
</div>
<div v-else-if="!hasMore" class="text-gray-500">
已加载全部内容
</div>
<div v-else class="text-gray-400">
向下滚动加载更多
</div>
</div>
</div>
</template>工具对比
| 特性 | @yeger/vue-masonry-wall | 结合虚拟滚动 |
|---|---|---|
| 主要用途 | 瀑布流布局 | 大数据量瀑布流 |
| 性能 | 适中(数百项) | 优秀(数万项) |
| 动态高度 | ✅ 自动计算 | ✅ 支持 |
| 响应式 | ✅ 自动调整列数 | ✅ 可配置 |
| 包大小 | ~2KB | ~17KB |
| TypeScript | ✅ 完整支持 | ✅ 完整支持 |
| 学习曲线 | 简单 | 中等 |
| 适用场景 | 图片画廊、卡片布局 | 大量图片、无限滚动 |
最佳实践
1. 选择合适的方案
使用纯瀑布流(@yeger/vue-masonry-wall)当:
- 数据量在几百到几千条
- 需要响应式的瀑布流布局
- 项目高度不固定
- 追求简单易用
结合虚拟滚动当:
- 数据量超过几千条
- 需要处理大量数据的瀑布流
- 每行显示多个项目
- 需要虚拟滚动的性能优势
2. 性能优化建议
vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import MasonryWall from '@yeger/vue-masonry-wall'
// 使用 shallowRef 减少响应式开销
const items = shallowRef([])
// 图片懒加载
const imageLoaded = (index: number) => {
console.log(`图片 ${index} 加载完成`)
}
// 分批加载数据
const loadBatch = async (start: number, count: number) => {
const newItems = await fetchItems(start, count)
items.value = [...items.value, ...newItems]
}
</script>
<template>
<MasonryWall :items="items" :column-width="300" :gap="16">
<template #default="{ item, index }">
<div class="card">
<img
:src="item.url"
:alt="item.title"
loading="lazy"
@load="imageLoaded(index)"
/>
</div>
</template>
</MasonryWall>
</template>3. 响应式设计
vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useWindowSize } from '@vueuse/core'
import MasonryWall from '@yeger/vue-masonry-wall'
const { width } = useWindowSize()
// 根据屏幕宽度动态调整列宽
const columnWidth = computed(() => {
if (width.value < 768) return width.value - 32 // 移动端
if (width.value < 1024) return 300 // 平板
return 350 // 桌面端
})
const gap = computed(() => {
return width.value < 768 ? 8 : 16
})
</script>
<template>
<MasonryWall
:items="items"
:column-width="columnWidth"
:gap="gap"
>
<template #default="{ item }">
<!-- 内容 -->
</template>
</MasonryWall>
</template>4. 错误处理和加载状态
vue
<script setup lang="ts">
import { ref } from 'vue'
import MasonryWall from '@yeger/vue-masonry-wall'
const items = ref([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadData = async () => {
loading.value = true
error.value = null
try {
const data = await fetchData()
items.value = data
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<MasonryWall v-else :items="items" :column-width="300" :gap="16">
<template #default="{ item }">
<!-- 内容 -->
</template>
</MasonryWall>
</div>
</template>使用场景
瀑布流布局适用于:
- 📸 图片画廊和相册
- 📰 新闻卡片流
- 🛍️ 商品展示
- 📱 社交媒体信息流
- 🎨 作品集展示
- 🖼️ Pinterest 风格布局
- � 博客文章列表
注意事项
- 图片加载:使用
loading="lazy"属性实现原生懒加载 - 内存管理:大量数据时考虑使用虚拟滚动
- 响应式:监听窗口大小变化,动态调整布局参数
- 性能监控:使用 Vue DevTools 监控组件性能
- SEO:服务端渲染时注意设置
ssrColumns参数