Skip to content

Commit 068c64b

Browse files
fix: 优化瀑布流布局和图片加载体验
- 移除 vueDevTools 插件,解决 localStorage 错误 - 添加动态图片尺寸检测,支持无预设尺寸的图片 - 修复长图造成的布局空隙问题(统一使用 Math.floor) - 滚动加载改用 throttle,提前加载距离增至 300px - 批量更新布局优化,减少重复计算 - 底部显示加载状态 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 13bc52d commit 068c64b

2 files changed

Lines changed: 111 additions & 65 deletions

File tree

src/components/ImageWaterfall.vue

Lines changed: 111 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script setup lang="ts">
22
import { ref, onMounted, onUnmounted, watch } from 'vue'
3-
import { NImage, NCard, NSpin, NButton, NIcon } from 'naive-ui'
3+
import { NCard, NSpin, NButton, NIcon, NImage } from 'naive-ui'
44
import { OpenOutline } from '@vicons/ionicons5'
55
import type { ImageItem } from '@/types/waterfall'
6-
import { debounce } from 'lodash-es'
6+
import { debounce, throttle } from 'lodash-es'
77
88
const props = defineProps<{
99
images: ImageItem[]
@@ -21,6 +21,8 @@ const containerRef = ref<HTMLElement | null>(null)
2121
const columns = ref<ImageItem[][]>([])
2222
const columnCount = ref(1)
2323
const columnWidth = ref(240)
24+
// 存储图片实际尺寸
25+
const imageSizes = ref<Map<string, { width: number; height: number }>>(new Map())
2426
2527
function calculateColumnCount() {
2628
if (!containerRef.value) return
@@ -53,17 +55,47 @@ function distributeImages() {
5355
})
5456
}
5557
58+
function getImageSize(image: ImageItem): { width: number; height: number } {
59+
// 优先使用缓存的实际尺寸
60+
if (imageSizes.value.has(image.id)) {
61+
return imageSizes.value.get(image.id)!
62+
}
63+
// 其次使用 API 返回的尺寸
64+
if (image.width && image.height && image.width > 0 && image.height > 0) {
65+
return { width: image.width, height: image.height }
66+
}
67+
// 默认使用 1:1 比例
68+
return { width: 1, height: 1 }
69+
}
70+
5671
function getColumnHeight(column: ImageItem[]) {
5772
return column.reduce((height, image) => {
58-
// 使用 API 返回的尺寸计算高度
59-
const scaledHeight = (columnWidth.value * image.height) / image.width
73+
const size = getImageSize(image)
74+
const scaledHeight = Math.floor((columnWidth.value * size.height) / size.width)
6075
return height + scaledHeight + 8
6176
}, 0)
6277
}
6378
6479
function calculateImageHeight(image: ImageItem): number {
65-
// 使用 API 返回的尺寸计算高度
66-
return Math.floor((columnWidth.value * image.height) / image.width)
80+
const size = getImageSize(image)
81+
return Math.floor((columnWidth.value * size.height) / size.width)
82+
}
83+
84+
// 批量更新布局,合并短时间内的多次调用
85+
const debouncedDistribute = debounce(() => {
86+
distributeImages()
87+
}, 50)
88+
89+
function onImageLoad(imageId: string, event: Event) {
90+
const img = event.target as HTMLImageElement
91+
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
92+
imageSizes.value.set(imageId, {
93+
width: img.naturalWidth,
94+
height: img.naturalHeight
95+
})
96+
// 使用 debounced 版本,50ms 内的多次调用会合并为一次
97+
debouncedDistribute()
98+
}
6799
}
68100
69101
// 使用 debounce 优化 resize 处理
@@ -72,19 +104,19 @@ const handleResize = debounce(() => {
72104
distributeImages()
73105
}, 100)
74106
75-
// 使用 debounce 优化滚动处理
76-
const handleScroll = debounce(() => {
107+
// 使用 throttle 优化滚动处理(throttle 比 debounce 更适合滚动场景)
108+
const handleScroll = throttle(() => {
77109
if (props.loading || !props.hasMore) return
78110
79111
const scrollTop = window.scrollY || document.documentElement.scrollTop
80112
const windowHeight = window.innerHeight
81113
const documentHeight = document.documentElement.scrollHeight
82114
83-
// 当距离底部 100px 时触发加载
84-
if (documentHeight - (scrollTop + windowHeight) < 100) {
115+
// 当距离底部 300px 时触发加载(增大触发距离,提前加载)
116+
if (documentHeight - (scrollTop + windowHeight) < 300) {
85117
emit('load-more')
86118
}
87-
}, 200)
119+
}, 100)
88120
89121
let resizeObserver: ResizeObserver | null = null
90122
@@ -115,15 +147,25 @@ onUnmounted(() => {
115147
window.removeEventListener('resize', handleResize)
116148
window.removeEventListener('scroll', handleScroll)
117149
118-
// 取消未执行的 debounce 函数
150+
// 取消未执行的 debounce/throttle 函数
119151
handleResize.cancel()
120152
handleScroll.cancel()
153+
debouncedDistribute.cancel()
121154
})
122155
123156
// 监听图片数组变化
124157
watch(
125158
() => props.images,
126-
() => {
159+
(newImages) => {
160+
// 清理不存在的图片尺寸缓存
161+
const imageIds = new Set(newImages.map(img => img.id))
162+
const newSizes = new Map<string, { width: number; height: number }>()
163+
imageSizes.value.forEach((size, id) => {
164+
if (imageIds.has(id)) {
165+
newSizes.set(id, size)
166+
}
167+
})
168+
imageSizes.value = newSizes
127169
distributeImages()
128170
},
129171
{ deep: true },
@@ -137,68 +179,65 @@ watch(columnCount, () => {
137179

138180
<template>
139181
<div ref="containerRef" class="waterfall">
140-
<NSpin :show="loading">
141-
<div class="columns">
142-
<div
143-
v-for="(column, columnIndex) in columns"
144-
:key="columnIndex"
145-
class="column"
146-
:style="{ width: `${columnWidth}px` }"
147-
>
148-
<NCard
149-
v-for="image in column"
150-
:key="image.id"
151-
class="image-card"
152-
:content-style="{ padding: 0 }"
153-
:header-style="{ padding: 0 }"
154-
:footer-style="{ padding: 0 }"
155-
:segmented="{ content: false }"
156-
:bordered="false"
157-
:style="{ marginBottom: '8px' }"
158-
>
159-
<div class="image-wrapper">
182+
<div class="columns">
183+
<div v-for="(column, columnIndex) in columns" :key="columnIndex" class="column"
184+
:style="{ width: `${columnWidth}px` }">
185+
<NCard v-for="image in column" :key="image.id" class="image-card" :content-style="{ padding: 0 }"
186+
:header-style="{ padding: 0 }" :footer-style="{ padding: 0 }" :segmented="{ content: false }"
187+
:bordered="false" :style="{ marginBottom: '8px' }">
188+
<div class="image-wrapper">
189+
<div class="image-container" :style="{
190+
width: '100%',
191+
height: `${calculateImageHeight(image)}px`,
192+
overflow: 'hidden',
193+
borderRadius: '4px',
194+
position: 'relative',
195+
backgroundColor: '#f0f0f0',
196+
}">
160197
<NImage
161198
:src="image.url"
162-
:width="columnWidth"
163-
:height="calculateImageHeight(image)"
164199
:preview-src="image.originalUrl"
165-
object-fit="cover"
166-
:show-toolbar-tooltip="false"
167-
:preview-disabled="false"
200+
:img-props="{
201+
style: {
202+
width: '100%',
203+
height: '100%',
204+
objectFit: 'cover',
205+
objectPosition: 'top',
206+
display: 'block',
207+
}
208+
}"
168209
:style="{
169210
width: '100%',
170-
height: 'auto',
211+
height: `${calculateImageHeight(image)}px`,
171212
display: 'block',
172213
borderRadius: '4px',
173214
cursor: 'zoom-in',
174215
}"
216+
@load="(e: Event) => onImageLoad(image.id, e)"
175217
/>
176-
<NButton
177-
quaternary
178-
circle
179-
size="small"
180-
class="open-link"
181-
tag="a"
182-
:href="image.wb_url"
183-
target="_blank"
184-
:style="{
185-
'--n-text-color': '#ffffff',
186-
'--n-text-color-hover': '#ffffff',
187-
'--n-text-color-pressed': '#ffffff',
188-
}"
189-
>
190-
<template #icon>
191-
<NIcon>
192-
<OpenOutline />
193-
</NIcon>
194-
</template>
195-
</NButton>
196218
</div>
197-
</NCard>
198-
</div>
219+
<NButton quaternary circle size="small" class="open-link" tag="a" :href="image.wb_url" target="_blank"
220+
:style="{
221+
'--n-text-color': '#ffffff',
222+
'--n-text-color-hover': '#ffffff',
223+
'--n-text-color-pressed': '#ffffff',
224+
}">
225+
<template #icon>
226+
<NIcon>
227+
<OpenOutline />
228+
</NIcon>
229+
</template>
230+
</NButton>
231+
</div>
232+
</NCard>
199233
</div>
200-
<div v-if="!loading && !hasMore" class="no-more">没有更多了</div>
201-
</NSpin>
234+
</div>
235+
<!-- 底部加载状态 -->
236+
<div v-if="loading" class="loading-more">
237+
<NSpin size="small" />
238+
<span>加载中...</span>
239+
</div>
240+
<div v-else-if="!hasMore && images.length > 0" class="no-more">没有更多了</div>
202241
</div>
203242
</template>
204243

@@ -260,6 +299,15 @@ watch(columnCount, () => {
260299
color: var(--n-text-color-3);
261300
}
262301
302+
.loading-more {
303+
display: flex;
304+
align-items: center;
305+
justify-content: center;
306+
gap: 8px;
307+
padding: 16px;
308+
color: var(--n-text-color-3);
309+
}
310+
263311
:deep(.n-card) {
264312
background-color: transparent;
265313
}

vite.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ import { fileURLToPath, URL } from 'node:url'
22

33
import { defineConfig } from 'vite'
44
import vue from '@vitejs/plugin-vue'
5-
import vueDevTools from 'vite-plugin-vue-devtools'
65

76
// https://vite.dev/config/
87
export default defineConfig({
98
plugins: [
109
vue(),
11-
vueDevTools(),
1210
],
1311
resolve: {
1412
alias: {

0 commit comments

Comments
 (0)