此页面需要javascript支持,请在浏览器中启用javascript

浏览器多线程离屏渲染压缩打包方案

浏览器
多线程
离屏渲染
压缩
打包
共1731个字,阅读时间 9 分钟
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://icebreaker.top/articles/2021/12/8-web-worker-jszip

Image

浏览器多线程离屏渲染压缩打包方案

最近朋友跟我交流了一个场景,他有需求要用浏览器实时生成上万个二维码并打包压缩。现在功能是实现了,就是耗时长,而且一旦开始生成之后,页面卡顿的很厉害。

我一听应该是 大量的 渲染, 转化,压缩 这类的计算阻塞了 Js执行主线程 导致的,于是开始尝试对方案进行优化。

首先先复现 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,在 qrcodeerrorLevellow , 不进行额外压缩的情况下。

每次生成图片大约 3-4kb(取决于携带参数的大小), 生成时间约为 190,257.10ms , 压缩时间为 13,531ms, 总耗时 203,788.10ms

后来经过反复测试,得出下列几个影响因素:

  • 压缩等级越高,压缩越慢
  • 生成图片体积越大,生成速度越慢,压缩速度也越慢

另外像这类的高耗时的工作任务,一定要添加 onProgress 这样一个 hook,方便用户自定义进度条来优化体验,同时也要防止用户误操作造成功亏一篑。

Worker 多线程压缩

既然现在主线程被阻塞了,我们自然而然就想到了 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)
  }
})

编写完成后,然后再使用webpackworker-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 那里去进行做。

OffscreenCanvas Worker 离屏渲染

  1. Web Worker 全局作用域中不存在 Image
  2. 有绘制过的 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 对象的形式进行图像传递。

为什么?

首先 ImageBitmapTransferable(见上部分 Worker 多线程压缩:引用转移介绍)

同时创建 ImageBitmap 的 API: createImageBitmap 方法同时存在 windowsworkers 中。它接受各种不同的图像来源。例如它可以处理我们最常使用的 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 对象。

主线程只负责创建 ImageBitmapArrayBuffer 这类的 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)
  1. 优先去除兼容性最差的OffscreenCanvas
  2. 次级去除 Web Worker , 只使用 Js 主线程

方案示例见 源代码

方案总结

优点:

  1. 处理大体积图片的性能更优
  2. 不阻塞主 js 线程

缺点:

  1. 兼容性差,需要降级方案
  2. 实现相比单线程版本较为复杂,心智负担较重,容易出 bug
  3. 处理小图片可能不如主线程
  4. 不同浏览器性能差异较大,难以统一用户体验

源代码和参考文献

Source Code

OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker

Speedy Introduction to Web Workers

Web Worker 传输大量 Transferable 对象时的性能问题

transferControlToOffscreen