在微信小程序里做图片上传功能时,很多团队都遇到过这样的问题:用户随手拍一张照片就是4M到8M,直接传到云存储,一次要传好几张图,弱网环境下用户等得心急,流量也哗哗地消耗。更麻烦的是,云存储的存储费用和CDN回源流量都是按量计费的,原始大图存得越多,账单越难看。其实大部分业务场景(比如头像、评论晒图、反馈截图)并不需要原图级别的清晰度,在上传前先在前端把图片压缩一遍,往往能把体积降到原来的十分之一左右,而且用户几乎无感知。本文就来完整讲一下如何用canvas在小程序端实现这套压缩流程。

为什么选择在上传前做前端压缩
图片压缩可以放在两个环节做:一是后端压缩,图片先原样上传,服务器收到后再转码处理;二是前端压缩,图片还没离开用户手机就已经变小了。两种方案各有适用场景,但对于小程序这种移动端产品,前端压缩的优势非常明显。
首先最直接的好处是省流量、省上传时间。一张5M的图片如果在前端压到300KB,上传耗时可能从十几秒缩短到一两秒,用户体验的差别是质变级别的。其次是省成本,云存储按存储量计费,CDN按流量计费,如果每张图都瘦身90%,长期积累下来的费用差异相当可观。最后还顺带规避了小程序单次上传的体积限制问题,比如wx.cloud.uploadFile对大文件的处理会比较吃力,压缩后再上传的成功率明显更高。
p>当然,前端压缩也不是万能的。如果你的业务需要保留原图(比如证件审核、摄影类应用),那就不适合有损压缩,可以只做尺寸缩放不做质量降级,或者干脆走后端处理。技术选型时先想清楚业务对图片质量的真实要求,再决定压缩策略。canvas压缩的核心原理与实现步骤
小程序里canvas压缩的思路和Web端一致:先把图片绘制到canvas画布上,画布尺寸可以比原图小,这就完成了一次降采样;然后再从画布导出为JPEG格式的图片,导出时指定quality参数,这就完成了一次有损编码压缩。两次处理叠加,体积能大幅下降。
具体步骤拆解开来是四步。第一步用wx.chooseMedia(新版接口,替代了已不推荐使用的wx.chooseImage)让用户选图,拿到本地临时文件路径。第二步通过wx.getImageInfo读取图片的原始宽高,这一步非常关键,因为后续计算压缩后的目标尺寸必须依赖原始尺寸。第三步把图片绘制到canvas上,绘制前根据最大边长限制计算目标宽高,保持宽高比不变形。第四步调用canvas的导出方法拿到压缩后的临时文件,交给云存储上传接口。
这里有一个容易忽略的细节:新版canvas 2d接口和旧版canvas接口的用法差异很大。旧接口通过canvasId和wx.createCanvasContext操作,导出用wx.canvasToTempFilePath;新接口基于原生组件,通过type="2d"获取节点后直接使用标准Web的CanvasRenderingContext2D,导出用canvas.toTempFilePath或wx.canvasToTempFilePath的object形式并传入canvas节点。新接口性能更好,官方也推荐使用,下面的代码统一采用新版2d接口。
完整代码实现
下面给出一个完整可运行的压缩函数,假设WXML中已经放置了一个隐藏的canvas节点(通过定位移出可视区域,不要用wx:if销毁它,否则取不到节点):
<canvas type="2d" id="compressCanvas" style="position:absolute;left:-9999px;top:-9999px;width:750px;height:750px;"></canvas>
// 封装:获取canvas节点,返回canvas对象和上下文
function getCanvasNode() {
return new Promise((resolve, reject) => {
const query = wx.createSelectorQuery()
query.select('#compressCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (res && res[0] && res[0].node) {
resolve(res[0].node)
} else {
reject(new Error('获取canvas节点失败'))
}
})
})
}
// 封装:读取图片信息
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail: reject
})
})
}
/**
* 压缩图片
* @param {string} filePath 本地临时文件路径
* @param {number} maxSide 最长边限制,默认1280像素
* @param {number} quality 导出质量,0到1之间,默认0.7
*/
async function compressImage(filePath, maxSide = 1280, quality = 0.7) {
const info = await getImageInfo(filePath)
const { width, height } = info
// 计算压缩后的目标尺寸,保持宽高比
let targetW = width
let targetH = height
if (width > maxSide || height > maxSide) {
if (width >= height) {
targetW = maxSide
targetH = Math.round(height * (maxSide / width))
} else {
targetH = maxSide
targetW = Math.round(width * (maxSide / height))
}
}
const canvas = await getCanvasNode()
canvas.width = targetW
canvas.height = targetH
const ctx = canvas.getContext('2d')
// 先铺一层白色底,避免透明PNG转JPEG后变黑
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, targetW, targetH)
// 创建图片对象并绘制
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, 0, 0, targetW, targetH)
// 导出为JPEG临时文件
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
fileType: 'jpg',
quality,
success: res => resolve(res.tempFilePath),
fail: reject
})
})
}在页面中使用时,把压缩和上传串联起来即可:
Page({
async onChooseImage() {
const res = await wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['original'] // 压缩由我们自己控制,选原图保留细节
})
const originPath = res.tempFiles[0].tempFilePath
// 压缩:最长边1280,质量0.7
const compressedPath = await compressImage(originPath, 1280, 0.7)
// 上传到云存储
const cloudPath = `images/${Date.now()}-${Math.floor(Math.random() * 1000)}.jpg`
const uploadRes = await wx.cloud.uploadFile({
cloudPath,
filePath: compressedPath
})
console.log('上传成功,fileID:', uploadRes.fileID)
}
})这段代码里有几个点值得展开说明。一是尺寸计算部分,必须根据宽高比判断哪条边是长边,否则竖拍照片会被错误缩放导致比例变形。二是白色底填充,PNG图片通常带透明通道,而JPEG不支持透明,直接绘制会导致透明区域渲染成黑色,先画一层白底是标准做法。三是sizeType参数,虽然微信自带['compressed']选项,但它的压缩策略不受我们控制,建议选原图后自己压,可控性更强。
参数如何调优以及常见坑
压缩参数没有万能值,需要根据业务场景调整。头像类场景可以激进一些,maxSide设为500、quality设为0.6,压缩后通常在50KB以内;商品图、晒单图建议maxSide在1080到1500之间,quality取0.7到0.8,观感和体积比较均衡;需要看文字细节的截图类场景,quality不要低于0.8,否则文字边缘会出现明显的块状伪影。建议开发阶段用几台不同分辨率的真机实测,打印压缩前后的文件大小做对比。
兼容性方面有几个高频踩坑点。第一个是iOS上PNG截图导出为JPEG后,如果忘了铺白底,透明区域会变成黑色而不是某些人以为的白色。第二个是canvas节点的width和height属性要用代码设置成目标像素尺寸,样式里的宽高只是显示尺寸,两者混淆会导致导出的图片模糊或尺寸不对。第三个是并发问题,如果一次选择多张图循环压缩,旧版接口在部分安卓机会出现导出串图,稳妥的做法是用队列串行处理,或者每次导出后延迟几百毫秒再处理下一张。
另外还有内存问题需要注意。一次压缩超大图(比如全景照片,宽度可能上万像素)时,canvas会占用大量内存,低端机可能直接崩溃。建议在读取图片信息后先判断原始尺寸,超过某个阈值(比如最长边6000像素)就先按比例降采样一次,分两步绘制到位,避免一次性操作过大的画布。
最后一个实用技巧:压缩完成后可以对比一下压缩结果和原始文件的大小。如果压缩后反而更大(原图本身很小且已是高压缩率JPEG时可能出现),就直接上传原文件,跳过压缩流程,这种判断逻辑能让方案更健壮。通过wx.getFileSystemManager的stat方法可以拿到文件大小,实现起来很简单。把这套压缩方案封装成公共工具函数后,整个项目里所有涉及图片上传的地方都能复用,投入产出比相当高。