在我维护的图片猫 PicCat(项目地址 www.piccat.cn)中,批量改尺寸和编辑后的保存都会用到 Canvas。整理导出逻辑时,有个问题值得单独解释:同一份画布内容,为什么把 PNG 的 quality 从 0.9 改成 0.1,文件并不变小?
结论先说:浏览器原生 Canvas 的 quality 不控制 PNG 编码。它也不是目标文件大小或"保留多少像素"的比例。要解决这个问题,需要分别处理导出格式、像素尺寸和编码选项。
1 先看一个容易误判的导出调用
下面是教学用的最小片段。它要求已有一个完成绘制的 canvas,只展示编码调用,省略了图片加载、画布绘制和下载。
canvas.toBlob(blob => {
console.log(blob?.type, blob?.size);
}, 'image/png', 0.1);
直觉上,0.1 似乎应该比 0.9 压得更小。但 WHATWG 的位图序列化规则明确排除了 image/png:只有支持可变质量、且格式不是 PNG 时,quality 才作为期望质量参与编码。换成 toDataURL 或 OffscreenCanvas.convertToBlob,也不会得到 PNG 的压缩等级开关。1
对于支持该参数的格式,quality 通常使用 0 到 1 的数值。传 80 并不代表 80 分质量;超出范围会使用浏览器默认质量,而不是自动按 100 分制换算。具体质量与体积的映射由实现决定。1

图 1 原理示意 格式决定采用哪条编码路径,quality 不改变画布像素尺寸
PNG 的无损压缩仍然可以优化
PNG 会对像素数据做过滤和无损压缩,体积受内容、尺寸和编码策略影响。Canvas 没有把 PNG 的压缩等级暴露给 quality。2
这里的无损针对当前位图。缩放、合成或色彩处理可能已经改变内容;导出 PNG 也不等于保留原始元数据、动画和文件字节。
2 项目里把格式和质量分开处理
图片猫的批量改尺寸在 BatchResize.vue 中先确定输出 MIME,再决定是否传入质量值。下面摘录真实的质量分支和调用语句;省略了 Vue 状态声明、尺寸推演、绘制、批量调度与保存流程,不能作为独立组件直接运行。
项目代码摘录 webClientVue/src/components/batch/BatchResize.vue
function getBaseExportQuality(
mimeType: string
): number | undefined {
if (mimeType === 'image/png') return undefined;
return exportQuality.value / 100;
}
const mimeType = getOutputMimeType(file);
const blob = await exportCanvasBlob(
canvas, mimeType, getBaseExportQuality(mimeType)
);
这里的关键是按最终输出 MIME 判断,而不是只看上传文件的后缀。原图可能是 PNG,导出却选择 JPEG;也可能保留原格式,最终仍然输出 PNG。getOutputMimeType 先处理显式格式选择,再处理保留原格式的情况。
当前界面选择 PNG 时会隐藏质量滑块;选择"保留原格式"时仍显示滑块,但提示质量仅影响 JPG 和 WebP。对于 PNG 输入,编码分支依然传 undefined。这部分交互还能更明确:按实际将输出的格式禁用或解释质量项,而不只是按下拉框的字面选项判断。
缩放质量和编码质量不是同一个参数
同一组件设置了 imageSmoothingEnabled 和 imageSmoothingQuality,并在大幅缩小时分步降采样。这些设置服务于绘制、重采样;toBlob 的 quality 服务于编码。把 imageSmoothingQuality 设为 high,不能让 PNG 的 quality 生效,也不能恢复原图不存在的细节。
正式压缩与前端预估也要分清
项目的图片压缩功能还会经过 Python 编码器。按质量压缩且未设置目标大小时,不透明 PNG 在质量低于 100 时可转为 JPEG;存在实际透明像素时保留 PNG,并使用 PNG 编码选项。压缩后没有更小时会返回原始文件。目标大小模式另有格式与缩放逻辑。
RestorerEditor.vue 中的 Canvas 试压只是体积预估:大图会缩到最长边 1024 再按面积估算;透明 PNG 则不提供这项可靠估值。本文的浏览器实验没有执行该服务端流程,不能据此推导正式压缩的体积或耗时。
3 用同一份画布像素做一次对照
实验素材取自项目公开案例目录中的 festival-dragon_before.webp,尺寸为 1024 × 1024。先解码并绘入同一个 Canvas,再只改变编码格式和 quality;不重新缩放,不从已经导出的 JPEG 继续转码。配图中的局部均来自这次导出文件,不是产品界面截图。

图 2 本次导出文件的同一区域局部对照 PNG 两份字节相同,JPEG 低质量出现细节损失
|----------|-------------|-------------|-----------|
| 请求格式 | quality | 实际 MIME | 文件字节数 |
| PNG | 0.1 | image/png | 2,436,281 |
| PNG | 0.9 | image/png | 2,436,281 |
| JPEG | 0.1 | image/jpeg | 55,176 |
| JPEG | 0.9 | image/jpeg | 384,911 |
| 不支持的类型 | 0.9 | image/png | 2,436,281 |
测试环境为 Windows x64、Chrome 155.0.8059.85 无头模式,日期为 2026 年 10 月 26 日。两份 PNG 做了逐字节比较,结果相同;这说明本次 quality 没有改变 PNG 输出,不构成跨浏览器、跨版本字节恒定的保证。
另一次独立输出把画布缩到 512 × 512,再导出 PNG,得到 687,432 字节。体积确实下降,但像素数量和细节也变了。这不属于"在原尺寸上把 PNG 的 quality 调低"。这些数值只描述本样本,不是通用压缩率。
4 复现时同时记录类型和字节数
下面是教学用的可运行探测函数,已在上述浏览器环境中执行。把它粘到页面控制台后,传入一个已有内容、且允许导出的 HTMLCanvasElement;函数不负责加载图片或触发下载。
async function probeCanvas(canvas) {
const rows = \[\];
const png = \[\];
const cases = [
'image/png', 0.1, 'image/png', 0.9,
'image/jpeg', 0.1, 'image/jpeg', 0.9,
'image/not-supported', 0.9,
];
for (const type, quality of cases) {
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) :
reject(new Error('编码失败')), type, quality);
});
const bytes = new Uint8Array(await blob.arrayBuffer());
if (type === 'image/png') png.push(bytes);
rows.push({ requested: type, actual: blob.type,
quality, bytes: blob.size });
}
const same = png0.length === png1.length &&
png0.every((value, i) => value === png1i);
console.table(rows);
console.log('两份 PNG 字节相同', same);
return { rows, same };
}
例如,页面确有画布时,可调用 await probeCanvas(document.querySelector('canvas'))。画布不存在时应先创建并完成绘制。用于对照的几次调用必须复用同一份位图,否则内容、字体加载时机或尺寸变化会混入实验。
为什么还要测试一个不支持的类型
原生 Canvas 在请求格式不受支持时会回退到 PNG。实验中的 image/not-supported 故意触发这条路径。因此,只看传入的 type 或下载文件名,不足以判断实际输出;应该检查 blob.type。浏览器能解码某格式,也不代表 Canvas 一定能编码该格式。13
逐字节比较适合发现本次编码有没有变化;要验证缩放、透明合成或重编码之后的视觉内容,还需要检查解码后的尺寸、像素或实际观感。相同体积也不代表内容一定相同。
5 把格式回退作为导出结果处理
BatchResize.vue 当前的 exportCanvasBlob 已检查空 Blob,但没有逐一核对返回的 blob.type;文件扩展名由请求 MIME 推导。因此,如果格式编码被回退,文件名可能与实际内容不一致。下面是建议改进的教学封装,本次写作没有把它提交到业务代码。
function exportCanvasStrict(canvas, type, percent = 90) {
const supported = 'image/png', 'image/jpeg', 'image/webp';
if (!supported.includes(type)) {
return Promise.reject(new Error('未开放该导出格式'));
}
if (type !== 'image/png' &&
(!Number.isFinite(percent) || percent < 0 || percent > 100)) {
return Promise.reject(new RangeError('质量须为 0 到 100'));
}
const quality = type === 'image/png' ? undefined : percent / 100;
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) return reject(new Error('图片编码失败'));
if (blob.type !== type) {
return reject(new Error('浏览器未按请求格式编码'));
}
resolve(blob);
}, type, quality);
});
}
这个版本选择显式拒绝格式回退。另一种可行方案是接受回退,然后根据实际 blob.type 更新扩展名和界面提示;产品必须作出一致的选择。质量项使用 0 到 100 的输入时,在这一层换算为 0 到 1,PNG 则明确不传质量。
JPEG 导出前先决定透明区域的底色
JPEG 不能保留透明背景。若决定把透明素材转成 JPEG,应在专用输出画布上先铺底色,再绘制源图;不要依赖默认背景,也不要改变仍用于 PNG 导出的原画布。下面是独立的关键绘制片段,假设 out、ctx、source 已准备好。
// out 是单独创建的输出画布
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(source, 0, 0, out.width, out.height);
const blob = await exportCanvasStrict(out, 'image/jpeg', 90);
项目的 ImagePrivacyCleaner.vue 已采用 JPEG 先铺白底、再绘制图片的顺序,并对 PNG 不传质量。它是已有实现的另一个例子,不代表所有导出入口都已完成同样的透明背景与 MIME 回退检查。
6 需要更小的 PNG 时怎样选择
如果要求仍输出 PNG,可以先确定允许改变什么,再选择处理方式。浏览器没有暴露的 PNG 编码选项,需要由专用编码器或服务端承担;引入这些能力之前,应单独评估依赖体积、执行时间和内存。
|----------------|------------|-------------------|
| 处理方式 | 实际改变 | 需要接受的边界 |
| 缩小像素尺寸 | 重采样后的像素数量 | 会丢失空间细节,不能称原尺寸无损 |
| 无损重新编码 | 过滤、压缩等编码策略 | 不保证更小,不同编码器结果不同 |
| 调色板量化 | 颜色集合及像素表示 | 可能损失颜色和半透明边缘细节 |
| 改用 JPEG 或 WebP | 格式和编码策略 | 先验证编码支持、透明背景与目标用途 |
对项目现状也应保持这个区分:批量改尺寸的 PNG 路径没有实现调色板量化或专用 PNG 压缩等级控制。Python 压缩核心有自己的 PNG 编码选项,但不是 Canvas quality 的另一种写法,也不能保证每张图都缩小。
已执行的验证与仍需补充的验收
本次实际执行了同一案例图的 PNG/JPEG 质量对照、PNG 半尺寸导出,以及教学封装的 PNG/JPEG/WebP 输出、零尺寸画布、越界质量值和未开放格式检查。封装拒绝 MIME 回退的分支使用模拟 Blob 验证。测试不包含 Safari、Firefox、移动端和线上下载全流程。
接入真实导出功能时,建议另外验收透明边缘、大图内存、格式回退后的文件名,以及跨域图片绘入后的导出权限。跨域资源需要满足 CORS 和画布可导出条件;仅给 img 设置 crossOrigin 而服务端未允许,不能解决污染画布的问题。toBlob 可能回调 null,也可能抛出 SecurityError,都应走明确的失败提示。3
我更愿意把导出设置拆成三个明确问题:输出什么格式、需要多少像素、允许怎样的画质损失。PNG 的质量滑块没有效果时,先检查这三个问题及实际 MIME,再决定是缩放、转码还是引入专门的 PNG 编码能力。