一.效果展示
1.展开折叠项,加载视图中图片,

2.随着滚动条移动,逐渐加载对应图片

二.处理前的效果图。
1.问题截图
这就是部分大图加载不出来的截图,就是因为一下加载整个表格的所有图片,一个是卡,第二个最主要的是加载不出来,但是点击单个图片预览却可以,这也是我做这个懒加载的原因。
三.解决方案。
1.页面模版展示图片部分
javascript
<div>
<vxe-grid
:ref="(el) => (gridRefs[tableIndex] = el)"
v-bind="gridOptions2"
v-on="gridEvents"
border="none"
:stripe="true"
maxHeight="300px"
:data="item.checkItemDetail"
>
<template #referenceImageUrl_content="{ row }">
<div
v-if="
props.type === 'view' ||
formData.documentStatus?.includes('审批') ||
formData.documentStatus === '已完成' ||
tableIndex < secondTable.length - 1
"
>
<template v-if="row.pictureList.length">
<elImageDialog :pictureList="row.pictureList" />
</template>
<img v-else class="custom-image" src="../../../../assets/imgs/暂无图片.png"/>
</div>
<div v-else>
<div v-visible="(v) => (row._imgVisible = v)" style="min-height: 110px">
<UploadMoreImage
v-if="row._imgVisible"
:limit="3"
:fileList="row.pictureList"
@update:fileList="(val) => (row.pictureList = val)"
@view="handlePreview"
@fileStatusChange="(status) => handleFileStatusChange(status, row)"
/>
</div>
</div>
</template>
</vxe-grid>
</div>
</div>
<!-- 图片预览弹窗 -->
<el-dialog v-model="dialogVisible" width="60%">
<img w-full :src="dialogImageUrl" alt="Preview Image" style="width: 100%" />
</el-dialog>
//引入v-visible
import { vVisible } from '@/directive/visible'
// 预览图片
const handlePreview = (file) => {
dialogImageUrl.value = file.url // 设置预览的图片 URL
dialogVisible.value = true // 显示预览弹窗
}
注释:大家只需看<template #referenceImageUrl_content="{ row }">... </template>里面这部分
2.v-visible实现
directive/visible.ts文件
javascript
import type { Directive } from 'vue'
const observers = new WeakMap<Element, IntersectionObserver>()
export const vVisible: Directive<HTMLElement, (v: boolean) => void> = {
mounted(el, binding) {
createObserver(el, binding.value)
},
updated(el, binding) {
// 当绑定函数变化 or 重新渲染时,重新监听
if (binding.value !== binding.oldValue) {
cleanup(el)
createObserver(el, binding.value)
}
},
unmounted(el) {
cleanup(el)
}
}
function createObserver(el: HTMLElement, callback: (v: boolean) => void) {
if (typeof callback !== 'function') return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
callback(true)
observer.disconnect()
}
},
{
rootMargin: '150px'
}
)
observers.set(el, observer)
observer.observe(el)
}
function cleanup(el: Element) {
const observer = observers.get(el)
if (observer) {
observer.disconnect()
observers.delete(el)
}
}
3.UploadMoreImage上传组件
javascript
<template>
<el-upload
ref="upload"
class="custom-upload"
:action="uploadUrl"
v-model:file-list="fileList"
:limit="props.limit"
list-type="picture-card"
:on-preview="handlePreview"
:on-remove="handleRemove"
:auto-upload="false"
:show-file-list="true"
multiple
:on-change="handleChange"
:on-exceed="handleExceed"
>
<el-icon><Plus /></el-icon>
</el-upload>
</template>
<script lang="ts" setup>
import { ref, computed, defineProps, watch } from 'vue'
import { ElUpload, ElMessage, ElIcon, genFileId } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { UploadInstance, UploadProps, UploadRawFile } from 'element-plus'
import { compressImage } from '@/utils/compressImage'
// Emit事件
const $emit = defineEmits(['view', 'update:fileList', 'fileStatusChange'])
// Props
const props = defineProps({
limit: {
type: Number,
default: 1 // 默认限制为1张图片
},
fileList: {
type: Array,
default: () => []
}
})
const upload = ref<UploadInstance>()
const fileList = ref(props.fileList) // 图片文件列表
const uploadUrl = 'https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15' // 上传接口
// 预览图片
const handlePreview = (file) => {
$emit('view', file)
}
// 删除图片
const handleRemove = (file) => {
$emit('fileStatusChange', true)
const index = fileList.value.indexOf(file)
if (index > -1) {
fileList.value.splice(index, 1) // 删除选中的图片
}
$emit('update:fileList', fileList.value)
}
// 当超出限制数量时,清空已上传的图片,重新上传
const handleExceed: UploadProps['onExceed'] = (files) => {
// 判断当前上传的文件数量,超过限制的文件进行处理
const excessFiles = files.slice(0, files.length - props.limit)
// 校验上传的文件
files.forEach((newFile, index) => {
// 只处理没有超过限制的文件
if (index >= excessFiles.length) {
if (!newFile.type.startsWith('image/') || newFile.size / 1024 / 1024 > 20) {
ElMessage.error('请上传20MB以内的图片格式!')
return
}
const raw = newFile as UploadRawFile
raw.uid = genFileId()
// 构造可展示的文件对象
const uploadFile = {
name: raw.name,
url: URL.createObjectURL(raw),
raw
}
// 仅在未超出限制时添加文件
// if (fileList.value.length < props.limit) {
// fileList.value.push(uploadFile)
// } else {
// // 如果超过限制,替换掉最后一张
// fileList.value.splice(fileList.value.length - 1, 1, uploadFile)
// }
if (fileList.value.length < props.limit) {
// 仅在未超出限制时添加文件
fileList.value.push(uploadFile)
} else {
// 超过限制时删除上传的文件并提示图片最大上传数量3张
ElMessage.error(`图片最大上传数量${props.limit}张!`)
return // 直接退出,避免继续添加文件
}
}
})
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (bytes === 0 || bytes == null) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const size = (bytes / Math.pow(k, i)).toFixed(2)
return `${size} ${units[i]}`
}
const handleChange = async (file, flist) => {
const rawFile = file.raw as File
const sizeMB = rawFile.size / 1024 / 1024
const isImg = rawFile.type.startsWith('image/')
console.log("当前图片大小", formatFileSize(rawFile?.size))
if (!isImg) {
ElMessage.error('请上传图片格式!')
const index = fileList.value.length - 1
fileList.value.splice(index, 1)
return
}
if (sizeMB > 20) {
ElMessage.error('请上传 20MB 以内的图片格式!')
const index = fileList.value.length - 1
fileList.value.splice(index, 1)
return
}
if (sizeMB > 5) {
try {
const compressedFile = await compressImage(rawFile, 1280, 0.8)
file.raw = compressedFile
file.size = compressedFile.size
file.url = URL.createObjectURL(compressedFile)
} catch (e) {
ElMessage.error('图片压缩失败')
return
}
}
if (flist.length > props.limit) return
$emit('update:fileList', flist)
$emit('fileStatusChange', true)
}
</script>
<style lang="less" scoped>
.custom-upload {
position: relative;
}
::v-deep(.el-upload--picture-card) {
--el-upload-picture-card-size: 103px;
}
::v-deep(.el-upload-list--picture-card) {
--el-upload-list-picture-card-size: 103px;
}
::v-deep(.el-icon--close-tip) {
display: none !important;
}
</style>
注释:图片压缩
import { compressImage } from '@/utils/compressImage'
if (sizeMB > 5) {
try {
const compressedFile = await compressImage(rawFile, 1280, 0.8)
file.raw = compressedFile
file.size = compressedFile.size
file.url = URL.createObjectURL(compressedFile)
} catch (e) {
ElMessage.error('图片压缩失败')
return
}
}
压缩前后大小截图:直接从19.77MB压缩到6.92 MB
4.elImageDialog组件
javascript
<template>
<div>
<!-- 图片展示区域 -->
<div class="image-gallery">
<img
v-for="(img, index) in props.pictureList"
:key="index"
v-lazy="img.url"
:class="props.isWidth ? 'thumbnail2' : 'thumbnail'"
@click="handlePreview(index)"
/>
</div>
<!-- 预览图片区域 -->
<div v-if="showPreview" class="preview-overlay" @click="closePreview">
<img :src="props.pictureList[currentPreviewIndex].url" class="preview-image" />
</div>
</div>
</template>
<script setup>
import { ref, defineProps } from 'vue';
// 接收外部传入的图片数据
const props = defineProps({
pictureList: {
type: Array,
required: true
},
isWidth:{
type: Boolean,
default:false
}
});
// 当前预览图片的索引
const currentPreviewIndex = ref(0);
// 是否显示预览图片
const showPreview = ref(false);
// 处理点击图片时,打开预览
const handlePreview = (index) => {
currentPreviewIndex.value = index;
showPreview.value = true;
};
// 关闭预览
const closePreview = () => {
showPreview.value = false;
};
</script>
<style lang="less" scoped>
.image-gallery {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.thumbnail {
width: 100px;
height: 100px;
object-fit: cover;
cursor: pointer;
transition: transform 0.3s ease;
}
.thumbnail2 {
width: 70px;
height: 70px;
object-fit: cover;
cursor: pointer;
transition: transform 0.3s ease;
}
.thumbnail:hover {
transform: scale(1.1);
}
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
cursor: pointer;
}
.preview-image {
max-width: 90%;
max-height: 90%;
}
</style>
5.v-lazy实现
directive/lazy.ts文件
javascript
import type { Directive } from 'vue'
const observers = new WeakMap<Element, IntersectionObserver>()
export const vLazy: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
initObserver(el, binding.value)
},
updated(el, binding) {
// 👇 关键:图片地址变了
if (binding.value !== binding.oldValue) {
// 重置 src,避免复用旧图
el.src = ''
// 重新监听
observers.get(el)?.disconnect()
initObserver(el, binding.value)
}
},
/**
* 清理元素关联的MutationObserver并移除观察者引用
* @param {HTMLElement} el - 需要清理观察者的DOM元素
*/
unmounted(el) {
observers.get(el)?.disconnect()
observers.delete(el)
}
}
function initObserver(el: HTMLImageElement, src: string) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = src
observer.unobserve(el)
}
})
observers.set(el, observer)
observer.observe(el)
}
6.全局注册v-lazy
import { createApp } from 'vue'
import App from './App.vue'
import { vLazy } from '@/directive/lazy'
const app = createApp(App)
app.directive('lazy', vLazy)
app.mount('#app')
7.图片压缩组件
utils/compressImage.ts
javascript
/**
* 图片压缩
* @param file 原始图片 File
* @param maxWidth 最大宽度
* @param quality 压缩质量 0~1
*/
export function compressImage(
file: File,
maxWidth = 1280,
quality = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.src = e.target?.result as string
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
let { width, height } = img
// 等比缩放
if (width > maxWidth) {
height = (maxWidth / width) * height
width = maxWidth
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (!blob) return reject('压缩失败')
const compressedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
})
resolve(compressedFile)
},
file.type,
quality
)
}
img.onerror = reject
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
