内容后台、官网 CMS 里最常见的隐性性能问题,是运营或编辑直接把手机拍的原图传上来:一张四五 MB、分辨率四千多像素的 JPEG,塞进正文图位后既拖慢上传、又让前台页面白白多加载几兆流量。把压缩放到上传之前的前端来做,能在不改变使用习惯的前提下把图片体积压到原来的十分之一左右。这篇讲清一套可直接落地的前端图片压缩方案:canvas 压缩、EXIF 方向纠正、尺寸与质量阶梯,以及几个一定会踩的坑,文末附参数对照表与压缩前自检清单。
把原图直接传到服务端再压缩有三个问题:上传耗时长、弱网下容易超时失败;占用服务器带宽和处理资源;用户在等待期间会反复点上传,制造一堆重复请求。前端压缩发生在图片离开浏览器之前,传上去的已经是体积合适的成品,服务端只需要存储和必要的二次处理。
需要强调的是,前端压缩不是为了取代服务端处理,而是先把 90% 的体积问题在源头解决,服务端仍然应该做格式校验和兜底压缩。
整体分四步:用 FileReader 把图片读成 DataURL,放进 Image 对象,按目标尺寸画到 canvas,再用 canvas.toBlob 导出压缩后的文件。
function compressImage(file, { maxSize = 1600, quality = 0.82 } = {}) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
// 等比缩放到 maxSize 以内
if (width > maxSize || height > maxSize) {
const ratio = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => resolve(new File([blob], file.name, { type: 'image/jpeg' })),
'image/jpeg',
quality
);
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}这里有两个关键参数:maxSize 控制最长边像素,正文配图一般 1280 到 1600 就足够清晰;quality 控制导出质量,0.8 左右通常是人眼几乎看不出差别的甜点区。
手机竖拍的照片带着一个叫 EXIF Orientation 的方向标记,浏览器的 canvas 在绘制时默认不会自动应用这个标记,结果就是上传后图片被旋转了 90 度或者倒置。解决办法是先读出方向值,再在 canvas 上做对应的旋转/镜像变换。
方向值常见为 1、3、6、8,分别对应正常、旋转 180、顺时针 90、逆时针 90。读取 EXIF 可以用成熟的轻量库,也可以自己解析 JPEG 的 APP1 段,工程上更推荐用经过验证的库避免边界错误。拿到方向后,在 drawImage 前对 canvas 做变换:
function applyOrientation(ctx, width, height, orientation) {
// orientation 6:顺时针 90,需要交换画布宽高并旋转
if (orientation === 6) {
ctx.canvas.width = height;
ctx.canvas.height = width;
ctx.translate(height, 0);
ctx.rotate(Math.PI / 2);
} else if (orientation === 3) {
ctx.translate(width, height);
ctx.rotate(Math.PI);
} else if (orientation === 8) {
ctx.canvas.width = height;
ctx.canvas.height = width;
ctx.translate(0, width);
ctx.rotate(-Math.PI / 2);
}
}经验结论:只要上传来源包含手机相册,就必须处理 EXIF 方向,否则一定会有一定比例的图是歪的,而且这类问题在线上很难从日志里发现。
固定 quality 在面对超大图、复杂纹理图时并不稳:有的图 0.82 压完仍然偏大。更稳的做法是质量阶梯——先按较高质量导出,若体积仍超过目标,就降低质量再导一次,直到达标或触达下限:
async function compressWithSteps(file, { targetKB = 300, maxSize = 1600 } = {}) {
const qualities = [0.85, 0.75, 0.65, 0.55];
let result = file;
for (const q of qualities) {
result = await compressImage(file, { maxSize, quality: q });
if (result.size / 1024 <= targetKB) break;
}
return result;
}如果降到最低质量仍然超标,就再把 maxSize 收小一档(比如从 1600 降到 1280)重来一轮,而不是无限压低质量导致画面糊成一片。要给整个过程设一个保底:当压缩后反而比原图还大时(简单图形可能出现),直接用原图,避免"越压越大"。
后台一次选十几张图很常见,如果对每张图同时做读取和解码,内存会在短时间内飙升,中低端设备上甚至会让标签页崩溃。正确做法是用一个并发受限的队列,同一时刻只处理 2 到 3 张,处理完一张再从队列取下一张,并把整体进度回传给界面:
async function compressBatch(fileList, options, concurrency = 2) {
const results = [];
const queue = fileList.slice();
async function worker() {
while (queue.length) {
const file = queue.shift();
const compressed = await compressWithSteps(file, options);
results.push(compressed);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
}还要注意 canvas 的尺寸上限:不同浏览器对单个 canvas 的总像素数有上限,超大图直接按原始分辨率建画布可能得到一张空白图。稳妥的做法是在缩放时就把最长边限制在合理范围(一般不超过 4096),正文场景 1600 已经足够,既绕开了上限,也进一步降低了内存占用。
格式方面,如果目标浏览器支持,可以优先导出 WebP:同等画质下 WebP 通常比 JPEG 再小两三成。做法是先用 canvas.toBlob 以 image/webp 导出,检测回调是否为空,为空说明不支持,再回退到 JPEG,形成一条带兜底的格式选择链路。
图片用途 | 最长边像素 | 建议质量 | 目标体积 | 格式建议 |
|---|---|---|---|---|
正文配图 | 1280–1600 | 0.8 | ≤300KB | JPEG |
横幅/Banner | 1920 | 0.78 | ≤450KB | JPEG/WebP |
缩略图 | 400–600 | 0.8 | ≤60KB | JPEG/WebP |
图标/截图(含文字) | 按需 | 0.9 | 看清晰度 | PNG |
透明底素材 | 原始尺寸 | — | 无损 | PNG/WebP |
照片类用 JPEG 或 WebP,含大量纯色、文字、需要透明的截图和图标用 PNG,不要为了省体积把文字截图也压成低质量 JPEG,否则边缘会出现明显噪点。
落地时建议把压缩封装成一个独立的上传工具函数:先校验类型与大小,再读取 EXIF 方向,接着按"尺寸缩放 + 质量阶梯"导出,最后把成品文件交给原有上传逻辑;多图场景用并发上限为 2 到 3 的队列逐张处理,避免一次性把内存占满。服务端保留类型白名单、魔数校验和一次兜底压缩,前后端各管一段。条件允许时可以优先尝试 WebP 导出并对不支持的环境回退 JPEG。以上为通用前端工程实践,具体接口与兼容性以实际目标浏览器为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。