239 lines
4.8 KiB
Vue
239 lines
4.8 KiB
Vue
<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>
|