概述
背景
业务背景:预览百万行数据量的大excel表格。
调研方案
要预览
excel解析
| 比较 | sheetjs | exceljs |
|---|---|---|
| 解析功能 | 提供基础能力,提供丰富插件,解析样式需要xlsx-style | 完整的 Excel 处理能力 |
| 大文件解析 | 内存占用少,解析稍慢 | 内存占用相对大 |
| npm包 | node和浏览器都有npm包 | node有npm包,浏览器需要script标签引入 |
| 体积 | 小 | 大 |
excel渲染
简单介绍下excel表格渲染方案:
以下是整理后的表格:
| 库名称 | 许可类型 | 渲染方式 | 解压大小 | 支持行数级别 |
|---|---|---|---|---|
| vtable | 免费 | Canvas | 22M | 百万行 |
| x-spreadsheet | 免费 | Canvas | 1.22M | 百万行 |
| luckysheet(归档) | 免费 | Canvas | 30M | 百万行 |
| univerjs(原luckysheet) | 基础免费,pro功能付费 | Canvas | 7.85M | 百万行 |
| aggrid | 社区版免费,有付费功能 | DOM | 18M | 百万行 |
| handsontable | 商用收费 | DOM | 20M | 十万行 |
| vxe-talbe | 企业收费 | DOM | 10.6M | 百万行 |
| SpreadJs | 企业收费 | - | - | - |
很多笔记软件都使用了handsontable,应该是功能支持最全面的了。univerjs作为从luckysheet升级过来的,功能也很全面,有不错的性能。
我们这里只需要做简单的预览功能,考虑性能和体积,选择了如下技术栈: sheetjs + x-spreadsheet;
瓶颈
内存方面的:
x-spreadsheet采用了canvas,占用内存少,渲染性能高。
内存性能瓶颈在 sheetjs解析xlsx数据,占用内存非常大,而浏览器对一个tab标签的内存有限制。
线程阻塞方面的:
在同一个tab或同源的一组tab中,共享一个渲染进程,意味着CPU密集计算,会导致主线程的阻塞。
浏览器对单个Tab/Web Worker内存限制
以现代Chrome版本为例,给单个Tab的内存限制是按机器性能来的。大概如下:
| (64位)系统内存大小 | 单个Tab内存限制 |
|---|---|
| >16G | 4G |
| <=16G | 2G |
| 32位系统,单个Tab内存为1G。 (需要注意:如果有多个同源的Tab页,那么可能是共享渲染进程,即共享内存限制,继续往下看) |
同源tab页共享渲染进程
渲染进程:包括JS主线程、GUI线程和其他线程。
- Web Worker(线程)和单个Tab属于同一个进程,因此与所属Tab共享内存限制。
- 站点隔离策略
- 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步
1+2步
可以看出 x-spreadsheet渲染所占用内存很小,对JS heap的内存峰值几乎没有影响。当内存回收后开始下降,比「1+2+3步」内存多占用200M左右(观察两个图中的最后稳定的数值,1080-900=180)。
1步
可以看出,表格数据转换,占用了200M左右。
0步(仅请求数据存入ArrayBuffer)
可以看出,请求的数据保存到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
优化主线程阻塞问题 - 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。