iPhone 默认拍出来的是 HEIC。用户把照片往网页上一拖,Windows 上的 Chrome 显示不了,后端也不认,于是很多项目会在前端加一步「先转成 JPG」。
这一步看起来就是装个库、调一个函数。我拿一张真实的 HEIC 跑了一遍,坑比想象的多。下面每一条都是在 Chrome 154(macOS)里实测的,测试图是 online-convert.com 按 CC BY 4.0 提供的 HEIC 样例:iPad Pro 拍摄,4032 × 3024,2.0 MB,带拍摄时间和设备型号,色彩配置是 Display P3。转换库用的是最常见的 heic2any 0.0.4。
坑一:Chrome 根本解不开 HEIC,别指望原生
第一反应是先试原生解码,能用就不用加库:
js
async function canDecodeNatively(blob) {
try {
const bmp = await createImageBitmap(blob)
bmp.close()
return true
} catch {
return false
}
}
Chrome 里直接抛 InvalidStateError。HEIC 用的是 HEVC 编码,涉及专利授权,Chrome 和 Firefox 都没有内置解码。Safari 是例外,新版本能直接解。
所以这段检测有意义,但意义在 Safari:能原生解就不用加载几百 KB 的库。其他浏览器只能走 WebAssembly 解码。
坑二:库有 1.3 MB,必须按需加载
heic2any 的 min.js 是 1,351,840 字节,gzip 之后 340 KB 左右。它把 libheif 编译成了 JS/WASM 一起打包,这个体积压不下去。
如果在页面入口 import heic2any from 'heic2any',所有用户都要为极少数上传 HEIC 的人付这 340 KB。正确做法是检测到 HEIC 再动态加载:
js
async function toJpeg(file) {
if (!(await isHeic(file))) return file
if (await canDecodeNatively(file)) return file // Safari 走原生
const { default: heic2any } = await import('heic2any')
const out = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 })
return new File([out], file.name.replace(/\.hei[cf]$/i, '.jpg'), { type: 'image/jpeg' })
}
Vite、webpack 都会把动态 import 拆成单独的 chunk,首屏不受影响。
坑三:别靠 file.type 判断是不是 HEIC
file.type 是浏览器根据扩展名和系统的类型映射猜出来的,不是读文件内容。HEIC 在不少环境下没有登记 MIME 类型,file.type 就是空字符串;反过来,用户把 .jpg 改名成 .heic,type 也会跟着变。
稳一点的办法是看文件头。HEIC 是 ISO BMFF 容器,第 4 到第 8 个字节是 ftyp,后面 4 个字节是品牌。我那张样图的前 32 字节:
00000004: 6674 7970 6865 6963 0000 0000 6d69 6631 ftypheic....mif1
判断函数可以这么写(示意):
js
const HEIF_BRANDS = ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1']
async function isHeic(file) {
if (/\.(heic|heif)$/i.test(file.name)) return true
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer())
const box = String.fromCharCode(...head.slice(4, 8))
const brand = String.fromCharCode(...head.slice(8, 12))
return box === 'ftyp' && HEIF_BRANDS.includes(brand)
}
注意 mif1 也会出现在 AVIF 文件的兼容品牌里,只看主品牌(第 8 到 12 字节)时一般没问题,要更严格就把 avif 主品牌排除掉。
坑四:转完反而大了七成
这个最反直觉。2.0 MB 的 HEIC,quality 0.92 转出来的 JPG 是 3.41 MB:
| 输出 | 体积 | 相对原图 |
|---|---|---|
| HEIC 原图 | 2.0 MB(2,049,547 字节) | --- |
| JPG,quality 0.92 | 3.41 MB | +75% |
| JPG,quality 0.6 | 1.59 MB | −19% |
| PNG | 19.1 MB | 约 9.8 倍 |
原因不复杂:HEVC 的压缩效率比 JPEG 高得多,同样观感 JPG 天然要更多字节。quality 定得越高,涨得越多。PNG 是无损的,1200 万像素直接 19 MB,基本不能用。
所以「转格式」和「控制体积」最好放在一起考虑。如果后面还有上传,quality 设到 0.8 左右,或者转完再走一遍按目标大小压缩,别让用户上传一张比原图还大的图。
坑五:拍摄时间、设备型号全没了
用十六进制看 heic2any 输出的 JPG,段结构是 APP0(JFIF) → APP2(ICC_PROFILE) → DQT → SOF0 ...,没有 APP1,也就是没有 EXIF。原图里的拍摄时间 2017:09:27 10:40:47、设备型号 iPad Pro (10.5-inch) 全部丢了。
对照组:同一张图在 macOS 上用 sips 转 JPG,这两项都还在。
丢 EXIF 的原因是这类库的流程本质上是「解码成像素 → 画到 canvas → canvas 导出 JPG」,canvas 只认像素,元数据在第一步就扔了。
这件事有两面:
- 坏处:相册、网盘按拍摄时间排序会乱,照片管理类的场景基本不能接受;
- 好处:GPS 坐标也一起被扔掉了。用户发到公开页面的照片,不带定位反而更安全。
如果业务需要保留,思路是转换前先把 EXIF 读出来,转完再写回 JPG。读 HEIC 的 EXIF 可以用 exifr 这类支持 HEIC 的库,写回 JPG 可以用 piexifjs。这两个库的组合我没有在这次测试里跑,只给方向。写回时记得把 Orientation 设成 1:像素已经按正确方向解出来了,再带一个旋转标记会被转两次。
坑六:转一张要 8 到 10 秒,得给进度
4032 × 3024 这张图,heic2any 转换在我这台机器上测了几次,在 8 到 10.5 秒之间。
我同时开了一个 50ms 的 setInterval 看主线程有没有被卡住:8 秒里跑了 155 次(理论上限约 160 次),最长一次间隔 264ms。说明解码大部分不在主线程上,页面不会整体卡死,但最后导出那一下会有一次明显的顿挫。
要说明一下,我测的是 CDP 控制下的 Chrome,绝对耗时受机器负载影响很大,只看量级:秒级,不是毫秒级。所以 UI 上一定要给「正在转换」的提示,不然用户以为没反应会重复拖拽;批量转多张时最好串行或者限并发,同时解几张 1200 万像素的图,内存会一下子涨上去。
顺带说一个没踩到的坑:色彩空间
这张样图是 Display P3 的,我原本担心解码时没做色彩管理,转出来会发灰。用 400 × 300 缩略图算了平均饱和度:heic2any 输出 0.121,macOS 系统转换(转到 sRGB)是 0.114,肉眼看不出差别。这张图本身颜色就淡,鲜艳的照片我没测,不敢下结论。
最后
把上面几条拼起来,前端 HEIC 转 JPG 的流程大概是:按文件头判断 → Safari 走原生 → 其他浏览器动态加载 WASM 库 → quality 别设太高 → 需要的话补回 EXIF → 全程给进度提示。
如果只是自己偶尔转几张、不想写代码,可以用 forxi.cn 的 HEIC 转 JPG,它就是在浏览器本地用 heic2any 转的,照片不上传。上面的坑它也一样有:我拿同一张图试,转出来是 3.41 MB,比原图大;拍摄时间也不会保留。转完可以接着用同页的压缩把体积压下来,下载时也能选 WebP。
