所有浏览器端的WebP转换器,底层几乎都写着同一行代码:canvas.toBlob(blob => { /* ... */ }, 'image/webp', quality)。看起来是个合理的起点,但它藏着一个让人尴尬很久才发现的失败模式。

Safari从来没有支持过通过canvas编码WebP。它解码WebP没问题——Safari 14就落地了——但编码这件事,它不做。

打开网易新闻 查看精彩图片

关键在这里:toBlob不抛错,也不返回null。HTML规范写明,如果请求的类型不支持,浏览器会回退到image/png。所以在Safari上,上面那行代码递给你的是一个完全有效的PNG。如果你把下载文件命名为photo.webp——你当然会这么命名,因为你明明要的是WebP——你就把一个扩展名错误的PNG发给了用户,而用户要等到下游某个环节拒绝它时才会发现。

没有报错,没有警告,只有错误的文件。

唯一能发现问题的办法

想知道自己拿到的是什么,只能检查返回值:

const blob = await canvas.convertToBlob({ type: mime, quality: quality / 100 })if (blob.type !== mime) {throw new Error(`This browser cannot encode ${format} via canvas. ` +`Safari does not support it — the WebAssembly encoder is required.`}

这个检查就是整篇文章存在的原因。一旦你接受canvas在编码这件事上不可信,你就得自己把编解码器打包进去——把libwebp编译成WebAssembly。有意思的问题从这里才开始。

六个最耗时间的坑

第一个坑非常不直观,因为exclude和include解决的是不同的问题,而你需要同时用上它们:

optimizeDeps: {exclude: ['@jsquash/webp/encode', '@jsquash/webp/decode', /* ... */],include: ['utif2', 'libheif-js/wasm-bundle', 'client-zip'],}

exclude是给jSquash编解码器用的。Vite的依赖预打包器会重写它们的Emscripten胶水代码,重写后的胶水代码无法再解析同级的.wasm文件。预打包会弄坏它们,所以你要选择退出。

include是给utif2(TIFF)和libheif-js(HEIC)用的。这些是CommonJS,而预打包正是把CJS转成浏览器可用ESM的那一步。如果退出这一步,运行时就会报UTIF.decode is not a function

所以一个列表的意思是“别碰这些”,另一个的意思是“你必须碰这些”。选错了,构建都会坏。区分的关键在于:这个包是否在运行时加载单独的.wasm文件——jSquash会,而libheif-js/wasm-bundle把它内联了,所以预打包在那里是安全的。

还有一个陷阱:Vite匹配的是精确的模块标识符。列出@jsquash/webp并不会覆盖@jsquash/webp/encode。你导入的每个子路径都必须单独列出来。

让人想摔电脑的bug

编解码器是在worker内部懒加载的,这个设计本身没问题,但配合前面的配置问题,排查起来就像在迷宫里找出口。文件类型对不上、构建结果时好时坏、运行时才报错——这些症状叠加在一起,足够让人怀疑人生。

核心教训其实就一条:canvas的编码能力不能假设,必须验证。Safari的静默回退只是第一层,真正的工作量在于把WebAssembly编解码器正确接进构建流程,而Vite的预打包机制在这里埋了不止一个雷。