大数据量excel预览优化

346 阅读5分钟

概述

背景

业务背景:预览百万行数据量的大excel表格。

image.png

image.png

调研方案

要预览

excel解析

比较有名的是 sheetjsexceljs

比较sheetjsexceljs
解析功能提供基础能力,提供丰富插件,解析样式需要xlsx-style完整的 Excel 处理能力
大文件解析内存占用少,解析稍慢内存占用相对大
npm包node和浏览器都有npm包node有npm包,浏览器需要script标签引入
体积

excel渲染

简单介绍下excel表格渲染方案:

以下是整理后的表格:

库名称许可类型渲染方式解压大小支持行数级别
vtable免费Canvas22M百万行
x-spreadsheet免费Canvas1.22M百万行
luckysheet(归档)免费Canvas30M百万行
univerjs(原luckysheet)基础免费,pro功能付费Canvas7.85M百万行
aggrid社区版免费,有付费功能DOM18M百万行
handsontable商用收费DOM20M十万行
vxe-talbe企业收费DOM10.6M百万行
SpreadJs企业收费---

很多笔记软件都使用了handsontable,应该是功能支持最全面的了。univerjs作为从luckysheet升级过来的,功能也很全面,有不错的性能。

我们这里只需要做简单的预览功能,考虑性能和体积,选择了如下技术栈: sheetjs + x-spreadsheet;

瓶颈

内存方面的:
x-spreadsheet采用了canvas,占用内存少,渲染性能高。 内存性能瓶颈在 sheetjs解析xlsx数据,占用内存非常大,而浏览器对一个tab标签的内存有限制。

线程阻塞方面的:
在同一个tab或同源的一组tab中,共享一个渲染进程,意味着CPU密集计算,会导致主线程的阻塞。

浏览器对单个Tab/Web Worker内存限制

以现代Chrome版本为例,给单个Tab的内存限制是按机器性能来的。大概如下:

(64位)系统内存大小单个Tab内存限制
>16G4G
<=16G2G
32位系统,单个Tab内存为1G。 (需要注意:如果有多个同源的Tab页,那么可能是共享渲染进程,即共享内存限制,继续往下看)

同源tab页共享渲染进程

渲染进程:包括JS主线程、GUI线程和其他线程。

  1. Web Worker(线程)和单个Tab属于同一个进程,因此与所属Tab共享内存限制。
  2. 站点隔离策略
  • Chrome默认会根据源(协议+域名+端口)分配渲染进程。同一源的多个标签页可能会共享同一个渲染进程,以减少内存占用。
  • 同源的情况下,在A页面使用window.open()/a标签打开B页面,A、B页面是共享同一个渲染进程,这意味着共享内存限制。(PS.如果新建tab重新输入地址打开B,则A、B属于两个不同进程

内存优化分析

数据:bigdata.xlsx, 45w行(65M)数据
浏览器:chrome浏览器
技术方案:使用sheetjs + x-spreadsheet


this.buffer = await res.arrayBuffer();

const ab = new Uint8Array(this.buffer).buffer;
// 1.解析excel, 从 ArrayBuffer 中读取数据
const wb =
  this.ext.toLowerCase() === "csv"
    ? XLSX.read(new TextDecoder("utf-8").decode(ab), {
        type: "string",
        raw: true,
      })
    : XLSX.read(ab, { type: "array" });
// 2.将excel数据转换为x-spreadsheet所需的数据格式
const sheets = stox(wb);
// console.log("sheets", sheets);
// 3.使用x-spreadsheet渲染数据(展示表格)
this.spreadsheet_s = new Spreadsheet(this.el, {});
this.spreadsheet_s.loadData(sheets);


1+2+3步

image.png

1+2步

image.png

可以看出 x-spreadsheet渲染所占用内存很小,对JS heap的内存峰值几乎没有影响。当内存回收后开始下降,比「1+2+3步」内存多占用200M左右(观察两个图中的最后稳定的数值,1080-900=180)。

1步

image.png

可以看出,表格数据转换,占用了200M左右。

0步(仅请求数据存入ArrayBuffer)

image.png 可以看出,请求的数据保存到ArrayBuffer,只用了20多M,也就是说sheetjs解析xlsx数据用了1.5G多。 基于上面的实验,可以发现内存峰值的瓶颈在:解析xlsx数据。

优化sheetjs解析数据

sheetjs针对大表格有做了优化,参考文档 添加 dense:true, 避免一次性把ArrayBuffer加入内存进行解析。 修改代码如下:


const ab = new Uint8Array(this.buffer).buffer;
// 1.解析excel, 从 ArrayBuffer 中读取数据
const wb =
  this.ext.toLowerCase() === "csv"
    ? XLSX.read(new TextDecoder("utf-8").decode(ab), {
        type: "string",
        raw: true,
      })
    : XLSX.read(ab, { type: "array", dense:true });

结果如下,内存峰值降低了大概200M

image.png

优化主线程阻塞问题 - Web worker

worker.ts:

import { parseTable } from "./core";
/* this callback will run once the main context sends a message */
self.addEventListener(
  "message",
  async (e) => {
    console.log("work start parse...");
    const { type, payload } = e.data;
    if (type === "parseTable") {
      try {
        const { url, tableType } = payload;
        const { sheets, sheetLengths } = await parseTable(url, tableType);
        postMessage({ type: "done", payload: { sheets, sheetLengths } });
      } catch (e) {
        postMessage({ type: "error", payload: e });
      }
    }
  },
  false
);

启动和处理worker:

const worker = useRef<Worker | null>(null);

function releaseWorker() {
  worker.current?.terminate();
  worker.current = null;
}
worker.current = worker.current || new ParseWorker();
worker.current.onmessage =
  worker.current.onmessage ||
  function (e) {
    if (e.data.type) {
      console.info(e.data.type);
      if (e.data.type === "done") {
        const { sheets, sheetLengths } = e.data.payload;
        const maxLength = Math.max(...sheetLengths);
        spreadsheetRef.current = new Spreadsheet(
          containerRef.current!,
          {
            row: { len: maxLength + 50, height: 30 },
          }
        ).loadData(sheets);
        console.log("读取结束", sheets);
        console.log(spreadsheetRef.current);
        setLoading(false);
        releaseWorker();
      } else if (e.data.type === "error") {
        setError(e.data.payload);
        setLoading(false);
        releaseWorker();
      }
    }
  };
worker.current.postMessage({
  type: "parseTable",
  payload: {
    url,
    tableType,
  },
});

其他思路 - WASM

解析xlsx文件非常消耗内存和时间,CPU计算密集,可以考虑用wasm来做,但目前这方面并不成熟,没有很好的wasm库。
基于rust的xlsx-wasm-parser :性能非常好,大概试了下速度和内存消耗能比sheetjs好一倍左右。但是就提供了2个API,太简单了几乎不能用做功能。 基于go的excelize-wasm: 性能很差。

完整demo地址

该demo把预览csv/xlsx的功能封装成一个hooks,能通过url直接预览xlsx/csv,同时支持web worker。

big-xlsx-viewer