#

瀑布流相册组件开发

By DoubleSpirit121 1 Views 45 MIN READ 0 Comments
详细介绍如何在VitePress中开发一个高性能的瀑布流相册组件PhotoGallery,支持MD文件配置、分类切换、图片点击放大、砌墙式布局、暗色模式辉光、省流加载等特性。

瀑布流相册组件开发

组件需求

为了在网站中展示相册,我们需要开发一个瀑布流相册组件,满足以下需求:

  • 支持在MD文件中直接配置分类和图片
  • 多个分类时支持标签切换
  • 砌墙式瀑布流布局(固定卡片宽度,随机高度)
  • 图片越后面添加的越显示在前面
  • 圆角矩形卡片,无框线
  • 鼠标悬浮放大效果
  • 暗色模式下有辉光效果
  • 点击图片可放大查看(Lightbox)
  • 响应式布局
  • 页面顶部显示大标题和副标题
  • 省流加载机制(每次加载30张,滚动到底部加载更多)

组件实现

创建文件 docs/.vitepress/theme/components/PhotoGallery.vue

<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'

interface PhotoItem {
  src: string
  alt?: string
}

interface Category {
  name: string
  photos: PhotoItem[]
}

interface Props {
  categories?: Category[]
}

const props = withDefaults(defineProps<Props>(), {
  categories: () => [
    {
      name: '默认',
      photos: [
        { src: 'https://example.com/photo1.jpg', alt: '照片1' },
        { src: 'https://example.com/photo2.jpg', alt: '照片2' },
        { src: 'https://example.com/photo3.jpg', alt: '照片3' },
      ]
    }
  ]
})

const activeCategory = ref(0)

const currentPhotos = computed(() => {
  return [...(props.categories[activeCategory.value]?.photos || [])].reverse()
})

const setCategory = (index: number) => {
  activeCategory.value = index
  displayedCount.value = 45
}

const masonryRef = ref<HTMLElement | null>(null)
const displayedCount = ref(45)
const BATCH_SIZE = 30
const PRELOAD_THRESHOLD = 20
const masonryHeight = ref(400)

const displayedPhotos = computed(() => {
  return currentPhotos.value.slice(0, displayedCount.value)
})

const hasMore = computed(() => {
  return displayedCount.value < currentPhotos.value.length
})

const getRandomHeight = () => {
  const heights = [180, 220, 260, 300, 340]
  return heights[Math.floor(Math.random() * heights.length)]
}

const photoItems = ref<{ src: string; alt?: string; height: number; left: number; top: number }[]>([])

const distributePhotos = () => {
  if (!masonryRef.value) return
  
  const containerWidth = masonryRef.value.offsetWidth
  if (containerWidth === 0) return
  
  const CARD_WIDTH = 260
  const gap = 16
  const columns = Math.max(1, Math.floor((containerWidth + gap) / (CARD_WIDTH + gap)))
  const columnHeights = new Array(columns).fill(0)
  
  photoItems.value = displayedPhotos.value.map((photo) => {
    const minHeight = Math.min(...columnHeights)
    const minColumn = columnHeights.indexOf(minHeight)
    
    const photoHeight = getRandomHeight()
    const left = minColumn * (CARD_WIDTH + gap)
    const top = columnHeights[minColumn]
    
    columnHeights[minColumn] += photoHeight + gap
    
    return {
      src: photo.src,
      alt: photo.alt,
      height: photoHeight,
      left,
      top
    }
  })
  
  masonryHeight.value = Math.max(...columnHeights)
}

const handleScroll = () => {
  if (!hasMore.value) return
  
  const scrollHeight = document.documentElement.scrollHeight
  const scrollTop = document.documentElement.scrollTop
  const clientHeight = document.documentElement.clientHeight
  
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    displayedCount.value = Math.min(displayedCount.value + BATCH_SIZE, currentPhotos.value.length)
    nextTick(() => {
      distributePhotos()
    })
  }
}

onMounted(() => {
  const initGallery = () => {
    if (masonryRef.value && masonryRef.value.offsetWidth > 0) {
      distributePhotos()
    } else {
      requestAnimationFrame(initGallery)
    }
  }
  requestAnimationFrame(initGallery)
  window.addEventListener('resize', distributePhotos)
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  window.removeEventListener('resize', distributePhotos)
  window.removeEventListener('scroll', handleScroll)
})

watch(activeCategory, () => {
  nextTick(() => {
    distributePhotos()
  })
})

watch(() => props.categories, () => {
  displayedCount.value = 45
  nextTick(() => {
    distributePhotos()
  })
}, { deep: true })

watch(displayedCount, () => {
  nextTick(() => {
    distributePhotos()
  })
})

const showLightbox = ref(false)
const currentImage = ref('')

const openLightbox = (src: string) => {
  currentImage.value = src
  showLightbox.value = true
}

const closeLightbox = () => {
  showLightbox.value = false
}
</script>

<template>
  <div class="photo-gallery">
    <div class="gallery-header">
      <h1 class="gallery-title">用图片记录生活</h1>
      <p class="gallery-subtitle">每一张图片都是一个故事,一段回忆,一种情感的表达。</p>
    </div>

    <div v-if="categories.length > 1" class="category-tabs">
      <button 
        v-for="(category, index) in categories" 
        :key="index"
        :class="['tab-btn', { active: activeCategory === index }]"
        @click="setCategory(index)"
      >
        {{ category.name }}
      </button>
    </div>

    <div class="masonry-wrapper">
      <div ref="masonryRef" :style="{ height: masonryHeight + 'px' }">
        <div 
          v-for="(photo, index) in photoItems" 
          :key="index" 
          class="photo-item"
          :style="{ width: '260px', height: photo.height + 'px', left: photo.left + 'px', top: photo.top + 'px' }"
          @click="openLightbox(photo.src)"
        >
          <img :src="photo.src" :alt="photo.alt || 'photo'" loading="lazy" />
        </div>
      </div>

      <div v-if="hasMore" class="loading-hint">
        <span>往下划动加载更多...</span>
      </div>
      <div v-else class="loading-hint">
        <span>已经到底啦~</span>
      </div>
    </div>

    <Teleport to="body">
      <div v-if="showLightbox" class="lightbox" @click="closeLightbox">
        <img :src="currentImage" alt="preview" class="lightbox-img" />
        <button class="lightbox-close" @click="closeLightbox">&times;</button>
      </div>
    </Teleport>
  </div>
</template>

<style scoped lang="scss">
.photo-gallery {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.gallery-header {
  text-align: left;
  margin-top: 40px;
  margin-bottom: 32px;
}

.gallery-title {
  font-size: 2.5rem;
  font-weight: 700;
  color: var(--vp-c-text-1);
  margin: 0 0 20px 0;
}

.gallery-subtitle {
  font-size: 1.3rem;
  color: var(--vp-c-text-2);
  margin: 0;
  line-height: 2.8;
}

.category-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 24px;
}

.tab-btn {
  padding: 8px 20px;
  font-size: 0.95rem;
  font-weight: 500;
  color: var(--vp-c-text-2);
  background: var(--vp-c-bg-soft);
  border: 1px solid var(--vp-c-divider);
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.3s ease;

  &:hover {
    color: var(--vp-c-text-1);
    border-color: var(--vp-c-brand);
  }

  &.active {
    color: #fff;
    background: var(--vp-c-brand);
    border-color: var(--vp-c-brand);
  }
}

.masonry-wrapper {
  position: relative;
  width: 100%;
  min-height: 100px;
  overflow: hidden;
}

.photo-item {
  position: absolute;
  width: 260px;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.3s ease, box-shadow 0.3s ease;
  
  &:hover {
    transform: scale(1.03);
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
    z-index: 10;
  }
  
  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }
}

:global(.dark) .photo-item:hover {
  box-shadow: 0 0 25px rgba(64, 158, 255, 0.35);
}

.loading-hint {
  text-align: center;
  padding: 20px;
  color: var(--vp-c-text-2);
  font-size: 0.9rem;
}

.lightbox {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
  cursor: pointer;
}

.lightbox-img {
  max-width: 90%;
  max-height: 90%;
  object-fit: contain;
  border-radius: 8px;
}

.lightbox-close {
  position: absolute;
  top: 20px;
  right: 30px;
  font-size: 40px;
  color: #fff;
  background: none;
  border: none;
  cursor: pointer;
  opacity: 0.8;
  transition: opacity 0.2s;
  
  &:hover {
    opacity: 1;
  }
}
</style>

组件注册

docs/.vitepress/theme/index.ts 中注册组件:

import PhotoGallery from "./components/PhotoGallery.vue";

export default {
  enhanceApp({ app }) {
    app.component('PhotoGallery', PhotoGallery);
  },
}

页面使用

在MD文件中使用组件,通过 :categories 属性配置分类和图片:

---
title: Mcoo Servers 相册
permalink: /photo
layout: page
article: false
sidebar: false
---

<PhotoGallery :categories="[
  {
    name: '新年',
    photos: [
      { src: 'https://example.com/1.jpg', alt: '新年1' },
      { src: 'https://example.com/2.jpg', alt: '新年2' },
      { src: 'https://example.com/3.jpg', alt: '新年3' },
    ]
  },
  {
    name: '风景',
    photos: [
      { src: 'https://example.com/4.jpg', alt: '风景1' },
      { src: 'https://example.com/5.jpg', alt: '风景2' },
    ]
  }
]" />

关键实现点

1. Props接收MD配置

使用 withDefaultsdefineProps 接收MD文件传入的分类数据:

interface Props {
  categories?: Category[]
}

const props = withDefaults(defineProps<Props>(), {
  categories: () => [
    {
      name: '默认',
      photos: [
        { src: '默认图片链接', alt: '照片1' },
      ]
    }
  ]
})

2. 砌墙式瀑布流(固定宽度+随机高度)

使用JavaScript动态计算每张图片的位置,实现真正的错落有致效果:

const CARD_WIDTH = 260
const gap = 16
const columns = Math.max(1, Math.floor((containerWidth + gap) / (CARD_WIDTH + gap)))

photoItems.value = displayedPhotos.value.map((photo) => {
  const minHeight = Math.min(...columnHeights)
  const minColumn = columnHeights.indexOf(minHeight)
  
  const photoHeight = getRandomHeight() // 随机高度
  const left = minColumn * (CARD_WIDTH + gap)
  const top = columnHeights[minColumn]
  
  columnHeights[minColumn] += photoHeight + gap
  
  return { src, alt, height: photoHeight, left, top }
})

关键点

  • 卡片宽度固定为 260px
  • 高度随机生成(180/220/260/300/340px)
  • 使用绝对定位 position: absolute
  • 动态计算 lefttop 位置

3. 暗色模式辉光效果

使用 :global() 选择器为暗色模式添加辉光:

:global(.dark) .photo-item:hover {
  box-shadow: 0 0 25px rgba(64, 158, 255, 0.35);
}

4. 图片点击放大(Lightbox + Teleport)

使用 Teleport 将灯箱渲染到body,解决定位问题:

const showLightbox = ref(false)
const openLightbox = (src: string) => {
  currentImage.value = src
  showLightbox.value = true
}
<Teleport to="body">
  <div v-if="showLightbox" class="lightbox" @click="closeLightbox">
    <img :src="currentImage" alt="preview" class="lightbox-img" />
    <button class="lightbox-close" @click="closeLightbox">&times;</button>
  </div>
</Teleport>

5. 省流加载(无限滚动)

首次加载45张图片,预加载阈值20张,滚动到底部时加载更多:

const displayedCount = ref(45)
const BATCH_SIZE = 30
const PRELOAD_THRESHOLD = 20

const handleScroll = () => {
  if (!hasMore.value) return
  
  const scrollHeight = document.documentElement.scrollHeight
  const scrollTop = document.documentElement.scrollTop
  const clientHeight = document.documentElement.clientHeight
  const remaining = currentPhotos.value.length - displayedCount.value
  
  if (remaining <= PRELOAD_THRESHOLD) {
    displayedCount.value = Math.min(displayedCount.value + BATCH_SIZE, currentPhotos.value.length)
    nextTick(() => {
      distributePhotos()
    })
  } else if (scrollTop + clientHeight >= scrollHeight - 100) {
    displayedCount.value = Math.min(displayedCount.value + BATCH_SIZE, currentPhotos.value.length)
    nextTick(() => {
      distributePhotos()
    })
  }
}

6. 初始化加载检测

首次加载时,容器宽度可能为0(DOM未渲染完成),需要使用 requestAnimationFrame 循环检测:

onMounted(() => {
  const initGallery = () => {
    if (masonryRef.value && masonryRef.value.offsetWidth > 0) {
      distributePhotos()
    } else {
      requestAnimationFrame(initGallery)
    }
  }
  requestAnimationFrame(initGallery)
  window.addEventListener('resize', distributePhotos)
  window.addEventListener('scroll', handleScroll)
})

Bug修复记录

Bug 1: 首次加载显示单列

问题:首次进入相册页面时,图片全部排列在左边,变成长长的一列,需要切换分类后再切回来显示。

原因:组件首次挂载时,容器宽度为0,导致列数计算错误。

修复:使用 requestAnimationFrame 循环检测容器宽度是否有效:

const initGallery = () => {
  if (masonryRef.value && masonryRef.value.offsetWidth > 0) {
    distributePhotos()
  } else {
    requestAnimationFrame(initGallery)
  }
}
requestAnimationFrame(initGallery)

Bug 2: 图片重叠

问题:图片卡片重叠在一起。

原因:CSS中卡片宽度未固定,且列数计算有问题。

修复

  1. 固定卡片宽度为260px
  2. 使用固定宽度计算列数
  3. 给容器添加 overflow: hidden

Bug 3: 加载提示位置错误

问题:"往下划动加载更多"出现在页面正中间,下面还有渐变色带。

原因:加载提示放在了瀑布流容器外面。

修复:将加载提示放在 masonry-wrapper 内部,紧跟在瀑布流图片后面。

参数说明

参数类型必填说明
categoriesCategory[]分类数组,默认有一个默认分类

Category类型:

字段类型说明
namestring分类名称
photosPhotoItem[]该分类下的图片数组

PhotoItem类型:

字段类型说明
srcstring图片链接(必填)
altstring图片描述(可选)

效果展示

  • 砌墙式布局:固定宽度260px,随机高度,实现错落有致的砌墙效果
  • 分类切换:多个分类时显示标签,点击切换
  • 省流加载:初始加载30张,滚动到底部时加载更多30张
  • 悬浮效果:鼠标放上图片有放大效果和阴影
  • 暗色模式:暗色模式下悬浮有蓝色辉光效果
  • 点击放大:点击图片可查看大图,点击空白处或关闭按钮退出

本文由 DoubleSpirit121 原创

采用 CC BY-NC-SA 4.0 协议进行许可

转载请注明出处:https://blog.mcoo.top/index.php/archives/43/

加入 Mcoo

0 评论

发表评论