去年做一个需求,用户上传一张几十兆的动图(有的是几百帧的 GIF、有的是 APNG),前端要把每一帧抽出来做处理再重新拼。我一开始的做法是老三样:<img> 加载、画到 canvas、getImageData 抠像素。结果动图只能拿到「当前正在播的那一帧」,抽不全;换成后端拆帧又觉得杀鸡用牛刀。折腾一圈才发现,浏览器早就给了一套更底层的东西------WebCodecs。
这篇把我用 WebCodecs 的 ImageDecoder 和 VideoFrame 做帧级处理的经验梳理一遍:怎么逐帧解码、怎么把解码结果直接喂给 canvas、close() 不调会怎样、和传统 createImageBitmap 比性能差多少、以及那些浏览器还没跟上的地方怎么降级。面向的是被传统图片/视频 API 卡过、想往底层走一层的前端同学。
传统方案卡在哪
先说清楚为什么要动 WebCodecs,不然就是为了新而新。
传统方案里,你想从一个图片文件拿到像素,链路大概是:fetch 拿 blob → createImageBitmap(blob) 或者塞进 <img> → 画到 canvas → getImageData。这套对「一张静态图」够用,但有两个硬伤。
一是动图和多帧 。<img> 和 createImageBitmap 对一个 GIF/动画 WebP,默认只给你一帧(通常是第一帧或当前播放帧),你没有官方 API 逐帧索引。想抽全部帧,过去只能上第三方 JS 解码库,纯 JS 解,慢且吃内存。
二是解码这一步的开销藏在暗处 。createImageBitmap 到底走没走硬件、什么时候真正解码、解码占了多少时间,你控制不了也测不准。图一大,主线程就莫名其妙卡一下。
WebCodecs 的思路不一样:它把「解码」这个动作显式暴露成 API,让你决定解什么、解第几帧、解完的帧对象(VideoFrame)什么时候释放。浏览器底层能用硬件解码器就用硬件,你拿到的是一个可以直接被 canvas 消费的帧句柄。控制权回到了你手上。
ImageDecoder:逐帧解码动图
先看图片这条线。ImageDecoder 专门用来解码图片,尤其是多帧动图,一个 API 就把「这图有几帧、我要第几帧」讲清楚了。
javascript
async function decodeAllFrames(blob) {
// isTypeSupported 是异步的,先确认这个格式能解
const type = blob.type; // 例如 'image/gif' / 'image/webp'
if (!('ImageDecoder' in window) ||
!(await ImageDecoder.isTypeSupported(type))) {
throw new Error('当前环境不支持解码 ' + type);
}
const decoder = new ImageDecoder({ data: blob.stream(), type });
// 等元数据 ready,才知道总帧数
await decoder.tracks.ready;
const track = decoder.tracks.selectedTrack;
const frameCount = track.frameCount; // 动图总帧数
const frames = [];
for (let i = 0; i < frameCount; i++) {
const { image, complete } = await decoder.decode({ frameIndex: i });
// image 是一个 VideoFrame,注意------图片解出来也是 VideoFrame 类型
frames.push(image);
}
return { frames, decoder };
}
有几个点第一次用会踩:
decoder.decode({ frameIndex: i }) 返回的 image 字段类型是 VideoFrame------对,图片帧和视频帧在 WebCodecs 里统一成同一种对象,这是它设计上很聪明的地方,后面处理逻辑就能复用。
decoder.tracks.ready 这个 Promise 一定要 await,不 await 直接读 frameCount 大概率是 0 或者 undefined,因为元数据还没解析出来。我第一次就栽在这,循环压根没进去还以为图坏了。
decode() 返回里还有个 complete 字段,标记这一帧的数据是不是已经完整解出来了。走流式解码时,元数据可能先到、像素数据后到,complete 为 false 时你拿到的是能显示但还没解全的中间态。大多数场景你等整图 buffer 到齐再解就不用管它;但如果做「边下边解、渐进展示」,这个字段就是判断要不要重解的依据。
还有 data: blob.stream()------传的是 ReadableStream,ImageDecoder 支持流式喂数据,大图不用一次性全读进内存。当然你也可以传 ArrayBuffer,看场景。两者的差别在首帧延迟:流式能更早吐出第一帧,全量 buffer 则省去流的调度开销,短图差别不大,长动图流式更跟手。
VideoFrame 直接画到 Canvas
拿到 VideoFrame 之后,最爽的一点是它能被 drawImage 直接接受,中间不用再转 ImageBitmap:
javascript
function drawFrameToCanvas(videoFrame, canvas) {
const ctx = canvas.getContext('2d');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
// VideoFrame 是 CanvasImageSource,drawImage 原生支持
ctx.drawImage(videoFrame, 0, 0);
// 画完就可以做你的像素处理了
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// ...处理 imageData...
return imageData;
}
注意读尺寸要用 displayWidth / displayHeight,不是 codedWidth。视频编码里存的分辨率(coded)常常带对齐 padding,比如实际 1080 的高,编码里可能是 1088,直接拿 coded 去画会多出几行黑边或者错位。display 才是「肉眼该看到的」尺寸。
close() 不调,显存会漏
这是 WebCodecs 和普通 JS 对象最不一样、也最容易翻车的地方:VideoFrame 不归 GC 管。
VideoFrame 背后是一块解码后的图像缓冲,很可能就在显存里。JS 的垃圾回收管不了这块显存------它只知道回收 JS 那个包装对象,底层缓冲得你手动 close() 才释放。你要是像我上面循环那样把几百个 VideoFrame 全 push 进数组屯着,不 close,分分钟内存/显存爆掉,浏览器要么疯狂 GC 卡顿,要么直接把标签页干崩。
正确姿势是:处理完一帧立刻 close,别囤。
javascript
async function processFrames(blob, onFrame) {
const decoder = new ImageDecoder({ data: blob.stream(), type: blob.type });
await decoder.tracks.ready;
const frameCount = decoder.tracks.selectedTrack.frameCount;
for (let i = 0; i < frameCount; i++) {
const { image } = await decoder.decode({ frameIndex: i });
try {
onFrame(image, i); // 同步处理这一帧:画到 canvas、抽像素、编码等
} finally {
image.close(); // 关键:无论成功失败都释放这一帧
}
}
decoder.close(); // 解码器本身也要关
}
我的经验是把 close() 塞进 finally,处理逻辑再复杂也不会漏。如果确实需要留住某几帧异步用,那就明确记账------哪些留了、什么时候统一 close,别指望 GC 兜底。开发时可以留意 console 里的告警,浏览器对没 close 的 VideoFrame 有时会给「被 GC 时仍未关闭」的提示,看到就是漏了。
我踩过一次很隐蔽的坑:把帧丢进一个 Promise.all 的异步管线里并发处理,每个子任务里 close,逻辑看着没问题。但其中一个子任务抛了异常,那一支的 close 没执行,单帧泄漏。测试图小、只跑几十帧,内存曲线看不出来;线上用户传了个几百帧的大动图,同样的异常概率放大,标签页内存一路涨到崩。后来把每个子任务内部也各自 try/finally 包严,才算根治。结论就一句:VideoFrame 的释放要当成文件句柄那样对待,每条可能的退出路径都得覆盖到,别信「大概率会走到 close」。
顺带说,VideoFrame 上有个 allocationSize() 能估算这帧占多少字节,调试内存时拿它打点很有用,能直观看到你到底囤了多少显存。
和 createImageBitmap 比,快在哪
聊性能。我在一台普通笔记本上,拿一张两百多帧的动画 WebP 做过对比,两条链路:
- 老路:把每帧数据切出来,逐帧
createImageBitmap→ drawImage; - 新路:
ImageDecoder逐帧 decode → drawImage。
差距主要不在「画」,而在「解码」这一步。ImageDecoder 底层能命中平台的硬件/优化解码路径,解 200 多帧的总耗时明显低于纯 JS 解码库那套;而 createImageBitmap 对多帧动图本身就不给你逐帧能力,你得先自己拆容器格式,光这一步 JS 就够喝一壶。
对单张静态大图 ,说句公道话,两者差距没那么夸张------createImageBitmap 本来就够优化,日常一张 JPG 你没必要为了它上 WebCodecs。WebCodecs 的价值集中在:多帧、需要逐帧精确控制、需要显式管理解码时机和内存的场景。别拿它当银弹到处套。
还有个隐性收益:解码可以搬去 Worker。ImageDecoder、VideoFrame 都能在 Web Worker 里用,配合 OffscreenCanvas,整条「解码 + 处理」链路彻底离开主线程。
OffscreenCanvas + Worker:不卡主线程
VideoFrame 和 OffscreenCanvas 都是 Transferable,能过 postMessage 传给 Worker(VideoFrame 传过去后原线程那份就失效了,属于所有权转移)。真正吃重的解码和像素运算放 Worker,主线程只管调度和 UI:
javascript
// worker.js
self.onmessage = async (e) => {
const { blob } = e.data;
const decoder = new ImageDecoder({ data: blob.stream(), type: blob.type });
await decoder.tracks.ready;
const count = decoder.tracks.selectedTrack.frameCount;
const offscreen = new OffscreenCanvas(1, 1);
const ctx = offscreen.getContext('2d');
for (let i = 0; i < count; i++) {
const { image } = await decoder.decode({ frameIndex: i });
offscreen.width = image.displayWidth;
offscreen.height = image.displayHeight;
ctx.drawImage(image, 0, 0);
image.close();
// 处理后可以把结果 bitmap 转出去
const bitmap = offscreen.transferToImageBitmap();
self.postMessage({ index: i, bitmap }, [bitmap]);
}
decoder.close();
};
主线程收到 bitmap 直接 drawImage 上屏就行。整个过程主线程零解码负担,滑块、动画该多顺多顺。
视频那条线:VideoDecoder
图片说完,顺带提一句视频。抽视频帧走的是 VideoDecoder,比 ImageDecoder 复杂,因为你得先用 demuxer(比如 mp4box.js 这类库)把容器解出编码包(EncodedVideoChunk),再喂给解码器:
javascript
const decoder = new VideoDecoder({
output: (frame) => {
// 每解出一帧回调一次,frame 是 VideoFrame
drawFrameToCanvas(frame, myCanvas);
frame.close(); // 老规矩,用完就关
},
error: (e) => console.error('解码出错', e),
});
// config 通常从 demuxer 拿(codec 字符串、描述信息等)
decoder.configure({ codec: 'avc1.42E01E' /* 从容器解析得到 */ });
// 把 demux 出来的编码包逐个喂进去
decoder.decode(new EncodedVideoChunk({
type: 'key', // 'key' 关键帧 / 'delta' 增量帧
timestamp: 0,
data: chunkBytes,
}));
VideoDecoder 是回调式(output 里拿帧),异步吐帧,跟 ImageDecoder 的「按 index 拉」是两种模型。视频这块坑更深(时间戳、关键帧、seek),容器解析还得配库,这篇就不展开了,重点是让你知道:图片和视频最终都归到同一个 VideoFrame 上,处理代码能统一。
能力检测与降级
WebCodecs 的浏览器支持这两年好多了,但不能默认都有,尤其 ImageDecoder 对具体格式的支持还得实测。降级要分两层判断:
javascript
async function pickDecodePath(blob) {
// 第一层:API 存在性
const hasWebCodecs = typeof window !== 'undefined' && 'ImageDecoder' in window;
// 第二层:具体格式是否可解(异步,务必 await)
const typeOk = hasWebCodecs &&
await ImageDecoder.isTypeSupported(blob.type).catch(() => false);
if (typeOk) return 'webcodecs';
// 退回传统路径:静态图用 createImageBitmap,动图老老实实用 JS 解码库
if ('createImageBitmap' in window) return 'imagebitmap';
return 'img-tag'; // 最后兜底,塞 <img>
}
isTypeSupported 是异步返回 Promise 的,别写成同步 if,我见过同事直接 if (ImageDecoder.isTypeSupported(...)) 判断一个 Promise 恒为真,等于没判断。用 .catch(() => false) 兜一下,某些实现对没见过的 MIME 会抛异常而不是返回 false。
降级策略上我的建议:静态图检测失败就回 createImageBitmap,用户基本无感;多帧动图这种 WebCodecs 才有优势的场景,降级到 JS 解码库时记得给个 loading 或者进度提示,因为那条路确实慢,体验有落差,别让用户以为卡死了。另外别把检测结果写死缓存一整个会话,同一浏览器不同格式的支持度不一样,isTypeSupported 要按实际 MIME 逐个问,别图省事一次判断全局复用。
诚实的边界
这套东西好用,但我不想把它吹成没缺点。几个我实际撞到的坑,摆出来:
格式支持不是全都有。 ImageDecoder 能解 GIF/PNG/JPEG/WebP 这些主流的,但冷门格式、以及某些平台上的 AVIF 动图,isTypeSupported 可能给 false。上线前一定针对你的目标格式在目标浏览器实测,别信「文档说支持」。
内存管理是纯手动,没有安全网。 close() 漏一处,测试环境可能看不出来(图小、帧少),一到线上真实大图就崩。写的时候就得把生命周期理清楚,finally 里 close 是底线。这跟平时写 JS 那种「交给 GC」的松弛感完全不同,得端着点。
iOS/Safari 上历史包袱多。 老版本 Safari 对 WebCodecs 支持来得晚、ImageDecoder 的覆盖也参差,移动端占比高的项目务必先看你的用户版本分布,降级路径要真跑通,不能只是写了个 else 分支没测过。
不是所有场景都值得上。 单张静态图、就画一次、不逐帧,用 createImageBitmap 反而更简单直接。WebCodecs 是给「多帧 / 逐帧控制 / 显式管理解码和内存」这类需求准备的,杀鸡别用牛刀,维护成本也是成本。
demux 得自己配。 视频那条线,WebCodecs 只管解码,不管容器解析,mp4/webm 拆包还得配库,这块工作量别低估。
我自己的取舍是:图片多帧、需要精确抽帧、要在 Worker 里离屏处理------上 WebCodecs,收益明确;普通静态图展示------老 API 更省心。工具选对场景比追新重要。想找现成的在线工具练手比对结果,随手搜一下 tudingai.cn 之类的站点也够用,但真到工程里,还是得自己把这套解码-释放的账算清楚。
底层 API 的爽点在于控制权,代价是责任也一并给了你。close() 这行别忘。