瀑布流相册组件开发
详细介绍如何在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">×</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配置
使用 withDefaults 和 defineProps 接收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 - 动态计算
left和top位置
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">×</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中卡片宽度未固定,且列数计算有问题。
修复:
- 固定卡片宽度为260px
- 使用固定宽度计算列数
- 给容器添加
overflow: hidden
Bug 3: 加载提示位置错误
问题:"往下划动加载更多"出现在页面正中间,下面还有渐变色带。
原因:加载提示放在了瀑布流容器外面。
修复:将加载提示放在 masonry-wrapper 内部,紧跟在瀑布流图片后面。
参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| categories | Category[] | 否 | 分类数组,默认有一个默认分类 |
Category类型:
| 字段 | 类型 | 说明 |
|---|---|---|
| name | string | 分类名称 |
| photos | PhotoItem[] | 该分类下的图片数组 |
PhotoItem类型:
| 字段 | 类型 | 说明 |
|---|---|---|
| src | string | 图片链接(必填) |
| alt | string | 图片描述(可选) |
效果展示
- 砌墙式布局:固定宽度260px,随机高度,实现错落有致的砌墙效果
- 分类切换:多个分类时显示标签,点击切换
- 省流加载:初始加载30张,滚动到底部时加载更多30张
- 悬浮效果:鼠标放上图片有放大效果和阴影
- 暗色模式:暗色模式下悬浮有蓝色辉光效果
- 点击放大:点击图片可查看大图,点击空白处或关闭按钮退出
本文由 DoubleSpirit121 原创
采用 CC BY-NC-SA 4.0 协议进行许可
0 评论