浏览器多线程离屏渲染压缩打包方案
最近朋友跟我交流了一个场景,他有需求要用浏览器实时生成上万个二维码并打包压缩。现在功能是实现了,就是耗时长,而且一旦开始生成之后,页面卡顿的很厉害。
我一听应该是 大量的 渲染 , 转化 ,压缩 这类的计算阻塞了 Js执行主线程 导致的,于是开始尝试对方案进行优化。
首先先复现Js主线程方案
这个方案心智负担最低,无非是 Canvas 渲染转化为 blob/(or others) , jszip 添加 blob 并进行压缩, 最后下载保存,执行的代码摘要如下:
import JSZip from 'jszip'
async function download(){
const zip = new JSZip()
await new Promise((resolve)=>{
canvas.toBlob((blob) => {
zip.file(filename, blob!)
resolve(blob)
})
})
const content = await zip.generateAsync(
{
type: 'blob'
}
)
saveAs(content, zipName)
}
实现是非常简单的。笔者也复现了生成 10000 个二维码的 case ,在 qrcode 的 errorLevel 为 low , 不进行额外压缩的情况下。
每次生成图片大约 3-4kb (取决于携带参数的大小),生成时间约为 190,257.10ms ,压缩时间为 13,531ms , 总耗时 203,788.10ms 。
后来经过反复测试,得出下列几个影响因素:
- 压缩等级越高,压缩越慢
- 生成图片体积越大,生成速度越慢,压缩速度也越慢
另外像这类的高耗时的工作任务,一定要添加 onProgress 这样一个 hook ,方便用户自定义进度条来优化体验,同时也要防止用户误操作造成功亏一篑。
Worker多线程压缩
既然现在主线程被阻塞了,我们自然而然就想到了 Web Worker , 于是笔者使用它来进行压缩图片的工作。
在挑选测试素材时,使用了一张 16MB 的图片,尝试下来,压缩时间显著高于图片的生成时间。(体积较小图片其实是没有必要的,主线程本身压缩速度足够快)
worker 代码摘要如下:
import JSZip from 'jszip'
const worker: Worker = self as any
async function doZip (arraybuffer: ArrayBuffer) {
const zip = new JSZip()
const filename = 'test.png'
const blob = new Blob([arraybuffer])
zip.file(filename, blob)
const content = await zip.generateAsync(
{
type: 'arraybuffer'
},
({ percent }) => {
const event: MainWorkerEventData = {
type: 'percent',
percent
}
worker.postMessage(event)
}
)
const finish: MainWorkerEventData = {
type: 'save',
content: content
}
worker.postMessage(finish, [content])
}
worker.addEventListener('message', (event:MessageEvent<ZipWorkerRequestEventData>) => {
const data = event.data
if (data.type === 'zip') {
doZip(data.arraybuffer)
}
})
编写完成后,然后再使用webpack 的 worker-loader 加载进来使用:
import MainWorker from 'worker-loader!@/workers/main.worker'
此时页面代码摘要为:
canvas.toBlob((blob)=>{
blob.arrayBuffer().then((ab)=>{
const message: ZipWorkerRequestEventData = {
type: 'zip',
arraybuffer: ab
}
worker.postMessage(message, [ab])
})
})
worker.onmessage = (event: MessageEvent<ZipWorkerResponseEventData>) => {
const data = event.data
if (data.type === 'save') {
const blob = new Blob([data.content as ArrayBuffer])
saveAs(blob, 'test.zip')
} else if (data.type === 'percent') {
percent.value = data.percent ?? 0
}
}
有点要特别注意: postMessage 推荐使用对象转移的写法。这里要解释一下,主线程与 Web Workers 之间的通信,并不是对象引用的传递,而是序列化/反序列化的过程,当对象非常庞大时,序列化和反序列化都会消耗大量计算资源,降低运行速度。对象转移就是将对象引用零成本转交给 Web Workers 的上下文,而不需要进行结构拷贝。
需要注意的是,对象引用转移后,原先上下文就无法访问此对象了,需要在 Web Workers 再次将对象还原到主线程上下文后,主线程才能正常访问被转交的对象。
其中可以进行引用转移的对象,只有:
ArrayBuffer MessagePort ReadableStream WritableStream TransformStream AudioData ImageBitmap VideoFrame OffscreenCanvas
详见 MDN文档
这也是我们把图片转化为 ArrayBuffer 对象进行传输的主要原因。不然复制一份数据,既浪费内存又浪费算力。
通过这种方案,就把压缩这一部分的计算转移到了 Web Worker 那里去,从而避免了JS主线程的阻塞。但是这适用场景不是那么多,因为这对于批量小图片的压缩打包,收益不是很大,另外生成图片也非常耗时耗资源,这一部分并没有解决,所以接下来我们尝试把 Canvas 图片渲染生成 也放入 Web Worker 那里去进行做。
OffscreenCanvas Worker 离屏渲染
- Web Worker 全局作用域中不存在 Image
- 有绘制过的 Canvas 无法转化为 OffscreenCanvas, 会报出:
Failed to execute ‘transferControlToOffscreen’ on ‘HTMLCanvasElement’: Cannot transfer control from a canvas that has a rendering context. 错误
方案
首先添加 @types 以引入智能提示: yarn add -D @types/offscreencanvas
另外确立js主线程和 web worker 之间的传输以 ImageBitmap 对象的形式进行图像传递。
为什么?
首先 ImageBitmap 是 Transferable (见上部分Worker多线程压缩:引用转移介绍)
同时创建 ImageBitmap 的 API: createImageBitmap 方法同时存在 windows 和 workers 中. 它接受各种不同的图像来源。例如它可以处理我们最常使用的 HTMLImageElement 对象。
这里我以 ImageBitmap 为例构建 Web Worker 部分代码:
async function getCanvasBlob(bitmap: ImageBitmap): Promise<Blob> {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D
ctx.drawImage(bitmap, 0, 0)
return await canvas.convertToBlob()
}
这部分代码目的在于取代主线程中创建的 HTMLCanvasElement 对象,更换为 Web Worker 中创建的 OffscreenCanvas 对象。
主线程只负责创建 ImageBitmap 和 ArrayBuffer 这类的 Transferable_objects 来和 Worker 进行数据通信。
并最终 Worker 输出一个 ArrayBuffer 交给主线程组装二进制对象并进行浏览器下载行为。
优雅的兼容性降级方案
方案主要存在 2 个可能不兼容的点:
OffscreenCanvas
OffscreenCanvas 除了 chormuim 内核的那一批浏览器(chrome.edge )支持较好之外,像 FF ,Safari ,ie 均不支持 (意外的是 opera 居然支持) 附Browser compatibility
Web Worker
Web Worker 大部分都支持(包括 ie10+ ) , 不过各个浏览器支持的特性存在一些细小的差异。 附Browser compatibility
兼容标志位
export const isSupportWorker = Boolean(window.Worker)
export const isSupportOffscreenCanvas = Boolean(window.OffscreenCanvas)
- 优先去除兼容性最差的
OffscreenCanvas - 次级去除
Web Worker , 只使用Js主线程
方案示例见 源代码
方案总结
优点:
- 处理大体积图片的性能更优
- 不阻塞主js线程
缺点:
- 兼容性差, 需要降级方案
- 实现相比单线程版本较为复杂,心智负担较重,容易出bug
- 处理小图片可能不如主线程
- 不同浏览器性能差异较大,难以统一用户体验
源代码和参考文献
Source Code
OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker
Speedy Introduction to Web Workers
Web Worker传输大量Transferable对象时的性能问题
transferControlToOffscreen
|