Skip to content

瀑布流布局

Vue 瀑布流布局组件,支持虚拟滚动和响应式布局。

@yeger/vue-masonry-wall

基本信息

特点

  • ✅ 完整的 TypeScript 支持
  • ✅ 响应式列数调整
  • ✅ SSR 支持
  • ✅ 轻量级(~2KB gzipped)
  • ✅ 零依赖
  • ✅ 支持 Vue 3 Composition API
  • ✅ 流畅的动画过渡

安装

bash
npm install @yeger/vue-masonry-wall
bash
pnpm add @yeger/vue-masonry-wall
bash
yarn add @yeger/vue-masonry-wall
bash
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 参数

参数类型默认值说明
itemsT[]必填要显示的数据数组
columnWidthnumber300列的最小宽度(px)
gapnumber0列之间和项目之间的间距(px)
rtlbooleanfalse是否启用从右到左布局
ssrColumnsnumber0SSR 时的列数

结合虚拟滚动使用

@yeger/vue-masonry-wallDynamicScroller 结合使用,实现大数据量的虚拟滚动瀑布流:

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 风格布局
  • � 博客文章列表

注意事项

  1. 图片加载:使用 loading="lazy" 属性实现原生懒加载
  2. 内存管理:大量数据时考虑使用虚拟滚动
  3. 响应式:监听窗口大小变化,动态调整布局参数
  4. 性能监控:使用 Vue DevTools 监控组件性能
  5. SEO:服务端渲染时注意设置 ssrColumns 参数

相关资源