关于h5使用van-uploader上传图片压缩不兼容app壳子的webview问题解决方案

15 阅读1分钟

需求背景: h5内嵌app壳子的时候上传文件压缩的时候格式问题

文件上传 formData 格式 采用的是二进制, 部分手机webview 不支持 new File 构造函数, 所以这里可以采用blob格式 废话不多说, 直接见代码

下边js文件为封装的公共方法

/**
 * 格式化文件大小
 * @param {number} bytes - 字节数
 * @returns {string} 格式化后的大小
 */
const formatFileSize = bytes => {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}

/**
 * 打印压缩结果对比
 */
const printCompressResult = (
  fileName,
  beforeSize,
  beforeSizeFormatted,
  originalWidth,
  originalHeight,
  afterSize,
  afterSizeFormatted,
  compressedWidth,
  compressedHeight,
  quality
) => {
  console.log(`压缩前:${fileName}`)
  console.log(`   尺寸: ${originalWidth} × ${originalHeight} px`)
  console.log(`   大小: ${beforeSizeFormatted} (${beforeSize} 字节)`)
  console.log('')
  console.log(`压缩后:${fileName}`)
  console.log(`   尺寸: ${parseInt(compressedWidth)} × ${parseInt(compressedHeight)} px`)
  console.log(`   大小: ${afterSizeFormatted} (${afterSize} 字节)`)
  console.log(`   质量: ${(quality * 100).toFixed(0)}%`)
}

/**
 * 获取图片的 EXIF 方向信息(移动端图片旋转问题)
 * @param {File} file - 图片文件
 * @returns {Promise<number>} 方向值
 */
const getImageOrientation = file => {
  return new Promise(resolve => {
    const reader = new FileReader()
    reader.onload = e => {
      const view = new DataView(e.target.result)
      if (view.getUint16(0, false) !== 0xffd8) {
        resolve(-1)
        return
      }
      const length = view.byteLength
      let offset = 2
      while (offset < length) {
        if (view.getUint16(offset, false) === 0xffe1) {
          const marker = view.getUint16(offset + 2, false)
          if (marker !== 0xe1ff) {
            offset += 2
            continue
          }
          if (view.getUint32(offset + 4, false) !== 0x45786966) {
            offset += 2
            continue
          }
          const little = view.getUint16(offset + 10, false) === 0x4949
          const tags = view.getUint16(offset + 18, little)
          for (let i = 0; i < tags; i++) {
            const tag = view.getUint16(offset + 20 + i * 12, little)
            if (tag === 0x0112) {
              resolve(view.getUint16(offset + 22 + i * 12, little))
              return
            }
          }
        }
        offset += 2
      }
      resolve(-1)
    }
    reader.readAsArrayBuffer(file.slice(0, 64 * 1024))
  })
}

/**
 * 根据 EXIF 方向旋转 canvas
 * @param {HTMLCanvasElement} canvas - canvas 元素
 * @param {number} orientation - EXIF 方向值
 */
const applyImageOrientation = (canvas, orientation) => {
  const ctx = canvas.getContext('2d')
  const width = canvas.width
  const height = canvas.height

  switch (orientation) {
    case 2:
      // 水平翻转
      ctx.translate(width, 0)
      ctx.scale(-1, 1)
      break
    case 3:
      // 旋转 180 度
      ctx.translate(width, height)
      ctx.rotate(Math.PI)
      break
    case 4:
      // 垂直翻转
      ctx.translate(0, height)
      ctx.scale(1, -1)
      break
    case 5:
      // 顺时针 90 度 + 水平翻转
      ctx.translate(height, 0)
      ctx.rotate(Math.PI / 2)
      ctx.scale(-1, 1)
      break
    case 6:
      // 顺时针 90 度
      ctx.translate(height, 0)
      ctx.rotate(Math.PI / 2)
      break
    case 7:
      // 逆时针 90 度 + 水平翻转
      ctx.translate(0, width)
      ctx.rotate(-Math.PI / 2)
      ctx.scale(-1, 1)
      break
    case 8:
      // 逆时针 90 度
      ctx.translate(0, width)
      ctx.rotate(-Math.PI / 2)
      break
    default:
      break
  }
}

/**
 * 压缩图片
 * @param {File} file - 原始图片文件
 * @param {Object} options - 压缩选项
 * @param {number} options.maxWidth - 最大宽度,默认 1920
 * @param {number} options.maxHeight - 最大高度,默认 1920
 * @param {number} options.quality - 压缩质量 0-1,默认 0.8
 * @param {number} options.maxSize - 最大文件大小(字节),默认 2MB
 * @returns {Promise<File>} 压缩后的文件
 */

const compress = async (file, options = {}) => {
  const {
    maxWidth = 1920,
    maxHeight = 1920,
    quality = 0.8,
    maxSize = 1 * 1024 * 1024 // 2MB
  } = options

  // 记录压缩前信息
  const beforeSize = file.size
  const beforeSizeFormatted = formatFileSize(beforeSize)

  return new Promise((resolve, reject) => {
    // 如果文件已经是小文件,直接返回
    if (file.size <= maxSize) {
      resolve(file)
      return
    }

    const reader = new FileReader()
    reader.onload = async e => {
      try {
        const img = new Image()
        img.onload = async () => {
          try {
            // 记录原始尺寸
            const originalWidth = img.width
            const originalHeight = img.height

            // 获取 EXIF 方向信息(移动端)
            const orientation = await getImageOrientation(file)

            // 计算压缩后的尺寸
            let { width, height } = img
            const needResize = width > maxWidth || height > maxHeight
            if (needResize) {
              const ratio = Math.min(maxWidth / width, maxHeight / height)
              width = width * ratio
              height = height * ratio
            }

            // 创建 canvas
            const canvas = document.createElement('canvas')
            const ctx = canvas.getContext('2d')

            // 根据方向调整 canvas 尺寸
            if (orientation > 4) {
              canvas.width = height
              canvas.height = width
            } else {
              canvas.width = width
              canvas.height = height
            }

            // 应用方向旋转
            applyImageOrientation(canvas, orientation)

            // 绘制图片
            ctx.drawImage(img, 0, 0, width, height)

            // 转换为 blob
            canvas.toBlob(
              blob => {
                if (!blob) {
                  reject(new Error('图片压缩失败'))
                  return
                }

                // 如果压缩后仍然大于目标大小,继续降低质量压缩
                if (blob.size > maxSize && quality > 0.1) {
                  const newQuality = Math.max(0.1, quality - 0.1)
                  canvas.toBlob(
                    newBlob => {
                      if (!newBlob) {
                        reject(new Error('图片压缩失败'))
                        return
                      }

                      printCompressResult(
                        file.name,
                        beforeSize,
                        beforeSizeFormatted,
                        originalWidth,
                        originalHeight,
                        newBlob.size,
                        formatFileSize(newBlob.size),
                        width,
                        height,
                        newQuality
                      )
                      resolve(newBlob)
                    },
                    'image/jpeg',
                    newQuality
                  )
                } else {
                  printCompressResult(
                    file.name,
                    beforeSize,
                    beforeSizeFormatted,
                    originalWidth,
                    originalHeight,
                    blob.size,
                    formatFileSize(blob.size),
                    width,
                    height,
                    quality
                  )
                  resolve(blob)
                }
              },
              'image/jpeg',
              quality
            )
          } catch (error) {
            console.error('图片压缩处理失败:', error)
            // 压缩失败时返回原文件
            resolve(file)
          }
        }
        img.onerror = () => {
          reject(new Error('图片加载失败'))
        }
        img.src = e.target.result
      } catch (error) {
        console.error('图片读取失败:', error)
        reject(error)
      }
    }
    reader.onerror = () => {
      reject(new Error('文件读取失败'))
    }
    reader.readAsDataURL(file)
  })
}

export default compress

文件上传的地方

async afterRead(file) {
  const formData = new FormData()
  const filesToUpload = Array.isArray(file) ? file : [file]
  // 先设置上传状态,确保 UI 立即更新
  filesToUpload.forEach(fileItem => {
    fileItem.status = 'uploading'
  })
  // 使用 $nextTick 确保状态更新到 UI
  await this.$nextTick()
  // 压缩所有图片文件
  const compressedFiles = await Promise.all(
    filesToUpload.map(async fileItem => {
      // 只压缩图片文件
      if (fileItem.file && fileItem.file.type.startsWith('image/')) {
        try {
          const compressedFile = await compress(fileItem.file)
          return {
            ...fileItem,
            file: compressedFile,
            name: fileItem.file.name // 保留文件名
          }
        } catch (error) {
          console.error('图片压缩失败,使用原文件:', error)
          return fileItem
        }
      }
      return fileItem
    })
  )
  // 将压缩后的文件添加到 FormData
  compressedFiles.forEach(fileItem => {
    formData.append('file', fileItem.file, fileItem.name)
  })

  this.$response(
    uploadApi(formData),
    null,
    data => {
      if (Array.isArray(file)) {
        file.forEach(fileItem => {
          fileItem.status = 'done'
        })
      } else {
        file.status = 'done'
      }
    },
    () => {
      // 上传失败处理
      if (Array.isArray(file)) {
        file.forEach(() => {
          this.fileList?.pop()
        })
      } else {
        this.fileList?.pop()
      }
    }
  )
},