iPhone 用户传不上图?浏览器端 HEIC 转码 + 超 10MB 自动压缩的完整实现(createImageBitmap 优先,WASM 按需加载)

本文由 AI 辅助生成:内容基于项目代码和 2026-09-22 的实测结果整理,作者负责事实核对。

上传前图片准备(image preparation)是用户选完文件到真正开始上传之间的那一步:在浏览器里把文件解码、转码、缩小,让服务端永远收不到它处理不了的格式和体积。凡是有手机用户的上传功能都需要它,因为 iPhone 默认拍出来的是 HEIC,而现在一张手机原图动辄超过 10MB。

先给结论:优先调 createImageBitmap,它在系统能解码的平台上免费且即时;只有它对一个确实是 HEIC 的文件抛错时,才 import() 一个 WASM 解码器。 这样从不选 HEIC 的用户永远不会下载那个 2.9MB 的 chunk。适合的场景是浏览器直传对象存储(服务端根本看不到文件)的上传链路;如果你的服务端能收 HEIC 再转,那在服务端做更简单。

先说利益关系:下面的代码来自我参与的产品 EditTextImage,它的功能是改掉成品图里已经印上去的文字并保留原字体和背景;这条管线就跑在它的上传入口。

根因是两道检查的顺序,不是缺功能

一个付费用户账户里有积分、零次编辑,对象存储里没有他的任何上传记录。服务端日志什么都查不到,因为文件在浏览器里就被拒掉了,根本没发请求。上传前有两道关卡:

第一道只接受 image/png、image/jpeg、image/webp。iPhone 相册直接选出来的照片是 HEIC,这里直接弹一个提示,结束。

第二道拒绝超过 10MB 的文件。它检查的是原始文件------而上传辅助函数本来就会把长边超过 2048px 的图缩小,一张 12MB 的手机照片缩完大约 1MB。也就是说,一张本来能以 1MB 上传的图,因为「它是 12MB」被拒了。检查跑在了压缩前面。

这两个问题都不需要新能力,需要的是把「转换和压缩」挪到「判断」前面,并且让上限作用在真正要发出去的那个文件上。

解码策略:原生优先,WASM 只做兜底

createImageBitmap 是浏览器最便宜的解码入口,返回的位图可以直接画到 canvas。在系统能解 HEIC 的平台上(Safari 走系统解码器,预期可以,但我没有在 Safari 上实测,后面单独说),这一步直接成功,一个字节的额外代码都不用下载;不能解的平台会抛错,这时才去取解码器。

ts 复制代码
async function decode(file: File): Promise<ImageBitmap | null> {
  if (typeof createImageBitmap !== "function") return null;
  try {
    return await createImageBitmap(file, { imageOrientation: "from-image" });
  } catch {
    // 走到 HEIC 解码器
  }
  if (!isHeic(file)) return null;
  try {
    const { heicTo } = await import("heic-to");
    // "bitmap" 直接返回 ImageBitmap,省掉「先出 JPEG 再解一次」的往返;
    // 并且会转发 ImageBitmapOptions,所以 EXIF 方向在这条路上同样被尊重。
    return await heicTo({
      blob: file,
      type: "bitmap",
      options: { imageOrientation: "from-image" },
    });
  } catch {
    return null;
  }
}

这段里有两个细节比它们看起来重要。

imageOrientation: "from-image" 两条路径都传了。手机照片经常是「像素横着存、EXIF 里标一个方向位」,解码时不尊重这个标志,图就横着传上去了。在一个图片编辑产品里,横过来的结果会被用户理解成「模型出错了」,而不是「上传出错了」------你会去排查完全错误的地方。heic-to 接受同样的 ImageBitmapOptions,这就是选 "bitmap" 输出而不是让它吐 JPEG 的原因。

动态 import("heic-to") 是把解码器挡在主包外面的全部机制。它会被打成独立 chunk(这个构建里是 2.9MB),只在那个 catch 分支里才会被请求。验证方式很直接:看首页 HTML 引用了哪些脚本------12 个 chunk、压缩后约 229KB,解码器不在其中。

HEIC 的判定不能只看 MIME

浏览器给 HEIC 文件报的 type 经常是空字符串 。测试用的真实样本进来时就是 type: "",只看 MIME 的判定会把它归为「不是图片」直接拒掉。扩展名必须算数。

ts 复制代码
export function isHeic(file: File): boolean {
  const type = file.type.toLowerCase();
  if (type.includes("heic") || type.includes("heif")) return true;
  // 浏览器经常给 .heic 一个空 type ------ 扩展名是唯一剩下的信号
  return /\.hei[cf]$/i.test(file.name);
}

file input 的 accept 出于同样原因把 MIME 和裸扩展名都列上:image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif。

整条管线的决策树

flowchart TD A[用户选了文件] --> B{MIME 或扩展名是 HEIC?} B -- 否 --> C{png/jpeg/webp 且 ≤ 10MB?} C -- 是 --> D[原样上传] C -- 否 --> E[createImageBitmap] B -- 是 --> E E -- 解码成功 --> G{有透明像素?} E -- 抛错 --> F{确实是 HEIC?} F -- 是 --> H[import heic-to 解成位图] F -- 否 --> X[拒绝:不支持] H -- 成功 --> G H -- 失败 --> X G -- 有 --> P[编码 PNG,逐级缩小到 ≤ 10MB] G -- 无 --> W[编码 WebP,质量 0.9→0.55,再逐级缩小到 ≤ 10MB]

最常见的情况------一张普通的、没超限的 JPEG------根本不碰 canvas,原样返回,交给原有的上传函数按 2048px 缩小,行为和以前完全一样。只有原本会被拒绝的文件才进入解码路径。

ts 复制代码
export async function prepareImage(file: File): Promise<PrepareOutcome> {
  const heic = isHeic(file);

  // 常见路径:普通的 JPEG/PNG/WebP 且没超限,原样返回
  if (!heic && isUploadable(file) && file.size <= MAX_UPLOAD_BYTES) {
    return { file, action: "none" };
  }

  // 既传不了也解不了的类型(PDF、GIF、SVG、TIFF...)是真正的拒绝,
  // 明说比静默失败好
  if (!heic && !isUploadable(file) && !file.type.startsWith("image/")) {
    return { file: null, action: "unsupported" };
  }

  const bitmap = await decode(file);
  if (!bitmap) return { file: null, action: "unsupported" };
  // ...按透明度选 PNG 或 WebP,然后走质量 / 缩放阶梯
}

透明度决定输出编码

WebP 更小,但这条编码路径不像 PNG 那样保留 alpha 通道;一个 logo 静默丢掉透明背景,比文件大一点糟糕得多。所以管线先探测透明度,发现任何透明像素就选 PNG。

探测是在一张 64px 的缩略副本上做的,不是全尺寸位图。对一张 4000px 的照片做 getImageData 在手机上慢到能感觉;而真实素材里的透明从来不是孤零零一个像素,64px 缩略图足够可靠。任何 alpha 小于 255 的像素都会把决定翻到 PNG。

PNG 不认质量参数,所以透明图只能靠缩放来压体积------外层循环每步按 0.8 缩放、最多 5 轮就是为它准备的。从 2048px 缩四步大约到 840px,对编辑用途已经没意义了;连这样都装不下的文件属于病态情况,报「太大」是诚实的回答。

调用方怎么接

管线的失败模式必须是「回到旧行为」,绝不能是「传不了」。这一步挡在每一次上传前面,一个能把正常上传搞坏的准备层,比没有准备层更糟。

ts 复制代码
setPhase("preparing");
let prepared: File;
try {
  const outcome = await prepareImage(file);
  if (!outcome.file) {
    setPhase("idle");
    toast.error(t.errBadType);
    return;
  }
  prepared = outcome.file;
} catch {
  // prepareImage 本身是防御式的,但万一抛错也不能把编辑器卡在 "preparing"
  prepared = file;
  setPhase("idle");
}
if (prepared.size > MAX_UPLOAD_BYTES) {
  setPhase("idle");
  toast.error(t.errTooLarge);
  return;
}
setPhase("uploading");

注意大小检查现在作用在 prepared 上------这就是修复本身。另外「preparing」阶段有一个可见的遮罩和文案,否则用户在 WASM 下载的那几秒里看到的是一个没反应的按钮。

实测结果

无头 Chrome,2026-09-22,真实文件:

  • 一个真实 HEIC 样本,288KB,type 为空。createImageBitmap 抛 InvalidStateError: The source image could not be decoded,WASM 解码器解出 1440×960,输出 WebP 534KB。
  • 一张 15.6MB、4032×3024 的 JPEG。原生解码成功,缩到 2048px 长边,第一档质量就输出 WebP 1.9MB。旧逻辑下这张图直接被拒。
  • 一张 3000×3000、含半透明区域的 PNG。检出 alpha,输出 PNG 87KB,透明通道完整。

哪些是验证过的,哪些只是预期

验证过的:Chrome 不能原生解 HEIC,兜底路径在 Chrome 上可用;上面三组转换;解码器 chunk 不在首屏加载里。

只是预期、没有测量的:Safari 的 createImageBitmap 能直接解 HEIC、不走兜底。设计不依赖这一点------Safari 如果也抛错,WASM 路径同样覆盖,只是多一次下载------但我没在 Safari 上跑过测试,所以不把「iPhone 用户零下载」当事实写。

什么时候别这么做

服务端能收 HEIC 并在服务端转,就在服务端做,客户端更薄。这套设计存在的原因是上传走的是预签名直传对象存储,服务端从头到尾看不到文件,转换没有别的地方可放。

用户几乎全在桌面端,HEIC 分支就是纯维护负担。先量再做:这次动手的触发点是一个付费用户零上传的账户,不是猜测。

以及,别对本来就合格的文件跑任何处理。整条管线最重要的一行是那个 early return------一个对每次上传都重新编码的准备层,会为了修少数人的问题静默劣化多数人的图。

FAQ

为什么不直接用 WASM 解码器,省掉原生那一步? 因为 2.9MB 在手机网络上是真实成本,而最可能持有 HEIC 的用户恰恰在手机上。原生尝试失败只花一个被拒绝的 Promise,成功则省掉整个下载。而且兜底在逻辑上严格是兜底:只有原生解码抛错且文件看起来是 HEIC 才会触发,一张损坏的 JPEG 不会引发一次无意义的 2.9MB 下载。

为什么转 WebP 而不是 JPEG? 同等视觉质量下 WebP 对照片内容更小,而且接收侧本来就以 WebP 存预览。例外是透明图,管线会选 PNG。如果你的后端只收 JPEG,把类型字符串换掉、保留质量阶梯即可,其他都不用动。

prepareImage 自己抛错了怎么办? 调用方捕获后回退到上传原文件。这一步挡在每次上传前面,它的失败模式必须是「旧行为」,而不是「没有上传」。

EXIF 方向能保住吗? 两条解码路径都能------imageOrientation: "from-image" 传给了 createImageBitmap,也通过 heic-to 的 options 转发了。这是最值得拿一张真实竖拍照片去测的细节,因为它坏了不报错,用户会怪错组件。

你们的上传链路是在客户端还是服务端处理 HEIC 的?如果在 Safari 上测过 createImageBitmap 对 HEIC 的实际行为,欢迎在评论里补上结果。

相关推荐
二月龙39 分钟前
大模型 RAG 检索增强是什么?简单讲清原理和实用价值
后端
1360967572339 分钟前
Jev 决策模型原理:为什么 Agent 循环里 80% 的判断不该交给 LLM
后端
吃饱了得干活39 分钟前
数据放哪儿:从 HashMap 到一致性哈希
java·后端
旺仔不是程序员40 分钟前
pg_trgm GIN 索引:PostgreSQL 正则、模糊与近似度查询的三合一加速器
数据库·后端·sql
一粒麦仔40 分钟前
SafeTensors vs GGUF:大模型权重格式的硬核拆解
人工智能·后端·架构
量化分析码农40 分钟前
【Python量化数据工程实战 #09】数据 Pipeline 跑了一个月才发现缺数?用质量评分卡 5 分钟定位问题
后端
1360967572340 分钟前
报错总在"跑完之后"
后端
爱勇宝40 分钟前
程序员该不该自己掏钱买Token:这笔账该怎么算
前端·后端·程序员
Bazingga40 分钟前
13张图讲透RAG:从“向量是什么”到评测体系的Spring AI实战
后端