基于worker实现excel报表导出下载

业务背景

在前端对透视表数据导出为excel进行下载,透视表数据最高可到百万个单元格以上,因此在主线程内对大量数据进行excel导出时不可避免的会对主线程进行阻塞,而主线程阻塞对于用户感知来说就是页面卡顿

显然,这种业务是一种典型的CPU密集型任务,如果可以放在主线程以外的线程进行执行那么就可以有效的避免对主线程的阻塞了;Worker 正是为此而生,通过创建worker来新建线程,就可以有效的分担主线程的压力;

关于 Worker

至于worker的使用,可以参考这个——使用 Web Workers - Web API 接口参考 | MDN,其实还挺简单的;更不用说像vite这种dev server还内置了worker-loader,开箱即用(顺便说一句,vite也对WASM提供了开箱即用的加载,心动😁)。

worker虽好,但是其运行上下文跟主线程还是不一样的,实际上是一个子集语法,因此DOM对象和很大一部分的BOM对象都不能访问和使用;具体限制可以参考——Web Workers 可以使用的函数和类 - Web API 接口参考 | MDN

Worker 的通信

image-20220806133601806

主线程和Worker实例之间都是通过postMessage方法来向对方发送消息,而又通过onmessage方法来注册各自的消息接收处理事件。

如何在 Worker 中实现 excel 的导出

前端有个著名的excel导出库——sheetjs,幸运的是这个库的大部分方法都能在worker中直接进行使用,因此基本上可以参照正常的导出逻辑搬到worker文件中即可。这里不会探讨sheetjs的具体使用细节(因为这种API使用文档网上太多了,就不赘述了),假设此时可以获取到对透视表表格本身的单元格二维数组(即以行为主导,每行里面的元素就是列),那么根据二维数组就能生成一个WorkBook对象。

image-20220806140802237

而有了WorkBook对象,正常来说基于XLSX.writeFile()等方法就能直接进行excel下载操作了;一般现在的文件下载操作都是通过a标签来实现的,而worker本身是限制DOM访问的,因此不能通过上述方法在worker内实现下载操作,所以应该将下载操作放在主线程,而将必要的数据传送到主线程。

那么问题来了,应该选择传送什么样的数据到主线程?

思路一:传送 WorkBook 对象

从直觉上来说直接把WorkBook对象传送到主线程,然后让主线程继续接着XLSX.writeFile()方法的路子不就好了。但事实证明,这种思路有两个弊端

  1. 线程间的通信成本WorkBook对象本质上是一个包含大量数据的对象结构(包含单元格数据和单元格设置等),而线程间的通信成本会随着数据量加大而陡增(结构化克隆,序列化,反序列化等等);当数据量较大时,主线程在接收来自Worker的消息依然会造成明显的阻塞。
  2. WorkBook对象到下载的成本:由于WorkBook对象本身不能直接进行下载,因此XLSX.writeFile()这类方法内部都需要先将WorkBook对象转为Blob/File对象,然后通过URL.createObjectURL()来创建一个可访问的Blob URL;但实际上将WorkBook对象转为Blob/File对象也是一个挺耗时的过程。

这里可以看一个具体导出过程的用时统计即可看出上述弊端:

image-20220806143254336

综上,如果采用该方案,那么worker的使用效果就很尴尬😅了。

思路二:传送 Blob URL

幸运的是,worker内可以使用URL.createObjectURL()方法[1],且创建的Blob URL可以被主线程访问[2]

image-20220806143536641

到此,剩下的问题就只有一个了——那就是如何将WorkBook对象转为Blob对象;事实上,sheetjs本身就提供了将WorkBook对象转为二进制数据(Uint8Array)的API——XLSX.write(),所以这事就很简单了。

image-20220806145214965

至此,只需要将Blob URL传送到主线程即可完成最后的下载步骤;显然,传送一个URL字符串的通信成本几乎为0,这样就可以把excel导出下载任务的绝大多数运算控制在worker内部了,最小可能的对主线程进行占用。

如何将透视表数据转换为单元格二维数组

这里渲染透视表采用的是S2这个库,这个库其实提供了一个将当前表格数据导出为csv字符串的API[3](基于此字符串可以分割得到单元格二维数组);而这个方法运行也是很耗时的,因此需要转移到worker内部进行执行。不过不幸的是这个实例方法本身并不能直接移植到worker(因为其依赖违反了限制),因此需要自行在worker实现一个将透视表数据转为单元格二维数组的方法。

这里已知的信息有:

  • 行维度信息
  • 列维度信息
  • 指标维度信息
  • 按照行、列、指标维度拼接的对象数组(即数组元素里面是一个对象,该对象的key为行、列、指标维度,value就是该维度的值)

根据上述信息可以构建出一个行头树结构和一个列头树结构

image-20220806161633512

然后根据指标分布在行或列的不同,在行头或列头树结构中的叶结点再添加相应的指标结点作为真实的叶结点,最终构成一个完整的行头结构。而根据行头路径(路径指的是自顶向下到一个叶结点所经过的结点)、列头路径再加上对应的指标key就可以确定一个单元格的位置,所以基于此就可以构建出具体指标数据对应的单元格唯一key;既然都知道每个单元格的数据分布在哪了,那么只需要根据行头和列头叶结点做**两层遍历(二维)**即可将所有数据单元格进行填充了。

相关文档


  1. https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers ↩︎

  2. https://stackoverflow.com/questions/36436075/is-it-possible-to-save-a-file-directly-from-a-web-worker ↩︎

  3. https://s2.antv.vision/zh/docs/common/export/#copydata ↩︎