不靠 UA 嗅探:浏览器端图像处理怎么判断「这台设备到底行不行」

上个月有人反馈,在 iPhone 微信里用我的工具把图转成 WebP,下下来的文件在电脑上打不开,看图软件提示格式不对。

我第一反应是编码器写崩了。拿到那个文件用十六进制看了一眼,前四个字节是 89 50 4E 47——PNG 的魔数。文件名后缀写着 .webp,内容是一张不折不扣的 PNG。

问题不在编码器。问题在于我压根没检查浏览器到底有没有把这张图编成 WebP。

后来在同事的 iPhone 上复现了一次:iOS 16.1,微信里打开,转 WebP,下载,扔进十六进制编辑器,还是 PNG 的头。换成 Safari 直接打开同一个页面,同样的结果。再换一台 iOS 17 的机器,正常出 WebP。

一、toBlob 不支持你要的格式时,不会报错

这段代码看起来没有任何问题:

canvas.toBlob(blob => {
  download(blob, 'output.webp');
}, 'image/webp');

回调正常触发了,blob 也不是 null,长度看着也合理。一切正常,除了它不是 WebP。

翻 HTML 规范会发现,这是规定好的行为toBlob 的 type 参数那一节写着:如果用户代理不支持请求的类型,它必须使用 PNG 格式来创建这个文件。

也就是说,浏览器在这里的静默回退不是 bug,是标准要求它这么干。它甚至没有义务告诉你一声。要拿到这个信息,只有一个地方——blob.type

canvas.toBlob(blob => {
  console.log(blob.type);   // iOS 16.4 以下:image/png
}, 'image/webp');

我看规范之前,一直以为「没抛异常 = 成功了」。这个假设在 canvas 编码这件事上完全不成立。toDataURL 也是一样,只不过它的回退更容易被肉眼发现,因为 data URL 的前缀直接就写着 data:image/png;base64,

二、为什么 UA 嗅探救不了这件事

我最早的补丁方案很直觉:既然是 iOS 老版本 Safari 的问题,那就查 UA,遇到 iOS 就不给 WebP 选项。

写了半天,越写越觉得不对。

iOS 上所有浏览器内核都是 WebKit,Chrome、Firefox 在那儿都只是套了个壳,所以「是不是 Safari」这个判断本身就不成立。微信内置浏览器是 WKWebView,UA 里会带 MicroMessenger 标识,但它的 WebView 版本跟随系统,同一个 iOS 大版本下不同小版本的行为也可能不一样。用户还能在 Safari 里开「请求桌面网站」,UA 直接变成 macOS 的样子。

更根本的问题是:UA 回答的是「你大概是个什么浏览器」,而我要问的是「你这台设备此刻能不能把这张图编成 WebP」。这两个问题之间隔着内核版本、系统版本、外壳 App、编译选项,甚至用户设置。中间任何一层不匹配,映射表就会给出错误答案。

而维护这张映射表意味着,我得跟着 Chrome、Safari、Firefox 加上一堆国产浏览器的发版节奏一起跑。这活儿没有尽头,而且每次判断错,代价都是用户拿到一个打不开的文件。

那有没有官方的查询接口?音视频那边是有的:MediaRecorder.isTypeSupported('video/webm;codecs=vp9') 直接告诉你能不能录,navigator.mediaCapabilities.encodingInfo() 还能告诉你流不流畅。

但 canvas 的图像编码没有对应的东西。toBlobtoDataURL 都不提供任何形式的能力查询,规范只规定了「不支持就回退 PNG」,没规定要怎么让你提前知道。

所以这不是我懒得查文档,是这个信息在 API 层面根本拿不到。既然拿不到,就只能自己造一个。

三、不猜,真编一次

后来的做法很朴素:想知道能不能编,就真的编一次

const encodeCache = new Map();

async function canEncode(mime) {
  if (encodeCache.has(mime)) return encodeCache.get(mime);

  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = 2;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'rgba(0, 128, 255, 0.5)';   // 带 alpha,顺便看透明通道保不保得住
  ctx.fillRect(0, 0, 1, 1);

  const blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
  const ok = !!blob && blob.type === mime;    // 关键在后半句

  encodeCache.set(mime, ok);
  return ok;
}

整段代码的重量全在 blob.type === mime 这一句上。少了它,前面所有工作都白做——你只是确认了浏览器愿意给你一个 blob,没有确认这个 blob 是你要的东西。

我拿这段代码在桌面版 Chromium 上跑了一遍,六个格式的结果是这样:

| 请求的 type | 实际拿到的 type | 大小 | 判定 |
|---|---|---|---|
| image/webp | image/webp | 564 B | 能编 |
| image/jpeg | image/jpeg | 796 B | 能编 |
| image/avif | **image/png** | 95 B | 回退 |
| image/heic | **image/png** | 95 B | 回退 |
| image/tiff | **image/png** | 95 B | 回退 |
| image/gif | **image/png** | 95 B | 回退 |

有两个地方值得看一眼。

一是桌面 Chrome 也编不出 AVIF。它能解码 AVIF,图片标签里显示得好好的,但 canvas 编不出来。我原先以为这是移动端才有的问题,实际不是。

二是后面四行的大小完全一样,都是 95 字节。因为它们返回的根本是同一个东西——那张 2×2 的 PNG。如果只看「blob 是不是 null」,这六个格式全部「成功」;一看 type 和大小,四个是同一张图。

顺带一提,Chrome 里编不出 AVIF 不代表这台机器编不出。libavif 的 WASM 版本可以,只是得自己加载——这是后面第六节要说的事。

读的方向同理。能不能读某个格式,也不查表,直接解一次:

async function canDecode(blob) {
  try {
    const bitmap = await createImageBitmap(blob);
    bitmap.close?.();
    return true;
  } catch {
    return false;
  }
}

这两件事必须分开测,因为能读不等于能写。Safari 16 能解 AVIF,但 canvas 编不出 AVIF;Safari 17 能原生读 HEIC,可没有任何主流浏览器能写 HEIC。读和写是两套独立的代码路径,浏览器厂商也是分开实现、分开发布的。

所以我这边的规则是:输入方向能解、输出方向能编,两头都成立,才走本地路径。任何一头不成立,就得换条路走。

四、读的探测比写麻烦,你得先有个样本

写的探测很省事,canvas 现造一个就行。读的探测有个前置条件:你手上得先有一张该格式的图,否则拿什么去解。

总不能让用户先传一张 AVIF 来测浏览器能不能读 AVIF——真到那一步,探测已经没意义了。

所以这些样本得内联在代码里,用 base64 塞进去。好在只需要能解析就行,尺寸可以压到极限,一张 1×1 的 AVIF 大概二三百字节,几种格式加起来也就一两 KB,可以接受。

const PROBES = {
  'image/avif': 'data:image/avif;base64,AAAAIGZ0eXBhdmlm...',
  'image/webp': 'data:image/webp;base64,UklGRh4AAABXRUJQ...',
};

async function canDecodeType(mime) {
  const probe = PROBES[mime];
  if (!probe) return false;
  const blob = await (await fetch(probe)).blob();
  return canDecode(blob);
}

这里有个容易踩的坑:不要用 `new Image()` 加 `onload` 来判断。有些浏览器对无法解码的图会走 onerror,有些会给你一个 naturalWidth 为 0 的成功回调,行为不一致。createImageBitmap 的语义明确得多——解不了就抛异常,没有中间态。

还有一点:HEIC 这类格式即使浏览器原生不认,也不代表这条路走死了。libheif 的 WASM 版本可以接管解码,所以我的探测顺序是先问原生、原生不行再问 WASM 是否已就绪,两级都不通才判定「读不了」。

五、探测本身是有成本的

真编一次不是免费的。尤其当目标格式需要先加载 WASM 编码器时,为了探测去下几百 KB 显然不划算。

几个实际的处理:

结果必须缓存。 同一个会话里,同一个 MIME 只测一次。上面那段代码里的 encodeCache 就是干这个的。

测试画布要小,但别太小。 我一开始用的是 1×1,后来改成 2×2。原因是有些编码器对极端尺寸有特殊分支,1×1 的结果不一定能代表真实图片的行为。2×2 加一个半透明像素,成本几乎一样,覆盖面好一点。

探测要尽早,但别在首屏。 用户还没选文件的时候就把所有格式测一遍,会拖慢首屏;等他点了「转换」再测,又会多一次等待。我的折中是在用户选完图之后、还在挑格式的这段空档里跑,这时候他的注意力在界面上,几十毫秒感知不到。

六、探测不过怎么办

探测出来「不行」,只是知道了事实,还得给用户一条出路。我这边分三档:

第一档,换等价格式。 要 WebP 给不了,就退到 JPG,并且明确告诉他为什么退——不是悄悄换掉。悄悄换格式和悄悄回退 PNG,本质上是同一种毛病。

第二档,按需加载 WASM 编码器****。 浏览器原生编不出来,不代表这台设备编不出来。libwebp、libavif、libheif 都有 WASM 版本,第一次用到的时候下载,之后照样在本地跑。这里有个必须讲清楚的区别:下载编解码器是程序从服务器到你设备,图片一步都没往外走。这两件事方向相反,不该混为一谈。

第三档,交服务端。 端侧确实做不了的,老实交给服务器。但界面上必须写明白现在走的是哪条路。

最后这条是我认为最要紧的。我在界面上给每个格式挂了三种标签:「即时」是浏览器直接能读能写、图片不出设备;「端侧⤓」是首次要下载 WASM、之后仍在本地完成;「服务端」是这台设备做不了、需要上传。

这三个标签是当场探测出来的,不是按浏览器型号查表填的。用户有权知道自己那张图到底离没离开过设备,这个信息不能靠猜。

七、还没解决的

HEIC 的写入我目前没有好办法。

HEIC 用的是 HEVC 编码,专利授权是明摆着的坑,端侧能用的开源实现体积也不小,为了一个使用频率不高的输出格式往首屏塞几兆的编码器,怎么算都不划算。所以现在 HEIC 写入统一走服务端,界面上明确标着「服务端」,不藏。

读 HEIC 倒是解决了——libheif 的 WASM 版本按需加载,之后在本地解码。iPhone 拍的照片转 JPG 这条链路是完整本地的。

写这块的时候我一直在想有没有更好的方案,暂时没想到。如果有人做过端侧 HEVC 编码的体积优化,欢迎在评论里指条路。

小结

回头看,这件事的教训其实只有一句话:浏览器 API 不报错,不等于它做了你以为它做的事

  • canvas.toBlob 不支持目标格式时会静默回退成 PNG,这是规范行为,必须核对 blob.type

  • UA 嗅探回答的是「你是谁」,而你要问的是「你现在能不能做这件事」,两者之间隔着太多层

  • 读和写要分开探测,能解不等于能编

  • 探测结果要缓存,探测时机放在用户注意力不在等待上的空档

  • 探测不过要给出路,并且明确告诉用户现在走的是哪条路

这套探测逻辑用在我做的一个浏览器端图像工具上,常见格式的转换、压缩和抠图都在本地完成,图片不上传;只有 HEIC 写入、TIFF、JPEG 2000 这类端侧确实做不了的格式才走服务端,界面会提前标出来。地址在这:https://imging.cn

如果你也在做端侧的图像处理,那句 blob.type === mime 建议现在就加上,别等用户拿着打不开的文件来问。

posted @ 2026-08-23 16:08  langka  阅读(1)  评论(0)    收藏  举报