欢迎光临
我们一直在努力

我给网站加了个“不上传服务器”的图片转换器:20 张 PNG/JPG/WebP 在浏览器里直接互转

在线转换图片,一定要先把原图上传到服务器吗?

我把转换链路搬进浏览器后,图片不再经过上传队列,批量任务也不占免费服务器的 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);
}

两路并发不一定是所有设备的理论最优值,但它在桌面和移动端之间更均衡,也不会让免费服务器承受任何转换压力。

六、用户改设置时,旧任务不能覆盖新结果

批量转换还有一个容易忽略的竞态:

  • 用户用 WebP 质量 80 开始转换;
  • 旧任务还没结束,用户把目标改成 PNG;
  • WebP 任务稍后完成,把 PNG 设置下的界面结果覆盖了。
  • 我给设置维护了一个递增版本号。任务开始时记录版本,完成时再次比较;如果版本已经变化,就丢弃旧结果并释放它的 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,或者不希望原图经过陌生服务器,可以直接打开页面处理,不需要注册。

    总结

    这次功能看起来只是“三种格式互转”,真正需要处理的却是浏览器编码能力、透明通道、并发峰值、异步竞态和资源释放。

    对小型在线工具来说,把适合本地完成的计算留在用户浏览器里,是一条很实用的路线:隐私更直接,响应更快,服务端成本也更可控。

    技术选型不一定要追求最复杂。能够稳定完成、边界清楚、用户拿到真实可用的文件,比堆更多格式名称重要。

    赞(0)
    未经允许不得转载:171主机测评 » 我给网站加了个“不上传服务器”的图片转换器:20 张 PNG/JPG/WebP 在浏览器里直接互转
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址