- 这个页面在 goodsInfo 插槽里通过 <LazyImage> 渲染商品缩略图,具体位于 index.vue ,只有 row.goods?.thumbUrl 有值时才会走 <LazyImage>,否则展示一个固定大小的 "预览" 占位块;为了维持尺寸,缩略图宽高都由 goodsThumbSize 计算后传入 width/height 属性。
- LazyImage 组件位于 LazyImage.vue (line 1),内部用 wrapperRef 挂在到外层 <div> 上,并通过 IntersectionObserver(rootMargin: '120px 0px')监听元素是否进入视窗;一旦 entry.isIntersecting,调用 loadImage 把 shouldLoad 设为 true 并断开 observer,确保图片只加载一次。
- 模板里 shouldLoad 为 true 时才渲染 ant-design-vue 的 <Image>,并通过 imageAttrs、alt、width、height、src 把 props/attrs 透传进去;未加载前会渲染带灰背景的 .lazy-image-placeholder 占位层,避免布局抖动。
- resolvedPreview 计算属性在 shouldLoad 变 true 以后才起作用,用来把 previewSrc、preview 相关配置组装成 Ant Design Image 可识别的预览配置,若 props.preview 为 false 则禁用预览;wrapperStyle 用来将传入的宽高参数转成 CSS 字符串保证容器尺寸。
- 组件还支持透传其它属性 (useAttrs 去除 class/style 后的属性会直接传给 <Image>),并在 onBeforeUnmount 里清理 IntersectionObserver,保证组件卸载时不会留下监听器。
<script lang="ts" setup>
import { Image } from 'ant-design-vue';
import {
computed,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
} from 'vue';
defineOptions({
name: 'LazyImage',
inheritAttrs: false,
});
const props = defineProps({
src: { type: String, default: '' },
previewSrc: { type: String, default: '' },
alt: { type: String, default: '' },
width: { type: [Number, String], default: undefined },
height: { type: [Number, String], default: undefined },
preview: { type: [Boolean, Object], default: true },
});
const attrs = useAttrs();
const wrapperRef = ref<HTMLElement | null>(null);
const shouldLoad = ref(false);
let observer: null | IntersectionObserver = null;
const imageAttrs = computed(() => {
const { class: _class, style: _style, ...rest } = attrs;
return rest;
});
const wrapperStyle = computed(() => {
const style: Record<string, string> = {};
if (props.width !== undefined) {
style.width =
typeof props.width === 'number'
? `${props.width}px`
: String(props.width);
}
if (props.height !== undefined) {
style.height =
typeof props.height === 'number'
? `${props.height}px`
: String(props.height);
}
return style;
});
const resolvedPreview = computed(() => {
if (!shouldLoad.value) return false;
if (props.preview === false) return false;
const source = props.previewSrc || props.src;
if (props.preview === true) {
return source ? { src: source } : true;
}
const base =
props.preview && typeof props.preview === 'object' ? props.preview : {};
return { src: source, ...(base as Record<string, unknown>) };
});
function loadImage() {
if (shouldLoad.value) return;
shouldLoad.value = true;
if (observer) {
observer.disconnect();
observer = null;
}
}
onMounted(() => {
const el = wrapperRef.value;
if (!el) {
shouldLoad.value = true;
return;
}
if (typeof IntersectionObserver === 'undefined') {
shouldLoad.value = true;
return;
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadImage();
}
},
{ rootMargin: '120px 0px' },
);
observer.observe(el);
});
onBeforeUnmount(() => {
if (observer) {
observer.disconnect();
observer = null;
}
});
</script>
<template>
<div
ref="wrapperRef"
:class="attrs.class"
:style="[wrapperStyle, attrs.style]"
>
<Image
v-if="shouldLoad"
v-bind="imageAttrs"
:src="src"
:preview="resolvedPreview"
:alt="alt"
:width="width"
:height="height"
/>
<div v-else class="lazy-image-placeholder" />
</div>
</template>
<style scoped>
.lazy-image-placeholder {
width: 100%;
height: 100%;
background: #f5f5f5;
}
</style>