只要项目里带图片上传,早晚会撞上同一个问题:用户从相册选的图,随手就是 3MB 起步,iPhone 拍的甚至能到 8MB。直接扔给 uni.uploadFile,小程序端卡在域名白名单和体积限制上,H5 端用户流量哗哗掉,App 端上传进度条能走半分钟。
更麻烦的是,三端压缩图片的路子完全不一样。H5 没有现成 API,得手搓 canvas;微信小程序有 wx.compressImage,但坑不少;App 端又能用 uni.compressImage。你很难在同一份代码里把这三套缝起来。
这篇文章就把这套东西从零封一遍。最终你会得到一个 compressImage() 加 uploadImage() 的组合,调用方不关心跑在哪个端上,只传文件和一个配置对象。
一、先搞清楚三端差在哪
动手之前先把差异列清楚,不然后面会反复返工。
- 选图返回的东西不一样。H5 端
uni.chooseImage返回的tempFiles是真正的File对象数组,带name、size、type;小程序和 App 端返回的是{ path, size },只有一个临时路径字符串。 - 压缩能力不一样。H5 完全没有原生压缩 API,只能用 canvas 自己画;微信小程序用
wx.compressImage;App 端用uni.compressImage。后两者虽然名字像,参数单位却不同。 - 上传时吃的参数不一样。H5 端
uni.uploadFile的filePath可以传File或Blob,但很多人直接用fetch更省事;小程序和 App 端必须传临时文件路径。 - 质量参数的单位不一样。这是最容易踩的一个坑,下面专门说。
所以统一封装的核心思路就一句话:每个端各自走一条实现路径,出口统一成同样形状的返回值。调用方拿到的永远是一个可以直接丢给上传函数的 source,以及压缩前后的体积信息。
二、统一出口长什么样
先定义返回结构,后面所有实现都往这个形状上靠:
{
source: File | string, // H5 是 File 对象,其他端是临时路径
size: number, // 压缩后的字节数,拿不到就填 0
compressed: boolean, // 是否真的执行了压缩
originalSize: number // 原始字节数,方便页面显示"节省了多少"
}
调用的时候只关心 source 和 compressed。size 用来在 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.compressImage 的 quality 是 0~100 的整数。同一份配置对象在两处直接传,效果能差出十倍。统一封装里,我把配置对象的 quality 定成 0~1,各端实现时自己转换,调用方永远不用记这件事。
第二个是格式限制。compressImage 的质量参数只对 jpg 生效。PNG 传进去不会报错,但压缩基本无效,返回的还是原来那张图。如果业务里用户经常传 PNG 截图,得在服务端或者上传前做格式转换,光靠压缩 API 解决不了。
第三个是尺寸参数要基础库版本。compressedWidth 和 compressedHeight 从微信基础库 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,和微信小程序名字一样,但参数集不同:它用的是 width 和 height,而不是 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
})
}
})
})
}
这里有个容易忽略的点:width 和 height 在 App 端如果不给,只压质量不缩尺寸,效果会大打折扣。但如果两个都给死值,某些机型上会被强行拉伸成正方形。所以配置里的 maxWidth 和 maxHeight 最好设成一样的值(比如都是 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 端
tempFiles是File对象,其他端是{ path, size },不能混用。 - H5 端
FormData上传时不要手动设Content-Type。 - App 端部分 Android 机型返回
content://路径,需要plus.io.convertLocalFileSystemURL转换。 - 小程序
uploadFile有并发上限,一次别超过 10 个请求。 - 压缩失败要降级成原图上传,不要让 Promise 悬在那里。
- H5 端遇到 4000px 以上的大图,canvas 在低端手机上可能直接内存溢出白屏,压缩前先量一下尺寸。
十、最后
这套封装写完之后,页面里再也不用出现 #ifdef,业务代码干净了很多。压缩配置也统一收在一个地方,以后想调参数只改一处。
值得再花时间的是失败环节。真实用户网络和相册里的图什么样都有,压缩报错、上传超时、服务端限流,能兜住的都得兜住。上面那段 uploadWithRetry 只是最基础的版本,如果要做得更细,可以按错误类型区分——网络错误重试,4xx 不重试直接报给用户,5xx 才退避重试。这个分叉加上去之后,线上下来的失败率会明显低一截。

