最近朋友跟我交流了一个场景,他有需求要用浏览器实时生成上万个二维码并打包压缩。现在功能是实现了,就是耗时长,而且一旦开始生成之后,页面卡顿的很厉害。
我一听应该是 大量的 渲染
, 转化
,压缩
这类的计算阻塞了 Js执行主线程
导致的,于是开始尝试对方案进行优化。
这个方案心智负担最低,无非是 Canvas
渲染转化为 blob/(or others)
, jszip
添加 blob
并进行压缩,最后下载保存,执行的代码摘要如下:
import JSZip from 'jszip'
async function download(){
// canvas do sth
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
,方便用户自定义进度条来优化体验,同时也要防止用户误操作造成功亏一篑。
既然现在主线程被阻塞了,我们自然而然就想到了 Web Worker
, 于是笔者使用它来进行压缩图片的工作。
在挑选测试素材时,使用了一张 16MB
的图片,尝试下来,压缩时间显著高于图片的生成时间。(体积较小图片其实是没有必要的,主线程本身压缩速度足够快)
worker
代码摘要如下:
// main.worker.ts
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'
此时页面代码摘要为:
// vue3 ts
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
那里去进行做。
- 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
部分代码:
// main.work.ts
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)
// do sth
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
// 是否支持 web worker
export const isSupportWorker = Boolean(window.Worker)
// 是否支持 OffscreenCanvas
export const isSupportOffscreenCanvas = Boolean(window.OffscreenCanvas)
OffscreenCanvas
Web Worker
, 只使用 Js 主线程方案示例见 源代码
OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker
Speedy Introduction to Web Workers