topfans/frontend/components/LazyImage.vue
2026-04-07 23:08:49 +08:00

239 lines
4.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="lazy-image-wrapper" :style="wrapperStyle">
<image
v-if="shouldLoad"
:src="currentSrc"
:mode="mode"
:class="['lazy-image', { 'loaded': isLoaded, 'error': hasError }]"
:style="imageStyle"
@load="handleLoad"
@error="handleError"
/>
<view v-else class="lazy-image-placeholder" :style="placeholderStyle">
<view class="placeholder-icon"></view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, getCurrentInstance, watch, inject } from 'vue'
const props = defineProps({
src: {
type: String,
required: true
},
placeholder: {
type: String,
default: ''
},
mode: {
type: String,
default: 'aspectFill'
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: 'auto'
},
lazy: {
type: Boolean,
default: true
},
threshold: {
type: Number,
default: 100 // 距离可视区域多少像素时开始加载
},
// 用于虚拟列表场景的滚动触发(可选,优先使用 inject
scrollTop: {
type: Number,
default: 0
}
})
const emit = defineEmits(['load', 'error'])
const shouldLoad = ref(!props.lazy) // 如果不启用懒加载,直接加载
const isLoaded = ref(false)
const hasError = ref(false)
const checkTimer = ref(null)
const instance = getCurrentInstance()
// 尝试从 VirtualList 注入 scrollTop用于虚拟列表场景
const injectedScrollTop = inject('virtualListScrollTop', null)
const currentSrc = computed(() => {
if (hasError.value && props.placeholder) {
return props.placeholder
}
return props.src
})
const wrapperStyle = computed(() => {
const style = {}
if (props.width) {
style.width = typeof props.width === 'number' ? `${props.width}px` : props.width
}
if (props.height) {
style.height = typeof props.height === 'number' ? `${props.height}px` : props.height
}
return style
})
const imageStyle = computed(() => {
return {
width: '100%',
height: '100%'
}
})
const placeholderStyle = computed(() => {
return {
width: '100%',
height: '100%',
backgroundColor: '#f5f5f5'
}
})
function handleLoad(e) {
isLoaded.value = true
hasError.value = false
emit('load', e)
}
function handleError(e) {
hasError.value = true
console.error('[LazyImage] 图片加载失败:', props.src)
emit('error', e)
}
// 检查元素是否在可视区域内
function checkInView() {
if (!props.lazy || shouldLoad.value) {
return
}
const query = uni.createSelectorQuery().in(instance)
query.select('.lazy-image-wrapper').boundingClientRect((rect) => {
if (!rect) return
const windowHeight = uni.getSystemInfoSync().windowHeight
const threshold = props.threshold
// 判断元素是否在可视区域内或即将进入可视区域
if (rect.top < windowHeight + threshold && rect.bottom > -threshold) {
shouldLoad.value = true
stopChecking()
}
}).exec()
}
// 停止检查
function stopChecking() {
if (checkTimer.value) {
clearTimeout(checkTimer.value)
checkTimer.value = null
}
}
// 设置懒加载
function setupLazyLoad() {
if (!props.lazy) {
shouldLoad.value = true
return
}
// 首次检查
checkInView()
// 如果首次检查未加载,设置延迟检查
if (!shouldLoad.value) {
checkTimer.value = setTimeout(() => {
checkInView()
checkTimer.value = null
}, 500)
}
}
// 监听 src 变化,重新设置懒加载
watch(() => props.src, () => {
if (props.lazy && !shouldLoad.value) {
setupLazyLoad()
}
})
onMounted(() => {
setupLazyLoad()
// 在 onMounted 后设置 watch确保 inject 已经准备好
// 监听注入的 scrollTop来自 VirtualList
if (injectedScrollTop) {
watch(injectedScrollTop, () => {
if (!shouldLoad.value && props.lazy) {
checkInView()
}
}, { immediate: false })
}
// 监听 prop scrollTop 变化(用于手动传递的场景)
watch(() => props.scrollTop, () => {
if (!shouldLoad.value && props.lazy) {
checkInView()
}
}, { immediate: false })
})
onUnmounted(() => {
stopChecking()
})
</script>
<style scoped>
.lazy-image-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.lazy-image {
display: block;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.lazy-image.loaded {
opacity: 1;
}
.lazy-image.error {
opacity: 0.5;
}
.lazy-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
.placeholder-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #e0e0e0;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>