在线转换图片,一定要先把原图上传到服务器吗?
我把转换链路搬进浏览器后,图片不再经过上传队列,批量任务也不占免费服务器的 CPU 和带宽。用户选完图片就能直接处理,20 张图还能一次打包下载。
最近我给自己的在线图片工具 Pixel Slim 增加了一个独立的图片格式转换页:
在线体验:https://image.997728.xyz/image-converter/
目前支持 PNG、JPG、WebP 三种格式互转,同时支持透明背景、JPEG 背景色、质量调节、单张下载和 ZIP 打包。整个过程都在浏览器本地完成,图片不会上传。
这篇文章不只展示结果,我想把实现过程中几个真正容易踩坑的细节拆开讲清楚。
一、为什么不把转换任务交给 Node 服务
服务端转换当然能做,Node.js 配合 Sharp 很成熟。但我的服务部署在免费实例上,CPU、内存和休眠策略都有限。如果每个用户一次上传 20 张原图,服务端要同时承担:
- 文件上传带宽;
- 临时文件或内存占用;
- 图片解码与重新编码;
- 转换结果下载流量;
- 超时、限流和文件清理。
更关键的是,格式转换并不一定需要服务端能力。现代浏览器已经可以解码 PNG、JPEG、WebP,并通过 Canvas 重新编码。
所以这次的核心架构很简单:服务器只负责发送静态页面,用户设备负责处理用户自己的图片。
#mermaid-svg-t0lqXkZYDq9lwNLh{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-t0lqXkZYDq9lwNLh .error-icon{fill:#552222;}#mermaid-svg-t0lqXkZYDq9lwNLh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-t0lqXkZYDq9lwNLh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-t0lqXkZYDq9lwNLh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-t0lqXkZYDq9lwNLh .marker.cross{stroke:#333333;}#mermaid-svg-t0lqXkZYDq9lwNLh svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-t0lqXkZYDq9lwNLh p{margin:0;}#mermaid-svg-t0lqXkZYDq9lwNLh .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster-label text{fill:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster-label span{color:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster-label span p{background-color:transparent;}#mermaid-svg-t0lqXkZYDq9lwNLh .label text,#mermaid-svg-t0lqXkZYDq9lwNLh span{fill:#333;color:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh .node rect,#mermaid-svg-t0lqXkZYDq9lwNLh .node circle,#mermaid-svg-t0lqXkZYDq9lwNLh .node ellipse,#mermaid-svg-t0lqXkZYDq9lwNLh .node polygon,#mermaid-svg-t0lqXkZYDq9lwNLh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-t0lqXkZYDq9lwNLh .rough-node .label text,#mermaid-svg-t0lqXkZYDq9lwNLh .node .label text,#mermaid-svg-t0lqXkZYDq9lwNLh .image-shape .label,#mermaid-svg-t0lqXkZYDq9lwNLh .icon-shape .label{text-anchor:middle;}#mermaid-svg-t0lqXkZYDq9lwNLh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-t0lqXkZYDq9lwNLh .rough-node .label,#mermaid-svg-t0lqXkZYDq9lwNLh .node .label,#mermaid-svg-t0lqXkZYDq9lwNLh .image-shape .label,#mermaid-svg-t0lqXkZYDq9lwNLh .icon-shape .label{text-align:center;}#mermaid-svg-t0lqXkZYDq9lwNLh .node.clickable{cursor:pointer;}#mermaid-svg-t0lqXkZYDq9lwNLh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-t0lqXkZYDq9lwNLh .arrowheadPath{fill:#333333;}#mermaid-svg-t0lqXkZYDq9lwNLh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-t0lqXkZYDq9lwNLh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-t0lqXkZYDq9lwNLh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t0lqXkZYDq9lwNLh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-t0lqXkZYDq9lwNLh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t0lqXkZYDq9lwNLh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster text{fill:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh .cluster span{color:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-t0lqXkZYDq9lwNLh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-t0lqXkZYDq9lwNLh rect.text{fill:none;stroke-width:0;}#mermaid-svg-t0lqXkZYDq9lwNLh .icon-shape,#mermaid-svg-t0lqXkZYDq9lwNLh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t0lqXkZYDq9lwNLh .icon-shape p,#mermaid-svg-t0lqXkZYDq9lwNLh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-t0lqXkZYDq9lwNLh .icon-shape .label rect,#mermaid-svg-t0lqXkZYDq9lwNLh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t0lqXkZYDq9lwNLh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-t0lqXkZYDq9lwNLh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-t0lqXkZYDq9lwNLh :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
PNG
JPEG
WebP
用户选择本地图片
浏览器读取图片
createImageBitmap 解码
Canvas 绘制像素
选择输出格式
无损编码并保留透明
合成背景色后有损编码
按质量参数编码
生成 Blob
单张下载或打包 ZIP
图片数据从开始到结束都留在当前浏览器标签页里,不需要经过 Render 接口。
二、第一步不是 Canvas,而是限制输入边界
“纯前端处理”不等于可以无条件读取任意文件。超大图片解码后占用的内存,远高于文件本身体积。
一张 8000 × 8000 的 RGBA 图片,光像素缓冲区就接近 244MB。如果再叠加原图、Canvas 和输出 Blob,手机浏览器很容易崩溃。
因此我先限制了三个边界:
- 最多 20 张;
- 单张不超过 25MB;
- 单张不超过 8000 万像素。
同时只接受当前明确支持的三种 MIME 类型:
const MAX_PIXELS = 80_000_000;
const SUPPORTED_TYPES = new Set([
'image/png',
'image/jpeg',
'image/webp'
]);
function isSupportedFile(file) {
const withinSize = file.size <= 25 * 1024 * 1024;
return SUPPORTED_TYPES.has(file.type) && withinSize;
}
这里我没有加入 AVIF。原因不是它不够先进,而是浏览器“能显示 AVIF”和“能通过 Canvas 稳定导出 AVIF”不是同一回事。格式转换工具最重要的是结果可预测,不是格式列表越长越好。
三、浏览器可能不会按你指定的格式输出
Canvas 转 Blob 的代码看起来只有一行:
canvas.toBlob(callback, 'image/webp', 0.88);
但这里有个隐蔽问题:如果浏览器不支持目标编码器,它可能返回空值,也可能回退成其他类型。只根据传入的 MIME 给文件命名,就可能得到“后缀是 WebP,内容却不是 WebP”的文件。
所以编码完成后必须检查 Blob 的真实类型:
function canvasToBlob(canvas, mimeType, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob || blob.type !== mimeType) {
reject(new Error('浏览器无法编码所选格式'));
return;
}
resolve(blob);
}, mimeType, quality);
});
}
下载文件的扩展名也应由目标格式生成,JPEG 使用 .jpg,而不是沿用上传文件原来的后缀。
四、透明 PNG 转 JPEG,不能直接画完就导出
PNG 和 WebP 可以保留 Alpha 透明通道,JPEG 不行。
如果把透明图片直接绘制到 Canvas 后导出 JPEG,透明区域可能变黑,也可能出现与预期不一致的底色。正确做法是先铺用户选择的背景色,再绘制原图:
function drawSource(context, image, settings) {
if (settings.format === 'jpeg') {
context.fillStyle = settings.background;
context.fillRect(0, 0, image.width, image.height);
}
context.drawImage(
image.source,
0,
0,
image.width,
image.height
);
}
这也是转换页在选择 JPEG 时才显示背景色控件的原因:把技术限制变成用户能理解、能控制的选项。
五、批量处理不等于 20 张图片一起跑
如果直接 Promise.all() 同时转换 20 张大图,瞬时内存和 CPU 峰值会非常明显。电脑可能只是卡顿,手机浏览器则可能直接回收页面。
我的实现把并发数固定为 2。一个任务完成并释放解码资源后,下一个任务才进入处理阶段。
async function runPool(items, worker, concurrency = 2) {
let nextIndex = 0;
async function runWorker() {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await worker(item);
}
}
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
runWorker
);
await Promise.all(workers);
}
两路并发不一定是所有设备的理论最优值,但它在桌面和移动端之间更均衡,也不会让免费服务器承受任何转换压力。
六、用户改设置时,旧任务不能覆盖新结果
批量转换还有一个容易忽略的竞态:
我给设置维护了一个递增版本号。任务开始时记录版本,完成时再次比较;如果版本已经变化,就丢弃旧结果并释放它的 Object URL。
用户清空队列或删除待处理图片时,也使用同样的原则:后台即使完成,也不能把已经删除的任务重新写回页面。
#mermaid-svg-qwLDMZpzARvm1eSj{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qwLDMZpzARvm1eSj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qwLDMZpzARvm1eSj .error-icon{fill:#552222;}#mermaid-svg-qwLDMZpzARvm1eSj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qwLDMZpzARvm1eSj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qwLDMZpzARvm1eSj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qwLDMZpzARvm1eSj .marker.cross{stroke:#333333;}#mermaid-svg-qwLDMZpzARvm1eSj svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qwLDMZpzARvm1eSj p{margin:0;}#mermaid-svg-qwLDMZpzARvm1eSj .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-qwLDMZpzARvm1eSj .cluster-label text{fill:#333;}#mermaid-svg-qwLDMZpzARvm1eSj .cluster-label span{color:#333;}#mermaid-svg-qwLDMZpzARvm1eSj .cluster-label span p{background-color:transparent;}#mermaid-svg-qwLDMZpzARvm1eSj .label text,#mermaid-svg-qwLDMZpzARvm1eSj span{fill:#333;color:#333;}#mermaid-svg-qwLDMZpzARvm1eSj .node rect,#mermaid-svg-qwLDMZpzARvm1eSj .node circle,#mermaid-svg-qwLDMZpzARvm1eSj .node ellipse,#mermaid-svg-qwLDMZpzARvm1eSj .node polygon,#mermaid-svg-qwLDMZpzARvm1eSj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qwLDMZpzARvm1eSj .rough-node .label text,#mermaid-svg-qwLDMZpzARvm1eSj .node .label text,#mermaid-svg-qwLDMZpzARvm1eSj .image-shape .label,#mermaid-svg-qwLDMZpzARvm1eSj .icon-shape .label{text-anchor:middle;}#mermaid-svg-qwLDMZpzARvm1eSj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qwLDMZpzARvm1eSj .rough-node .label,#mermaid-svg-qwLDMZpzARvm1eSj .node .label,#mermaid-svg-qwLDMZpzARvm1eSj .image-shape .label,#mermaid-svg-qwLDMZpzARvm1eSj .icon-shape .label{text-align:center;}#mermaid-svg-qwLDMZpzARvm1eSj .node.clickable{cursor:pointer;}#mermaid-svg-qwLDMZpzARvm1eSj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qwLDMZpzARvm1eSj .arrowheadPath{fill:#333333;}#mermaid-svg-qwLDMZpzARvm1eSj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qwLDMZpzARvm1eSj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qwLDMZpzARvm1eSj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qwLDMZpzARvm1eSj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qwLDMZpzARvm1eSj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qwLDMZpzARvm1eSj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qwLDMZpzARvm1eSj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qwLDMZpzARvm1eSj .cluster text{fill:#333;}#mermaid-svg-qwLDMZpzARvm1eSj .cluster span{color:#333;}#mermaid-svg-qwLDMZpzARvm1eSj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qwLDMZpzARvm1eSj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qwLDMZpzARvm1eSj rect.text{fill:none;stroke-width:0;}#mermaid-svg-qwLDMZpzARvm1eSj .icon-shape,#mermaid-svg-qwLDMZpzARvm1eSj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qwLDMZpzARvm1eSj .icon-shape p,#mermaid-svg-qwLDMZpzARvm1eSj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qwLDMZpzARvm1eSj .icon-shape .label rect,#mermaid-svg-qwLDMZpzARvm1eSj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qwLDMZpzARvm1eSj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qwLDMZpzARvm1eSj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qwLDMZpzARvm1eSj :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
任务进入两路并发队列
记录当前设置版本
解码并绘制 Canvas
编码为 Blob
任务仍存在且版本一致吗
创建结果 URL 并更新列表
丢弃过期结果
下载完成或用户删除
立即释放 Object URL
关闭 Bitmap 并清空 Canvas
这类状态保护在小图上很难察觉,但图片越大、设备越慢,越容易真实发生。
七、本地图片工具最容易漏掉的是资源释放
URL.createObjectURL() 很方便,但浏览器不会在业务状态变化时自动知道哪个 URL 已经没用了。
下面这些时机都要主动调用 URL.revokeObjectURL():
- 删除某张图片;
- 重新转换并替换旧结果;
- 清空全部任务;
- 组件卸载;
- 设置变化导致结果过期;
- ZIP 下载触发之后。
图片绘制结束后,还需要关闭 ImageBitmap,并把 Canvas 缩到 1 × 1,尽快释放大块像素缓冲区。
try {
context.drawImage(bitmap, 0, 0);
return await canvasToBlob(canvas, mimeType, quality);
} finally {
bitmap.close();
canvas.width = 1;
canvas.height = 1;
}
“页面没有报错”不代表内存没有累积。连续处理多批图片后仍然稳定,才算真正完成了批量功能。
八、PNG 转成 JPG,为什么有时反而更小
格式转换不是改后缀,而是重新编码像素。
- 照片和复杂纹理转成 JPEG 或 WebP,通常更容易减小体积;
- 截图、文字、线条转成 PNG,边缘通常更干净;
- JPEG 转 PNG 不会恢复已经丢失的细节,文件还可能变大;
- 透明 PNG 转 JPEG 后,透明区域会被选定背景色永久替换。
所以工具会展示转换前后的文件大小,但不会承诺“每次转换都能变小”。如果目标只是减小体积,应该使用图片压缩;如果目标是兼容某个平台、保留透明背景或统一素材格式,才应该使用格式转换。
九、最终效果:一个页面解决批量格式统一
现在这个转换页可以完成:
- PNG、JPG、WebP 三种格式互转;
- 最多 20 张图片批量处理;
- PNG 无损输出;
- JPEG、WebP 质量调节;
- 透明图片转 JPEG 时自定义背景色;
- 单张下载或 ZIP 打包下载;
- 中英文页面切换;
- 全程浏览器本地处理,不上传原图。
实际体验地址:
https://image.997728.xyz/image-converter/
如果你临时需要把一批 PNG 统一转成 JPG、把文章配图转成 WebP,或者不希望原图经过陌生服务器,可以直接打开页面处理,不需要注册。
总结
这次功能看起来只是“三种格式互转”,真正需要处理的却是浏览器编码能力、透明通道、并发峰值、异步竞态和资源释放。
对小型在线工具来说,把适合本地完成的计算留在用户浏览器里,是一条很实用的路线:隐私更直接,响应更快,服务端成本也更可控。
技术选型不一定要追求最复杂。能够稳定完成、边界清楚、用户拿到真实可用的文件,比堆更多格式名称重要。

