博客 / 詳情

返回

上傳圖片時交互來回閃現的情況優化

<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重建

優化數據更新策略,避免不必要的重渲染

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.