背景
商家上传 Gif 作为商品图,过程中转为 WebP 格式已优化了约 60% 的体积,但仍接近 2M 的图片对于网站加载过程中用户体验无疑是一场灾难。因此从商家侧上传 Gif 时进行截取首帧作为动图的封面图,在网站加载中预显示,优化用户体验。
探索
Gif 解帧网上也有多种方案,若限定于浏览器端环境的话,可选的其实也就不多。
GIFrame
业界一些类似于 GIFrame 的库可在浏览器端进行解帧,同时无需解析整个 Gif 图片数据,可快速拿到首帧数据(100+ms)。但解析器工作主要运行在 JS 主线程,JS Heap 约 20+m,挺耗性能的任务。
HTMLImageElement + HTMLCanvasElement
一种相对直接简单粗暴而兼容性可靠的方式,例如
_15const reader = new FileReader()_15reader.readAsDataURL(event.target.files[0])_15await new Promise((resolve) => (reader.onload = resolve))_15const image = new Image()_15await new Promise(resolve => {_15 image.src = reader.result_15 image.onload = resolve_15})_15const canvas = document.getElementById('canvas')_15const ctx = canvas.getContext('2d')_15canvas.width = image.width_15canvas.height = image.height_15ctx.drawImage(image, 0, 0, image.width, image.height)_15const base64 = canvas.toDataURL('image/png', 1.0)_15console.log(base64)
与上一种方式对比,避免了 Gif JS 解析器在主线程的性能消耗,但测试对比发现拿到帧的数据的明显会慢些(800+ms),怀疑 toDataURL
同步转换格式、转换字符串等逻辑影响,所以改为
_17const reader = new FileReader()_17reader.readAsDataURL(event.target.files[0])_17await new Promise((resolve) => (reader.onload = resolve))_17const image = new Image()_17await new Promise(resolve => {_17 image.src = reader.result_17 image.onload = resolve_17})_17const canvas = document.getElementById('canvas')_17const ctx = canvas.getContext('2d')_17canvas.width = image.width_17canvas.height = image.height_17ctx.drawImage(image, 0, 0, image.width, image.height)_17const blob = await new Promise((resolve) => canvas.toBlob((blob) => {_17 resolve(blob);_17}))_17document.getElementById('img').src = URL.createObjectURL(blob)
消耗的时间仍差别不大,后续调试发现最长的耗时在于 image.onload
解析 base64 部分(700+ms),再改为
_17const reader = new FileReader()_17reader.readAsArrayBuffer(event.target.files[0])_17await new Promise((resolve) => (reader.onload = resolve))_17const image = new Image()_17await new Promise(resolve => {_17 image.src = URL.createObjectURL(new Blob([reader.result]));_17 image.onload = resolve_17})_17const canvas = document.getElementById('canvas')_17const ctx = canvas.getContext('2d')_17canvas.width = image.width_17canvas.height = image.height_17ctx.drawImage(image, 0, 0, image.width, image.height)_17const blob = await new Promise((resolve) => canvas.toBlob((blob) => {_17 resolve(blob);_17}))_17document.getElementById('img').src = URL.createObjectURL(blob)
多次测试平均整体耗时 50+ms,image.onload
解析 Blob 仅花费了 10+ms,这部分方案梳理发布到 NPM 库 — Gifff。另外这种方式确实能成功导出一帧,但是否首帧?这个问题就留给各位思考了🤔
WebWorker + OffscreenCanvas + ImageBitmap
尝试一下 WebWorker 生态环境,例如
_10// worker.js_10const imageBitmap = await createImageBitmap(blob)_10const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height)_10const ctx = canvas.getContext('2d')_10ctx.drawImage(imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height)_10const blob = await canvas.convertToBlob({ type: 'image/png' })_10self.postMessage(blob)
但查了一下 caniuse,兼容性的问题算是无解了。


WebAssembly + Rust
先查了 caniuse,主流 PC 端 Chrome、Safari 版本 WebAssembly 能被支持,是个不错的开始。

按照之前的逻辑编译为 Rust 代码,例如
_25#[wasm_bindgen]_25pub fn wasm_memory() -> JsValue {_25 wasm_bindgen::memory()_25}_25_25#[wasm_bindgen]_25pub fn alloc(size: usize) -> *mut u8 {_25 let mut buf = Vec::with_capacity(size);_25 let ptr = buf.as_mut_ptr();_25 mem::forget(buf);_25 ptr_25}_25_25#[wasm_bindgen]_25pub async fn parse(ptr: *mut u8, size: usize) -> Result<JsValue, JsValue> {_25 let bytes = unsafe { Vec::from_raw_parts(ptr, size, size) };_25 let image = match image::load_from_memory_with_format(&bytes, ImageFormat::Gif) {_25 Ok(image) => image,_25 Err(err) => return Err(JsValue::from(Error::new(&format!("{:?}", err)))),_25 };_25 let mut u8: Vec<u8> = Vec::new();_25 image.write_to(&mut u8, ImageOutputFormat::Png);_25 let base64 = format!("{},{}", "data:image/png;base64", base64::encode(u8));_25 Ok(JsValue::from_str(base64.as_str()))_25}
_10const reader = new FileReader()_10reader.readAsArrayBuffer(file)_10const buf = await new Promise(resolve => reader.onload = () => {_10 resolve(reader.result as ArrayBuffer)_10})_10const size = buf.byteLength_10const ptr = alloc(size)_10const imgArray = new Uint8Array(wasm_memory().buffer, ptr, size)_10imgArray.set(new Uint8Array(buf))_10const base64 = await parse(ptr, size)
多次测试平均整体耗时 160+ms,这部分方案梳理发布到 NPM 库 — Gifff。