<template>
<div class="car-upload">
<el-upload
ref="upload"
action="#"
:file-list="internalFileList"
:before-upload="beforeUpload"
:http-request="handleUpload"
:on-preview="handlePreview"
:multiple="false"
list-type="picture-card"
:on-remove="handleRemove"
:class="{ 'hide-upload': hideUpload }"
:disabled="uploading"
>
<template v-if="uploading">
<div class="uploading-mask">
<div class="custom-loading-icon" />
<span class="upload-text">{{ $t('common.uploading') }}</span>
</div>
</template>
<template v-else>
<i class="el-icon-plus" />
</template>
<div slot="tip" class="tip">
{{ $t('externalModel.uploadimgTip') }}
</div>
</el-upload>
<PreviewModal :visible.sync="previewVisible" :image-url="previewImageUrl" />
</div>
</template>
<script>
import { getToken } from '@/utils/auth'
import PreviewModal from './PreviewModal.vue'
import { imagesUpload } from '@/api/type/external'
export default {
name: 'CarImageUpload',
components: {
PreviewModal
},
props: {
value: {
type: [String, Array],
default: () => []
},
maxSize: { type: Number, default: 2 },
accept: {
type: Array,
default: () => ['image/bmp', 'image/png', 'image/jpeg', 'image/gif']
},
uploadUrl: { type: String, required: false, default: '' },
fixedSize: { type: Object, required: false, default: null },
limit: {
type: Number,
default: 1
}
},
data() {
return {
internalFileList: [],
previewUrl: '',
headers: { Authorization: 'Bearer ' + getToken() },
previewVisible: false,
previewImageUrl: '',
uploading: false,
currentFile: null,
// 添加緩存用於存儲原始文件的UID
fileUidMap: new Map()
}
},
computed: {
hideUpload() {
return this.internalFileList.length >= this.limit || this.uploading
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
// 優化:避免不必要的重渲染,只有在真正變化時更新
const currentUrls = this.internalFileList.map(item => item.url).filter(Boolean)
const newUrls = Array.isArray(newVal) ? newVal : (newVal ? [newVal] : [])
if (JSON.stringify(currentUrls) !== JSON.stringify(newUrls)) {
this.syncFileList(newVal)
}
}
}
},
methods: {
// 優化文件列表同步方法
syncFileList(newVal) {
if (Array.isArray(newVal)) {
this.internalFileList = newVal.map((url, index) => ({
url,
status: 'success',
// 保持UID一致性,避免重新生成
uid: this.fileUidMap.get(url) || this.generateUid(url, index)
}))
} else if (newVal) {
this.internalFileList = [{
url: newVal,
status: 'success',
uid: this.fileUidMap.get(newVal) || this.generateUid(newVal, 0)
}]
} else {
this.internalFileList = []
}
},
// 生成基於URL的穩定UID
generateUid(url, index) {
// 基於URL生成穩定UID,避免每次重新生成不同的UID
const uid = `file_${btoa(url).substr(0, 10)}_${index}`
this.fileUidMap.set(url, uid)
return uid
},
async beforeUpload(file) {
if (this.uploading) {
this.$message.warning('正在上傳中,請稍候...')
return false
}
const isImage = this.accept.includes(file.type)
const isLt10M = file.size / 1024 / 1024 < 10
if (!isImage) {
this.$message.error(this.$t('externalModel.uploadimgTip'))
return false
}
if (!isLt10M) {
this.$message.error(this.$t('externalModel.uploadimgTip'))
return false
}
if (this.fixedSize || this.aspectRatio) {
const isDimensionValid = await this.validateImageSize(file)
if (!isDimensionValid) {
const msg = this.fixedSize
? `圖片尺寸必須為 ${this.fixedSize.width}×${this.fixedSize.height}`
: `寬高比需為 ${this.aspectRatio}:1`
this.$message.error(msg)
return false
}
}
this.currentFile = file
return true
},
// 優化上傳方法,避免閃動[1,2](@ref)
async handleUpload({ file }) {
this.uploading = true
// 保存原始文件的UID[2](@ref)
const originalUid = file.uid
try {
const formData = new FormData()
formData.append('file', this.currentFile || file)
const res = await imagesUpload(formData)
if (res.code === '000000') {
const url = res.body.url
// 優化:直接更新現有文件對象,而不是創建新對象[5](@ref)
const existingFileIndex = this.internalFileList.findIndex(f => f.uid === originalUid)
if (existingFileIndex > -1) {
// 保持UID不變,只更新URL[2](@ref)
this.internalFileList[existingFileIndex].url = url
this.internalFileList[existingFileIndex].status = 'success'
} else {
// 如果找不到現有文件,添加新文件但保持UID
this.internalFileList.push({
url,
status: 'success',
uid: originalUid
})
}
this.updateModelValue(url)
this.$message.success('上傳成功')
} else {
throw new Error(res.message || '上傳失敗')
}
} catch (error) {
console.error('上傳失敗:', error)
this.$message.error('上傳失敗,請重試')
// 上傳失敗時更新狀態而不是移除文件[1](@ref)
const failedFileIndex = this.internalFileList.findIndex(f => f.uid === originalUid)
if (failedFileIndex > -1) {
this.internalFileList[failedFileIndex].status = 'failed'
}
} finally {
this.uploading = false
this.currentFile = null
}
},
validateImageSize(file) {
return new Promise((resolve) => {
const img = new Image()
img.src = URL.createObjectURL(file)
img.onload = () => {
let isValid = true
if (this.fixedSize) {
isValid = img.width === this.fixedSize.width && img.height === this.fixedSize.height
} else if (this.aspectRatio) {
const ratio = (img.width / img.height).toFixed(2)
isValid = ratio === this.aspectRatio.toFixed(2)
}
URL.revokeObjectURL(img.src)
resolve(isValid)
}
img.onerror = () => resolve(false)
})
},
// 優化模型值更新[5](@ref)
updateModelValue(newUrl) {
// 避免直接賦值導致的重新渲染
this.$nextTick(() => {
const currentValue = this.value
let newValue
if (this.limit > 1) {
const currentUrls = Array.isArray(currentValue) ? currentValue : []
// 去重並過濾空值
newValue = [...new Set([...currentUrls, newUrl].filter(url => url))]
} else {
newValue = newUrl
}
// 只有值真正改變時才觸發更新
if (JSON.stringify(currentValue) !== JSON.stringify(newValue)) {
this.$emit('input', newValue)
}
})
},
handleRemove(file) {
// 從UID映射中移除
this.fileUidMap.delete(file.url)
const newList = this.internalFileList.filter(f => f.uid !== file.uid)
const newValue = this.limit > 1 ? newList.map(f => f.url) : ''
this.$emit('input', newValue)
},
handlePreview(file) {
// 添加時間戳防止緩存問題[4](@ref)
const timestamp = new Date().getTime()
this.previewImageUrl = file.url + (file.url.includes('?') ? '&' : '?') + `t=${timestamp}`
this.previewVisible = true
}
}
}
</script>
<style lang="scss" scoped>
.car-upload {
height: 200px;
position: relative;
}
.hide-upload {
::v-deep .el-upload--picture-card {
display: none;
/* 添加過渡效果減少視覺突兀感 */
transition: opacity 0.3s ease;
}
}
.tip {
font-size: 12px;
color: #909399;
padding-top: 8px;
}
.uploading-mask {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #409EFF;
}
.upload-text {
margin-top: 8px;
font-size: 12px;
}
.custom-loading-icon {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #409EFF;
border-radius: 50%;
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 添加文件列表項過渡效果 */
::v-deep .el-upload-list__item {
transition: all 0.3s ease;
}
/* 確保圖片加載平滑 */
::v-deep .el-upload-list__item-thumbnail {
object-fit: contain;
transition: opacity 0.3s ease;
}
</style>
上傳組件的閃動問題是由於雙階段渲染導致的,選擇文件後先用本地Blob URL預覽,上傳完成後再用服務器URL替換,這個替換過程會導致重新加載和閃動。文中建議保持使用本地預覽不替換URL,或者優化上傳流程。都指出Element UI中el-upload組件的閃動問題與uid變化有關,上傳成功後如果file-list被重新賦值且uid發生變化,就會導致組件重新渲染和圖片閃爍。解決方案是在回調中保持uid不變。提到避免使用computed或watch監聽fileList,這可能導致不必要的重新渲染。
UID不一致
文件上傳後新對象的UID與原始文件不同,導致組件重新渲染
保持UID一致性,避免重新創建文件對象
URL替換閃動
從本地Blob URL切換到服務器HTTP URL時瀏覽器重新加載
優化URL替換時機或使用統一URL格式
文件列表重建
使用watch監聽value導致整個internalFileList重建
優化數據更新策略,避免不必要的重渲染