uni-app 图片压缩上传实战:H5、小程序、App 三端统一封装

2026-09-17 0 521

只要项目里带图片上传,早晚会撞上同一个问题:用户从相册选的图,随手就是 3MB 起步,iPhone 拍的甚至能到 8MB。直接扔给 uni.uploadFile,小程序端卡在域名白名单和体积限制上,H5 端用户流量哗哗掉,App 端上传进度条能走半分钟。

更麻烦的是,三端压缩图片的路子完全不一样。H5 没有现成 API,得手搓 canvas;微信小程序有 wx.compressImage,但坑不少;App 端又能用 uni.compressImage。你很难在同一份代码里把这三套缝起来。

这篇文章就把这套东西从零封一遍。最终你会得到一个 compressImage()uploadImage() 的组合,调用方不关心跑在哪个端上,只传文件和一个配置对象。

一、先搞清楚三端差在哪

动手之前先把差异列清楚,不然后面会反复返工。

  • 选图返回的东西不一样。H5 端 uni.chooseImage 返回的 tempFiles 是真正的 File 对象数组,带 namesizetype;小程序和 App 端返回的是 { path, size },只有一个临时路径字符串。
  • 压缩能力不一样。H5 完全没有原生压缩 API,只能用 canvas 自己画;微信小程序用 wx.compressImage;App 端用 uni.compressImage。后两者虽然名字像,参数单位却不同。
  • 上传时吃的参数不一样。H5 端 uni.uploadFilefilePath 可以传 FileBlob,但很多人直接用 fetch 更省事;小程序和 App 端必须传临时文件路径。
  • 质量参数的单位不一样。这是最容易踩的一个坑,下面专门说。

所以统一封装的核心思路就一句话:每个端各自走一条实现路径,出口统一成同样形状的返回值。调用方拿到的永远是一个可以直接丢给上传函数的 source,以及压缩前后的体积信息。

二、统一出口长什么样

先定义返回结构,后面所有实现都往这个形状上靠:

{
  source: File | string,   // H5 是 File 对象,其他端是临时路径
  size: number,            // 压缩后的字节数,拿不到就填 0
  compressed: boolean,     // 是否真的执行了压缩
  originalSize: number     // 原始字节数,方便页面显示"节省了多少"
}

调用的时候只关心 sourcecompressedsize 用来在 UI 上给用户一个反馈,体验会好很多。

三、H5 端:没有 API,只能自己画

H5 端的思路很直接:把图片读进来、画到 canvas 上、按比例缩放、再导出成 Blob。

function compressByCanvas(file, opts) {
  return new Promise((resolve, reject) => {
    // 小图不值得压,压完可能反而更大
    if (file.size <= opts.minSize) {
      return resolve({
        source: file,
        size: file.size,
        originalSize: file.size,
        compressed: false
      })
    }

    const url = URL.createObjectURL(file)
    const img = new Image()

    img.onload = () => {
      const scale = Math.min(
        1,
        opts.maxWidth / img.naturalWidth,
        opts.maxHeight / img.naturalHeight
      )
      const w = Math.round(img.naturalWidth * scale)
      const h = Math.round(img.naturalHeight * scale)

      const canvas = document.createElement('canvas')
      canvas.width = w
      canvas.height = h

      const ctx = canvas.getContext('2d')
      ctx.drawImage(img, 0, 0, w, h)

      canvas.toBlob((blob) => {
        URL.revokeObjectURL(url)

        if (!blob) {
          return reject(new Error('canvas 导出失败'))
        }

        // 压完比原图还大就直接用原图
        if (blob.size >= file.size) {
          return resolve({
            source: file,
            size: file.size,
            originalSize: file.size,
            compressed: false
          })
        }

        const out = new File(
          [blob],
          file.name || `image_${Date.now()}.jpg`,
          { type: blob.type || 'image/jpeg' }
        )

        resolve({
          source: out,
          size: out.size,
          originalSize: file.size,
          compressed: true
        })
      }, 'image/jpeg', opts.quality)
    }

    img.onerror = () => {
      URL.revokeObjectURL(url)
      reject(new Error('图片解码失败'))
    }

    img.src = url
  })
}

几个细节值得单独说。

小图不压。minSize 默认给 200KB。一张 80KB 的截图走一遍 canvas,质量下降但体积可能涨到 120KB,纯亏。这个判断能挡掉很大一部分没必要的处理。

压完反而变大就不压。和上面同理,但要单独判断,因为大图也可能出现这种情况——比如一张已经压过的高质量 JPEG。

文件名要保留。构造 File 的时候带上原名,服务端很多框架靠后缀判断类型,丢了会出问题。

还有 EXIF 方向的问题。老版本 iOS Safari 拍照后图片会带一个 orientation 标记,canvas 绘制时如果不处理,出来的图是躺着的。现代浏览器基本都在 img.decode 阶段自动旋转了,但如果你恰好要兼容老设备,可以把 Image 换成 createImageBitmap(blob, { imageOrientation: 'from-image' }),这样方向问题直接交给浏览器处理。

四、小程序端:uni.compressImage 背后的坑

微信小程序上,uni.compressImage 底层就是 wx.compressImage。看起来简单,实际有三个地方会咬人。

第一个是 quality 的单位。H5 的 canvas.toBlob 第三参数是 0~1 的浮点数,而 uni.compressImagequality0~100 的整数。同一份配置对象在两处直接传,效果能差出十倍。统一封装里,我把配置对象的 quality 定成 0~1,各端实现时自己转换,调用方永远不用记这件事。

第二个是格式限制。compressImage 的质量参数只对 jpg 生效。PNG 传进去不会报错,但压缩基本无效,返回的还是原来那张图。如果业务里用户经常传 PNG 截图,得在服务端或者上传前做格式转换,光靠压缩 API 解决不了。

第三个是尺寸参数要基础库版本。compressedWidthcompressedHeight 从微信基础库 2.26.0 才开始支持。低于这个版本的设备上这两个参数会被静默忽略,结果就是只压了质量没缩尺寸,一张 4000px 宽的照片压完还是有 1MB 多。稳妥的做法是在项目里设个最低基础库版本,或者干脆在 H5 层做尺寸兜底。

function compressByWeapp(path, opts) {
  return new Promise((resolve) => {
    uni.compressImage({
      src: path,
      quality: Math.round(opts.quality * 100),
      compressedWidth: opts.maxWidth,
      success: (res) => {
        resolve({
          source: res.tempFilePath,
          size: 0,          // 小程序端拿不到压缩后的字节数
          originalSize: 0,
          compressed: true
        })
      },
      fail: () => {
        // 压缩失败不要卡住流程,退回原图继续上传
        resolve({
          source: path,
          size: 0,
          originalSize: 0,
          compressed: false
        })
      }
    })
  })
}

注意这里 fail 的处理。压缩失败直接 reject 会让整个上传流程断掉,但用户其实并不在乎这张图压没压,他只在乎能不能传上去。所以这里的策略是失败就退回原图,让后面的上传逻辑自己决定怎么办。

小程序端拿不到压缩后的文件体积,size 只能填 0。如果 UI 上要显示”已压缩 68%”,那只能在小程序端换成显示别的信息,比如”图片已优化”。这点得提前和产品对齐,别到联调才发现。

五、App 端:路径和尺寸都要照顾

App 端用的是 uni.compressImage,和微信小程序名字一样,但参数集不同:它用的是 widthheight,而不是 compressedWidth

function compressByApp(path, opts) {
  return new Promise((resolve) => {
    // 少数 Android 机型返回的是 content:// 或 _doc/ 开头的路径
    // 先转成绝对路径再交给压缩 API,不然会直接 fail
    let src = path
    if (plus && plus.io) {
      try {
        const abs = plus.io.convertLocalFileSystemURL(path)
        if (abs) src = abs
      } catch (e) {
        // 转换失败就用原路径试一把
      }
    }

    uni.compressImage({
      src,
      quality: Math.round(opts.quality * 100),
      width: opts.maxWidth,
      height: opts.maxHeight,
      success: (res) => {
        resolve({
          source: res.tempFilePath,
          size: 0,
          originalSize: 0,
          compressed: true
        })
      },
      fail: () => {
        resolve({
          source: path,
          size: 0,
          originalSize: 0,
          compressed: false
        })
      }
    })
  })
}

这里有个容易忽略的点:widthheight 在 App 端如果不给,只压质量不缩尺寸,效果会大打折扣。但如果两个都给死值,某些机型上会被强行拉伸成正方形。所以配置里的 maxWidthmaxHeight 最好设成一样的值(比如都是 1280),让内部的等比逻辑自己处理,别自己先算一遍比例再传进去。

六、用条件编译把三端缝起来

有了三个端各自的实现,剩下的事就是分发。uni-app 的条件编译在这里非常好用,因为它是编译期就把无关代码删掉的,不会把 H5 的 canvas 代码打进小程序包里。

const DEFAULTS = {
  maxWidth: 1280,
  maxHeight: 1280,
  quality: 0.82,
  minSize: 200 * 1024
}

export function compressImage(source, options = {}) {
  const opts = Object.assign({}, DEFAULTS, options)
  return new Promise((resolve, reject) => {

    // #ifdef H5
    compressByCanvas(source, opts).then(resolve).catch(reject)
    // #endif

    // #ifdef MP-WEIXIN
    compressByWeapp(source, opts).then(resolve).catch(reject)
    // #endif

    // #ifdef APP-PLUS
    compressByApp(source, opts).then(resolve).catch(reject)
    // #endif

    // #ifdef MP-ALIPAY || MP-TOUTIAO
    // 这两个平台也有自己的压缩 API,按同样套路补实现
    resolve({
      source,
      size: 0,
      originalSize: 0,
      compressed: false
    })
    // #endif
  })
}

写条件编译的时候有个习惯值得养成:每个平台块里都要有一条”兜底出口”。上面支付宝和抖音的那段就是干这个的——即使暂时不实现压缩,也要把 Promise 正常 resolve 掉,否则在对应平台上这个 Promise 会永远挂起,页面就卡死了。

另外补一句,#ifdef 这种注释必须放在函数体顶层或者模块顶层,不能塞进对象字面量里当某个属性的值。想在配置对象里做平台区分,就在运行时用 uni.getSystemInfoSync().uniPlatform 判断,别硬塞条件编译。

七、上传部分:并发控制和失败重试

压缩只是前半程,上传才是真正决定体验的地方。

H5 端我用 fetch 手写,因为在小程序端用 uni.uploadFile 更方便,两边行为没法完全对齐,不如分开写清楚。

export function uploadImage(source, config = {}) {
  const {
    url,
    name = 'file',
    formData = {},
    header = {},
    timeout = 30000
  } = config

  return new Promise((resolve, reject) => {

    // #ifdef H5
    const fd = new FormData()
    fd.append(name, source, source.name || 'image.jpg')
    Object.keys(formData).forEach(k => fd.append(k, formData[k]))

    fetch(url, {
      method: 'POST',
      body: fd,
      headers: header   // 千万别手动加 Content-Type
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(resolve)
      .catch(reject)
    // #endif

    // #ifndef H5
    uni.uploadFile({
      url,
      filePath: source,
      name,
      formData,
      header,
      timeout,
      success: (res) => {
        if (res.statusCode < 200 || res.statusCode >= 300) {
          return reject(new Error(`HTTP ${res.statusCode}`))
        }
        let data = res.data
        try {
          data = JSON.parse(data)
        } catch (e) {
          // 服务端返回不是 JSON,原样交给调用方
        }
        resolve(data)
      },
      fail: reject
    })
    // #endif
  })
}

有个细节必须强调:H5 端用 FormData 上传时,绝对不能自己设 Content-Type: multipart/form-data。手动设了以后浏览器不会自动补上 boundary,服务端直接解析失败。这个坑每年都有人踩。

然后是并发控制。用户一次性选九张图,如果九张同时上传,小程序端会因为并发限制直接失败一部分,H5 端也可能被浏览器限制到六个并发。写个简单的池子:

export async function runPool(tasks, limit = 3) {
  const results = new Array(tasks.length)
  let cursor = 0

  async function worker() {
    while (cursor < tasks.length) {
      const index = cursor++
      results[index] = await tasks[index]()
    }
  }

  const runners = Array.from(
    { length: Math.min(limit, tasks.length) },
    () => worker()
  )

  await Promise.all(runners)
  return results
}

配合重试,就是一套能上线的上传逻辑:

const sleep = ms => new Promise(r => setTimeout(r, ms))

export async function uploadWithRetry(source, config, retries = 2) {
  let lastError

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await uploadImage(source, config)
    } catch (err) {
      lastError = err
      if (attempt < retries) {
        // 指数退避,300ms / 600ms / 1200ms
        await sleep(300 * Math.pow(2, attempt))
      }
    }
  }

  throw lastError
}

八、在页面里怎么用

整套东西拼起来,页面里的代码会变得很短:

<script setup>
import { ref } from 'vue'
import { compressImage, uploadWithRetry, runPool } from '@/utils/image'

const list = ref([])
const uploading = ref(false)

function pickImages() {
  return new Promise((resolve) => {
    uni.chooseImage({
      count: 9,
      sizeType: ['original'],   // 原图,压缩交给我们自己
      sourceType: ['album', 'camera'],
      success: resolve,
      fail: () => resolve(null)
    })
  })
}

async function handleUpload() {
  const res = await pickImages()
  if (!res || !res.tempFiles.length) return

  uploading.value = true

  const tasks = res.tempFiles.map((item) => async () => {
    // H5 端 item 是 File,其他端是 { path, size }
    const raw = item instanceof File ? item : item.path

    const compressed = await compressImage(raw, {
      maxWidth: 1440,
      quality: 0.8
    })

    const data = await uploadWithRetry(compressed.source, {
      url: 'https://api.example.com/upload',
      header: { Authorization: 'Bearer ' + uni.getStorageSync('token') },
      formData: { scene: 'order' }
    })

    return { ...data, compressed: compressed.compressed }
  })

  try {
    const results = await runPool(tasks, 3)
    list.value.push(...results)
  } catch (err) {
    uni.showToast({ title: '部分图片上传失败', icon: 'none' })
  } finally {
    uploading.value = false
  }
}
</script>

注意 item instanceof File 这个判断。在小程序里 File 是 undefined,这个表达式会直接抛错,所以要用条件编译包一下,或者干脆用一个 typeof File !== 'undefined' && item instanceof File 来做守卫。我一般选后者,少写几行条件编译。

九、踩坑清单

上面零零散散提了不少,这里集中过一遍。

  • 压缩质量单位不统一。H5 是 0~1,uni.compressImage 是 0~100。封装层统一成 0~1,各端自己转。
  • 微信 compressImage 只对 jpg 有效,PNG 传进去不会报错但也不会被压缩。
  • compressedWidth 需要微信基础库 2.26.0 以上,低版本会被静默忽略。
  • 小图压完可能变大,压缩前后都要比一次体积。
  • H5 端 tempFilesFile 对象,其他端是 { path, size },不能混用。
  • H5 端 FormData 上传时不要手动设 Content-Type
  • App 端部分 Android 机型返回 content:// 路径,需要 plus.io.convertLocalFileSystemURL 转换。
  • 小程序 uploadFile 有并发上限,一次别超过 10 个请求。
  • 压缩失败要降级成原图上传,不要让 Promise 悬在那里。
  • H5 端遇到 4000px 以上的大图,canvas 在低端手机上可能直接内存溢出白屏,压缩前先量一下尺寸。

十、最后

这套封装写完之后,页面里再也不用出现 #ifdef,业务代码干净了很多。压缩配置也统一收在一个地方,以后想调参数只改一处。

值得再花时间的是失败环节。真实用户网络和相册里的图什么样都有,压缩报错、上传超时、服务端限流,能兜住的都得兜住。上面那段 uploadWithRetry 只是最基础的版本,如果要做得更细,可以按错误类型区分——网络错误重试,4xx 不重试直接报给用户,5xx 才退避重试。这个分叉加上去之后,线上下来的失败率会明显低一截。

uni-app 图片压缩上传实战:H5、小程序、App 三端统一封装
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 图片压缩上传实战:H5、小程序、App 三端统一封装 https://www.taomawang.com/web/uniapp/2767.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务