本文由 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。
整条管线的决策树
最常见的情况------一张普通的、没超限的 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 的实际行为,欢迎在评论里补上结果。