Rust赋能前端: 纯血前端将 Table 导出 Excel

人的本事靠自己,人的成长靠网络

大家好,我是柒八九 。一个专注于前端开发技术/RustAI应用知识分享Coder

此篇文章所涉及到的技术有

  1. Rust( Rust接收json对象并解析/Rust生成xml)

  2. WebAssembly

  3. 表格合并(静态/动态)

  4. React/Vue表格导出 excel

  5. Rspack

因为,行文字数所限,有些概念可能会一带而过亦或者提供对应的学习资料。请大家酌情观看。


前言

自从上次更文Rust赋能前端: 给我0.02秒,生成一套Vite/Rsbuild前端项目已过去半个月之久了。

不是偷懒和懈怠了。而是年底了,工作有点多。所以,导致更文的速度和频率有点下降。

想必大家在平时业务开发的时候,或多或少都有过将前端页面中的table导出excel的需求。

常规的方案有几种

  1. 纯后端处理,也就是发起一个异步任务,然后将 excel生成移步到后端。

    • 优点:这种情况,针对那种 数据量大 的情况,是一种可选方案。如果数据过于庞大,我们还可以在用一个 导出页面来展示各种导出任务。(已导出/正在导出...)

    • 缺点:我们无法做到导出任务的时效性。当然,我们可以借助 websockt/sse等方案来接收后端的导出结果。但是呢,这种方式无疑增加业务的复杂度。

  2. 纯前端处理,我们可以借助一些第三方的库例如 SheetJS [1]来执行数据的导出。

    • 优点:导出结果能够及时看到。

    • 缺点:处理数据量大的表格,性能就有点慢。同时,比如做一些表格合并(静态/动态)就有点麻烦,然后如果我们想对导出的 excel某些 cell做样式处理,这块也有很大的上手难度。

而,今天呢,我们提供一种方案,用Rust来处理前端表格的导出(excel)。最后的效果就是,我们既可以实时得到导出结果,也能针对大数据表格实现高性能导出。并且还可以实现表格合并(静态/动态)。

运行效果

静态表格
静态长表格(1万条数据)
静态表格合并
动态表格合并

好了,天不早了,干点正事哇。


我们能所学到的知识点
  1. 初衷

  2. 案例展示

  3. 源码解析

  4. TODO


1. 初衷

其实呢,我们公司对于前端表格的导出,是走的纯后端处理的模式。也就是

  1. 在前端页面中发起一个异步任务

  2. 后端将特定的数据填充到 excel

  3. 后端向前端在返回一个 Blob对象

    export?const?exportxxRecord?=?(data)?=>?{ ??return?axiosInstance<Blob>({ ????url:?`xxx`, ????data, ????responseType:?'blob', ????method:?'POST', ??}); };

  4. 前端生成一个 a标签来执行下载任务

    export?const?downLoad?=?(blob:?Blob,?fileName:?string)?=>?{ ??const?url?=?window.URL.createObjectURL(blob); ??const?a?=?document.createElement('a'); ??a.href?=?url; ??a.download?=?fileName; ??a.click(); ??window.URL.revokeObjectURL(url); };

但是呢,最近接到一个需求。这个需求可谓是Buff叠满。

  1. 表格列( columns)是动态生成的

  2. 表格数据也是动态的(非静态表格)

  3. 表格数据中特定列的数据需要执行合并处理,并且列和列之前是有包含关系的

  4. last but not least,表格导出的 excel也是需要进行列合并的

然后,更诡异的是,后端同学说他实现不了excel的动态合并。 这你能受的了吗。

既然,锅已经甩过来了,那没有不接的道理。正所谓,我不入地狱,谁入地狱?。那还是由我来哇。

更深的逻辑

其实,大部分业务场景中,大家对导出TableExcel的常规做法都是通过异步接口来实现的。这样做也是有一定的好处的。对于部分业务场景,我们需要记录用户的导出记录,这个操作就需要后端将记录入库。

但是呢,对于一些非后端记录的导出,我们就可以使用纯前端的方式了。其实针对这类的业务处理,是有很多好处的。

  1. 针对导出,无非就是将前端页面中展示的 Table导出为 Excel。此时,在前端环境中,我们在利用 Antd/Element等前端组件库展示的时候,这块导出数据前端已经知晓了。我们要做的就是对于这些数据再次拼装或者直接一股脑的扔给 Excel导出引擎(我们就是采用这种方式)

  2. 通过异步方式处理,无论数据多少,都会产生 网络时延 。如果在弱网情况下,就算是数据量小的情况下,导出效果也不尽人意。我们在 22023面试真题之网络篇中讲过。

最好最快的请求就是没有请求

  1. 就算网络时延不是主要的性能损耗点,但是对于一些统计类型的表格,对于后端同学是需要进行 多表关联的查询。有些看似简单的数据值,可能需要跨越很多表去查询。这也是一个性能损耗点。

所以,如果上天给我一种能够在前端环境中,又快又好的导出excel。我会毫不犹豫的使用它


2. 案例展示

写在最前面

因为,我们是先讲我们wasm的能力,后面才会涉及到源码部分。但是呢,因为我们这个wasm兼容了很多情况,所以参数也是有很多传人方式和格式。所以,我们在讲示例之前,先讲讲参数的含义。

我们在Rust中定义了和参数相关的Struct用于收集相关信息。

#[derive(Deserialize)] pub?struct?CellCoords?{ ????pub?column:?u32, ????pub?row:?u32, } #[derive(Deserialize)] pub?struct?MergedCell?{ ????pub?from:?CellCoords, ????pub?to:?CellCoords, } #[derive(Debug,?Serialize,?Deserialize)] struct?Column?{ ????title:?String, ????width:?serde_json::Value, ????dataIndex:?String, } #[derive(Deserialize)] struct?InputJson?{ ????name:?String, ????columns:?Vec<Column>, ????source:?Vec<std::collections::HashMap<String,?serde_json::Value>>, ????merge:?Option<Vec<MergedCell>>, ????correlation:?Option<Vec<String>>, }

  1. name:接收一个 String类型的数据,用于配置生成 excelsheetName

  2. columns:看 Struct我们得知,它接收的是 Column的数组,而 Column是用于定义我们每列的具体信息。

    • 可以看到,类似 title/width/dataIndex都是我们在前端构建 Table( Antd)中用到的字段。(当然,当使用 Element时,你可以将对应的结构转换成此种类型)

    • 其实这里有一个警告 ,在 Rust中我们定义变量名,都是使用 **蛇形命名法(snake_case)**是指每个空格皆以底线( _)取代的书写风格,且每个单字的第一个字母皆为 小写。 但是,我们为最大程度的兼容前端的数据,不需要再转换,这里就使用了驼峰命名法

    • 当然,我们也不需要在传人的时候,在前端处理 columns相关字段,无脑传即可

    • 这里还有一点需要说明,在前端 columns我们定义 width时候,是可以接收 numberstring类型,在 Rust中我们使用 serde_json::Value来定义类型

  3. source:这里我们用 Vec<std::collections::HashMap<String, serde_json::Value>>定义,对标前端数据的数据类型就是 对象

    • 也就是说,我们 source传人对象即可,也是无脑传即可
  4. merge: 是一个可选项,用于接收静态表格合并的信息

    • 其中 MergedCell接收 fromto的相关信息。
  5. correlation:这也是一个可选项,用于接收对于针对 列合并 时对应列的 dataIndex信息。

    • 如果传人多个字段,那么这些字段默认有 关联关系,后面的字段会以前面字段分组后,才会执行合并操作。

关于在Rust中如何操作JSON相关的,可以看我们之前写的如何在Rust中操作JSON


项目初始化

还是熟悉的套路,我们使用npx f_cli_f create table2excel的前端项目。(发布到npmf_cli_frspack版本的.gitignore缺失了,有空我重新发布一版)

我们选择rspack+antd+react+tailwind的前端模板。(下面的方案,其实和框架无关,也就是说我们可以在React/Vue中无痛使用该方案)

然后,我们将项目中的pages/Home中的替换为下面的代码。

import?init,?{?generate_excel?}?from?"@/wasm/table2excel"; import?{?Button,?Table?}?from?"antd"; import?{?useCallback,?useEffect,?useState?}?from?"react"; import?{?mergeDynamicTable,?mergeTable,?staticTable?}?from?"./data.js"; const?Home?=?()?=>?{ ?const?[time,?setTime]?=?useState(0); ?useEffect(()?=>?{ ??const?initWasmInstance?=?async?()?=>?{ ???await?init(); ??}; ??initWasmInstance(); ?},?[]); const?handleExcelBlob?=?(res:?Blob)?=>?{ ????const?blob?=?new?Blob([res],?{ ????????type:?"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,", ????}); ????const?a?=?document.createElement("a"); ????a.href?=?URL.createObjectURL(blob); ????a.download?=?"data.xlsx"; ????document.body.append(a); ????a.click(); }; const?handleExport4Static?=?async?()?=>?{ ????const?startTime?=?performance.now(); ????const?res?=?await?generate_excel({ ????????columns:?staticTable.columns, ????????source:?staticTable.source, ????????name:?"front789", ????????merge:?[], ????}); ????const?endTime?=?performance.now(); ????const?duration?=?endTime?-?startTime;? ????setTime(duration);? ????handleExcelBlob(res); }; ?return?( ??<section?className="h-screen?w-screen?overflow-auto?flex?flex-col?gap-10?p-20"> ???<div?className="flex?flex-col?gap-2"> ????<div?className="flex?items-center?gap-5"> ?????静态表格?<Button?onClick={handleExport4Static}>导出</Button>?<span>耗时:{time}ms</span> ????</div> ????<Table ?????columns={staticTable.columns} ?????bordered ?????dataSource={staticTable.source} ?????pagination={false} ????/> ???</div> ??</section> ?); }; export?default?Home;

其中,有几个外部文件,我们简单说描述一下

  1. data.js用于定义 columns/source等数据

  2. wasm就是存放我们 Rust编译好的文件(这个后面会讲)

我们在组件初始化中执行了table2excel的初始化操作。

useEffect(()?=>?{ ??const?initWasmInstance?=?async?()?=>?{ ??????await?init(); ??}; ??initWasmInstance(); },?[]);

随后,我们就可以直接在事件回调中执行wasm的相关操作了。

我们还抽象了一个执行下载的操作方法(handleExcelBlob)

const?handleExcelBlob?=?(res:?Blob)?=>?{ ????const?blob?=?new?Blob([res],?{ ????????type:?"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,", ????}); ????const?a?=?document.createElement("a"); ????a.href?=?URL.createObjectURL(blob); ????a.download?=?"data.xlsx"; ????document.body.append(a); ????a.click(); };


静态表格导出

当我们运行yarn dev的时候,在Home路径下,就会展现如下页面

在这种情况下,我们是用data.js中的staticTable的信息。

export?const?staticTable?=?{?? ????"columns":[ ??????{ ????????"title":?"A前端", ????????"width":?100, ????????"dataIndex":?"a" ??????}, ??????{ ????????"title":?"B柒八九", ????????"width":?"150", ????????"dataIndex":?"b" ??????}, ??????{ ????????"title":?"C北宸", ????????"width":?100, ????????"dataIndex":?"c" ??????}, ???????{ ????????"title":?"D南蓁", ????????"width":?"500px", ????????"dataIndex":?"d" ??????} ????], ????"source":[ ??????{ ????????"a":?1, ????????"b":?"b1", ????????"c":?"c1", ????????"d":?"专注于前端开发技术,Rust及AI应用知识分享" ??????}, ??????{ ????????"a":?2, ????????"b":?"b2", ????????"c":?"c2", ????????"d":?"专注于前端开发技术,Rust及AI应用知识分享" ??????}, ??????{ ????????"a":?3, ????????"b":?"b3", ????????"c":?"c3", ????????"d":?"专注于前端开发技术,Rust及AI应用知识分享" ??????}, ??????{ ????????"a":?4, ????????"b":?"b4", ????????"c":?"c4", ????????"d":?"专注于前端开发技术,Rust及AI应用知识分享" ??????} ????] }

上面的这个信息,其实就是Antd-Table中的相关配置。

导出按钮的事件,我们执行相关的数据导出操作。

const?handleExport4Static?=?async?()?=>?{ ????const?startTime?=?performance.now(); ????const?res?=?await?generate_excel({ ????????columns:?staticTable.columns, ????????source:?staticTable.source, ????????name:?"front789", ????}); ????const?endTime?=?performance.now(); ????const?duration?=?endTime?-?startTime;? ????setTime(duration);? ????handleExcelBlob(res); };

其中,最主要的就是generate_excel。这就是wasm导出的相关函数。(import init, { generate_excel } from "@/wasm/table2excel";)

其中,有几个重要的参数,我们需要解释一下

  1. columns:该参数就是定义 antd-table中的 columns配置。我们可以无脑传。

  2. source:该参数就是定义 antd-table中的 dataSource配置。我们可以无脑传。

  3. name:该参数用于生成 excelsheetName

效果展示

当我们在页面中,触发导出任务后,我们就会得到如下的excel

导出耗时

我们还通过performance.now()的计算耗时任务。执行多次会发现当执行一个简单的静态表格时,平均耗时为2ms左右。(当然这还和本机环境和数据量多少有关系)


大数据表格导出

对于简单静态表格,我们已经展示过了。现在我们来展示一下对于大数据表格的导出情况。

我们用node来生成一个10000条数据(其实10万条也是可以的,这个自行研究,我自己实验下,导出也不超过1秒,大部分都维持在500ms左右)

const?fs?=?require('fs'); const?dataStructure?=?{ ??columns:?[ ????{ ??????title:?"A前端", ??????width:?100, ??????dataIndex:?"a" ????}, ????{ ??????title:?"B柒八九", ??????width:?150, ??????dataIndex:?"b" ????}, ????{ ??????title:?"C北宸", ??????width:?100, ??????dataIndex:?"c" ????}, ????{ ??????title:?"D南蓁", ??????width:?"500px", ??????dataIndex:?"d" ????} ??], ??source:?(()?=>?{ ????const?data?=?new?Array(10000).fill(0).map((_,?index)?=>?({ ??????a:?`a${index}`, ??????b:?`b${index}`, ??????c:?`c${index}`, ??????d:?`专注于前端开发技术,Rust及AI应用知识分享?${index}`, ????})); ????return?data; ??})() }; fs.writeFileSync('data.json',?JSON.stringify(dataStructure,?null,?2),?'utf-8');

这样,我们就可以在组件中导入json数据。

import?init,?{?generate_excel?}?from?"@/wasm/table2excel"; import?{?Button,?Table?}?from?"antd"; import?{?useCallback,?useEffect,?useState?}?from?"react"; import?json?from?'./data.json'?with?{?type:?'json'?}; const?Home?=?()?=>?{ ?const?[time,?setTime]?=?useState(0); ?useEffect(()?=>?{ ??//?省略部分代码 ?},?[]); ??const?handleExcelBlob?=?(res:?Blob)?=>?{ ??????//?省略部分代码 ??}; const?handleExport4LongStatic?=?async?()?=>?{ ????const?startTime?=?performance.now(); ????const?res?=?await?generate_excel({ ????????columns:?json.columns, ????????source:?json.source, ????????name:?"front789", ????????merge:?[], ????}); ????const?endTime?=?performance.now(); ????const?duration?=?endTime?-?startTime;? ????setTime(duration);? ????handleExcelBlob(res); }; return?( ????<section?className="h-screen?w-screen?overflow-auto?flex?flex-col?gap-10?p-20"> ????????<div?className="flex?flex-col?gap-2"> ????????????<div?className="flex?items-center?gap-5"> ????????????????静态长表格?<Button?onClick={handleExport4LongStatic}>导出</Button>?<span>耗时:{time}ms</span> ????????????</div> ????????????<Table ????????????????columns={json.columns} ????????????????bordered ????????????????dataSource={json.source} ????????????????pagination={false} ????????????????virtual ????????????/> ????????</div> ????</section> ); }; export?default?Home;

针对上面的代码,我们也有几点需要说明

  1. JSONRspack 的一等公民,我们可以直接导入。 import json from './data.json' with { type: 'json' };

  2. 在使用 generate_excel时,传人的参数和之前示例是一样的,只不过我们接收的数据是 json维护的。

导出耗时

执行多次会发现当执行一个长静态表格 时,平均耗时为160ms左右。(当然这还和本机环境和数据量多少有关系)

效果展示

静态表格合并导出

何为静态表格?其实就是表格的列/行数据都是不变的。

当需要进行表格合并时,我们是可以提前知晓,哪些行或者哪些列是需要合并操作的。

我们使用data.js中的mergeTable的信息。

因为,表格是静态的,所以我们可以提前在columns中定义onCell来控制行和列的合并。

下面是我们在还用Antd-Table进行合并时的相关配置。这块可以参考antd-table表格行/列合并[2]

const?sharedOnCell?=?(_,?index)?=>?{ ??if?(index?===?9)?{ ????return?{?colSpan:?0?}; ??} ??return?{}; }; export?const?mergeTable?=?{ ????"columns":[ ??????{ ????????"title":?"A前端", ????????"width":?"100", ????????"dataIndex":?"a", ????????onCell:?(_,?index)?=>?{? ??????????if?(index?==?9)?{? ????????????return?{ ??????????????colSpan:4 ????????????} ??????????} ????????} ??????}, ??????{ ????????"title":?"B柒八九", ????????"width":?"150", ????????"dataIndex":?"b", ????????onCell:sharedOnCell ??????}, ??????{ ????????"title":?"C北宸", ????????"width":?100, ????????"dataIndex":?"c", ????????onCell:sharedOnCell ??????}, ???????{ ????????"title":?"D南蓁", ????????"width":?500, ?????????"dataIndex":?"d", ?????????onCell:?(_,?index)=>{? ???????????if?(index?==?9)?{? ?????????????return?{ ??????????????colSpan:0 ????????????} ???????????} ???????????if?(index?==?0)?{? ?????????????return?{ ???????????????rowSpan:9 ?????????????} ???????????} ???????????if?(index?>?0)?{? ?????????????return?{rowSpan:0} ???????????} ??????????return?{}; ?????????} ??????} ????], ????"source":[ ??????//?省略部分代码 ????] }

然后,我们更新Home组件。

import?init,?{?generate_excel?}?from?"@/wasm/table2excel"; import?{?Button,?Table?}?from?"antd"; import?{?useCallback,?useEffect,?useState?}?from?"react"; import?{?mergeDynamicTable,?mergeTable,?staticTable?}?from?"./data.js"; const?Home?=?()?=>?{ ?const?[time,?setTime]?=?useState(0); ?useEffect(()?=>?{ ??//?省略部分代码 ?},?[]); ?const?handleExcelBlob?=?(res:?Blob)?=>?{ ??//?省略部分代码 ?}; const?handleExport4StaticMerge?=?async?()?=>?{ ????const?startTime?=?performance.now(); ????const?res?=?await?generate_excel({ ????????columns:?mergeTable.columns, ????????source:?mergeTable.source, ????????name:?"front789", ????????merge:?[ ????????????{ ????????????????from:?{ ????????????????????column:?0, ????????????????????row:?10, ????????????????}, ????????????????to:?{ ????????????????????column:?3, ????????????????????row:?10, ????????????????}, ????????????}, ????????????{ ????????????????from:?{ ????????????????????column:?3, ????????????????????row:?1, ????????????????}, ????????????????to:?{ ????????????????????column:?3, ????????????????????row:?9, ????????????????}, ????????????}, ????????], ????}); ????const?endTime?=?performance.now(); ????const?duration?=?endTime?-?startTime;? ????setTime(duration);? ????handleExcelBlob(res); }; ??return?( ??????<section?className="h-screen?w-screen?overflow-auto?flex?flex-col?gap-10?p-20"> ??????????<div?className="flex?flex-col?gap-2"> ??????????????<div?className="flex?items-center?gap-5"> ??????????????????静态表格合并?<Button?onClick={handleExport4StaticMerge}>导出</Button>?<span>耗时:{time}ms</span> ??????????????</div> ??????????????<Table ??????????????????columns={mergeTable.columns} ??????????????????bordered ??????????????????dataSource={mergeTable.source} ??????????????????pagination={false} ??????????????/> ??????????</div> ??????</section> ??); }; export?default?Home;

相比较之前针对静态表格 的导出,我们在调用generate_excel时候,多传了一个merge字段。

merge:?[ ????{ ????????from:?{ ????????????column:?0, ????????????row:?10, ????????}, ????????to:?{ ????????????column:?3, ????????????row:?10, ????????}, ????}, ????{ ????????from:?{ ????????????column:?3, ????????????row:?1, ????????}, ????????to:?{ ????????????column:?3, ????????????row:?9, ????????}, ????}, ],

该字段就是用于处理excel合并的字段信息。它接收一个对象数组,其中对象是一个用于标识哪些cell是合并的。

  1. from:用于标识 起始cell位置

  2. to:用于标识 结束cell位置

  3. columnrow就不必多解释了

还有一点需要说明,由于我们在处理的时候,将columns中的title也抽离出来作为了excelcell。所以在merge列的时候,针对row的配置,是以1开始的。

导出耗时

执行多次会发现当执行一个静态表格合并时,平均耗时为2ms左右。(当然这还和本机环境和数据量多少有关系)

效果展示

动态表格合并导出

何为动态表格?其实就是表格的列/行数据都是可变的。

这个也是我们此次要做的初衷。

对于这个案例,有点复杂。

我们稍微用较多篇幅来讲讲。

我们为了讲主要的逻辑,我们暂时将columns设定为定值。其实,真实业务中,columns也是动态变化的。可以从上图看到,我们对于第一列/第二列是依据数据来计算合并的。

data.js中我们定义如下的数据类型。

export?const?mergeDynamicTable?=?{ ????"columns":[ ??????{ ????????"title":?"A前端", ????????"width":?"110px", ????????"dataIndex":?"a", ??????}, ??????{ ????????"title":?"B柒八九", ????????"width":?100, ????????"dataIndex":?"b", ??????}, ??????{ ????????"title":?"C北宸", ????????"width":?200, ????????"dataIndex":?"c", ??????}, ???????{ ????????"title":?"D南蓁", ????????"width":?"500px", ?????????"dataIndex":?"d", ??????} ????], ????"source":[ ??????//?省去部分代码 ????] }

主要代码

import?init,?{?generate_excel?}?from?"@/wasm/table2excel"; import?{?Button,?Table?}?from?"antd"; import?{?useCallback,?useEffect,?useState?}?from?"react"; import?{?mergeDynamicTable,?mergeTable,?staticTable?}?from?"./data.js"; export?type?ListItem?=?{ ?id?:?string; ?a:?string; ?b:?string; ?c:?string; ?d:?string; }; type?RowSpanTuple?=?[number,?number]; const?Home?=?()?=>?{ ?const?[columns,?setColumns]?=?useState(mergeDynamicTable.columns); ?const?[source,?setSource]?=?useState([]); ?const?[rowMergeMaps,?setRowMergeMaps]?=?useState<Map<string,?Map<string,?RowSpanTuple>>>(new?Map()); ?const?[time,?setTime]?=?useState(0); const?calculateRowMerge?=?useCallback((data:?ListItem[],?field:?keyof?ListItem,?parentField?:?keyof?ListItem)?=>?{ ????//?省略部分代码 ??},?[]); ?useEffect(()?=>?{ ??//省去部分代码 ?},?[]); ??useEffect(()?=>?{ ??????const?emulateAsync?=?async?()?=>?{ ??????????return?new?Promise((resolve)?=> ??????????????setTimeout( ??????????????????()?=>?resolve(mergeDynamicTable.source), ??????????????????Math.random()?*?1000?+?1000, ??????????????), ??????????); ??????}; ??????emulateAsync().then((source)?=>?{ ??????????if?(!source?||?!Array.isArray(source))?{ ??????????????console.error("Invalid?source?data"); ??????????????return; ??????????} ??????????const?data?=?[...source]; ??????????const?map?=?new?Map(); ??????????map.set("a",?calculateRowMerge(data,?"a")); ??????????if?(data[0]?.b)?{ ??????????????map.set("b",?calculateRowMerge(data,?"b",?"a")); ??????????} ??????????setRowMergeMaps(map); ??????????setSource(source); ??????}); ??},?[calculateRowMerge]); ?useEffect(()?=>?{ ??//?省去部分逻辑,在下面会讲到 ?},?[source,?rowMergeMaps]); ??const?handleExport4DynamicMerge?=?async?()?=>?{ ??????const?startTime?=?performance.now(); ??????const?res?=?await?generate_excel({ ??????????columns:?mergeTable.columns, ??????????source:?mergeTable.source, ??????????name:?"front789", ??????????correlation:?["a",?"b"], ??????}); ??????console.timeEnd('generate_excel_duration'); ??????const?endTime?=?performance.now(); ??????const?duration?=?endTime?-?startTime;? ??????setTime(duration);? ??????handleExcelBlob(res); ??}; ?const?handleExcelBlob?=?(res:?Blob)?=>?{ ??//省去部分代码 ?}; ? ??return?( ??????<section?className="h-screen?w-screen?overflow-auto?flex?flex-col?gap-10?p-20"> ??????????<div?className="flex?flex-col?gap-2"> ??????????????<div?className="flex?items-center?gap-5"> ??????????????????动态表格合并?<Button?onClick={handleExport4DynamicMerge}>导出</Button>?<span>耗时:{time}ms</span> ??????????????</div> ??????????????<Table ??????????????????columns={columns} ??????????????????bordered ??????????????????dataSource={source} ??????????????????pagination={false} ??????????????/> ??????????</div> ??????</section> ??); }; export?default?Home;

这里有几点需要特别说明一下:

  1. 我们在 useEffect中通过 emulateAsync来模拟一个异步任务。

  2. 随后,我们通过 calculateRowMerge来计算 datacolumnsdataIndex的相关的合并信息。

const?calculateRowMerge?=?useCallback((data:?ListItem[],?field:?keyof?ListItem,?parentField?:?keyof?ListItem)?=>?{ ????const?keyIndexMap?=?new?Map<string,?RowSpanTuple>(); ????const?getKey?=?(item:?ListItem)?=>?(parentField???`${item[parentField]}-${item[field]}`?:?`${item[field]}`); ????data.reduce( ??????(acc,?item,?index)?=>?{ ????????const?prevItem?=?data[index?-?1]?||?data[0]; ????????const?isSameGroup?=?parentField ????????????prevItem[parentField]?===?item[parentField]?&&?prevItem[field]?===?item[field] ??????????:?prevItem[field]?===?item[field]; ????????if?(isSameGroup)?{ ??????????acc[1]?=?index; ????????}?else?{ ??????????if?(acc[0]?!==?null)?{ ????????????keyIndexMap.set(getKey(prevItem),?acc); ??????????} ??????????acc?=?[index,?index]; ????????} ????????if?(index?===?data.length?-?1)?{ ??????????keyIndexMap.set(getKey(item),?acc); ????????} ????????return?acc; ??????}, ??????[0,?0]?as?RowSpanTuple ????); ????return?keyIndexMap; ??},?[]);

这一步其实,就是通过遍历data(data.reduce)来收集每列中数据相同的信息。然后,存放到一个Map<string, Map<string, RowSpanTuple>>state中。

  1. 随后,我们在 useEffect中监听 rowMergeMaps用以动态计算每一列的 onCell的相关逻辑.

if?(source?.length)?{ ????const?baseColumns?=?[...mergeDynamicTable.columns]; ????//?处理第一行的行合并 ????const?userNameMap?=?rowMergeMaps.get("a"); ????baseColumns[0].onCell?=?(value,?index)?=>?{ ????????const?indexSpan?=?userNameMap.get(value.a); ????????if?(indexSpan)?{ ????????????if?(indexSpan[0]?===?index)?{ ????????????????return?{?rowSpan:?indexSpan[1]?-?indexSpan[0]?+?1?}; ????????????} ????????????if?( ????????????????(index?as?number)?>?indexSpan[0]?&& ????????????????(index?as?number)?<=?indexSpan[1] ????????????) ????????????????return?{?rowSpan:?0?}; ????????} ????????return?{?rowSpan:?0?}; ????}; ????const?typeMap?=?rowMergeMaps.get("b"); ????baseColumns[1].onCell?=?(value:?ListItem,?index:?number)?=>?{ ????????const?indexSpan?=?typeMap.get(`${value.a}-${value.b}`); ????????if?(indexSpan)?{ ????????????if?(indexSpan[1]?!==?indexSpan[0]?&&?indexSpan[0]?===?index)?{ ????????????????return?{?rowSpan:?indexSpan[1]?-?indexSpan[0]?+?1?}; ????????????} ????????????if?(index?>?indexSpan[0]?&&?index?<=?indexSpan[1]) ????????????????return?{?rowSpan:?0?}; ????????} ????????return?{}; ????}; ????setColumns(baseColumns); }

  1. last but not least,我们在handleExport4DynamicMerge中执行导出任务。

    • 其中最为显眼的就是 correlation字段。该字段就是为 wasm传递,说明到底是哪几个列基于数据进行列合并。

    • 同时这里还有一个默认的规则。如果传人的是多个字段,那么后面的字段会按照前面的字段进行分组合并

导出耗时

执行多次会发现当执行一个动态表格合并时,平均耗时为10ms左右。(当然这还和本机环境和数据量多少有关系)

效果展示


3. 源码解析

项目初始化

我们通过cargo new --lib table2excel来构建一个项目。

同时呢,我们在项目根目录中创建用于打包优化的文件。

  1. build.sh

  2. tools/optimize-rust.sh

  3. tools/optimize-wasm.sh

这个我们在之前的Rust赋能前端:为WebAssembly 瘦身中介绍过相关概念,这里就不在赘述了。

核心参数

在之前呢,我们解释了从前端环境传人到wasm的参数的含义。其实呢,之前的InputJson只是为了能够方便的收集前端的Table信息。基于这些信息去拼装最后要生成excel必须的数据格式。

type?Dict?=?HashMap<String,?String>; #[derive(Debug,?Deserialize)] pub?struct?ColumnData?{ ????pub?width:?f32, } #[derive(Deserialize)] pub?struct?SheetData?{ ????name:?Option<String>, ????plain:?Option<Vec<Vec<Option<String>>>>, ????cols:?Option<Vec<Option<ColumnData>>>, ????merged:?Option<Vec<MergedCell>>, } #[derive(Deserialize)] pub?struct?SpreadsheetData?{ ????data:?SheetData, }

如上面所示,我们定义了一个SpreadsheetData

  1. data:就是用于构建我们 excel的数据信息`

data中,我们接收一个SheetData类型的结构体。其中每个字段的含义如下

  1. name:和之前一样,用于设置每个 sheet的名称

  2. plain:承载每个 cell的值

  3. cols:用于配置每个 col的宽度

  4. merged:用于配置合并信息


数据转换函数

那么,我们现在要做的最核心的部分就是将从前端环境接收的json对象转换为SpreadsheetData

函数 process_json 的作用是:

  1. 接收一个 JsValue 类型的 JSON 数据。

  2. 将其解析为特定的 Rust 结构体 InputJson

  3. 根据解析后的数据,构造一个 SpreadsheetData 类型的对象,包含处理后的表格数据及其样式等信息。

fn?process_json(raw_data:?&JsValue)?->?SpreadsheetData?{ ????let?input:?InputJson?=?match?raw_data.into_serde()?{ ????????Ok(data)?=>?data, ????????Err(err)?=>?{ ????????????//?记录日志或返回默认值 ????????????utils::log!("Failed?to?parse?JSON:?{:?}",?err); ????????????return?SpreadsheetData?{ ????????????????data:?vec![], ????????????????styles:?None, ????????????}; ????????} ????}; ????let?mut?plain?=?vec![ ????????input.columns ????????????.iter() ????????????.map(|col|?Some(col.title.clone())) ????????????.collect::<Vec<Option<String>>>() ????]; ????for?source_row?in?&input.source?{ ????????let?mut?row?=?Vec::new(); ????????for?column?in?&input.columns?{ ????????????let?value?=?source_row.get(&column.dataIndex).map(|v|?{ ????????????????match?v?{ ????????????????????serde_json::Value::String(s)?=>?s.clone(), ????????????????????serde_json::Value::Number(n)?=>?n.to_string(), ????????????????????serde_json::Value::Bool(b)?=>?b.to_string(), ????????????????????_?=>?String::new(), ????????????????} ????????????}); ????????????row.push(value); ????????} ????????plain.push(row); ????} ????let?cols?=?extract_width(&input.columns); ????let?merged:?Option<Vec<MergedCell>>?=?match?input.merge?{ ????????Some(merge)?if?!merge.is_empty()?=>?Some(merge), ????????None?=> ????????????match?input.correlation?{ ????????????????Some(ref?correlation)?if?!correlation.is_empty()?=> ????????????????????Some(handle_merge_info(correlation.clone(),?&input.source,?&input.columns)), ????????????????_?=>?None, ????????????} ????????_?=>?None, ????}; ????//?构造输出数据 ????let?output_data?=?SheetData?{ ????????name:?Some(input.name.clone()), ????????plain:?Some(plain), ????????merged:?merged, ????????cells:?None, ????????rows:?None, ????????cols:?Some(cols), ????}; ????SpreadsheetData?{ ????????data:?vec![output_data], ????????styles:?None, ????} }


1. JSON 解析

let?input:?InputJson?=?match?raw_data.into_serde()?{ ????Ok(data)?=>?data, ????Err(err)?=>?{ ????????//?记录日志或返回默认值 ????????utils::log!("Failed?to?parse?JSON:?{:?}",?err); ????????return?SpreadsheetData?{ ????????????data:?vec![], ????????????styles:?None, ????????}; ????} };

  • 作用

    • 将传入的 raw_data 转换为 Rust 的 InputJson 结构体。

    • 使用了 serde_wasm_bindgen::from_value (通过 into_serde 方法),将 JavaScript 的 JsValue 转为 Rust 的结构体。

    • 如果解析失败,记录日志并返回一个空的默认值。


2. 构造表格的标题行

let?mut?plain?=?vec![ ????input.columns ????????.iter() ????????.map(|col|?Some(col.title.clone())) ????????.collect::<Vec<Option<String>>>() ];

  • 作用

    • plain 是最终表格数据的二维数组,第一行用于存储列的标题。

    • 遍历 input.columns,提取每一列的标题 col.title,并存储在一个 Vec<Option<String>> 中。

    • 使用 Some(col.title.clone()) 包装标题,表示每个单元格的值可能为 Option<String>


3. 构造表格的每一行数据

for?source_row?in?&input.source?{ ????let?mut?row?=?Vec::new(); ????for?column?in?&input.columns?{ ????????let?value?=?source_row.get(&column.dataIndex).map(|v|?{ ????????????match?v?{ ????????????????serde_json::Value::String(s)?=>?s.clone(), ????????????????serde_json::Value::Number(n)?=>?n.to_string(), ????????????????serde_json::Value::Bool(b)?=>?b.to_string(), ????????????????_?=>?String::new(), ????????????} ????????}); ????????row.push(value); ????} ????plain.push(row); }

  • 作用

    • 遍历每一行的源数据 input.source

    • 对每个列的 dataIndex 进行查找,如果找到相应值,将其转换为字符串形式,并存储在 row 中。

    • 将每一行的 row 数据加入到 plain

  • 核心逻辑

    • 动态数据处理 :根据列的 dataIndexsource_row 中提取对应的值。

    • 类型处理 :处理可能的 JSON 数据类型,包括字符串、数字、布尔值等,将它们统一转换为字符串。

    • 默认值处理:如果数据类型不匹配或数据不存在,返回空字符串。


4. 提取列宽信息

let?cols?=?extract_width(&input.columns);

  • 作用

    • 提取每一列的宽度信息。

    • extract_width 应该是一个自定义函数,用于从 columns 中获取列的 width 属性或其默认值。


5. 合并单元格的处理

let?merged:?Option<Vec<MergedCell>>?=?match?input.merge?{ ????Some(merge)?if?!merge.is_empty()?=>?Some(merge), ????None?=>?match?input.correlation?{ ????????Some(ref?correlation)?if?!correlation.is_empty()?=> ????????????Some(handle_merge_info(correlation.clone(),?&input.source,?&input.columns)), ????????_?=>?None, ????}, ????_?=>?None, };

  • 作用

    • 处理表格的合并单元格信息。

    • 如果 input.merge 提供了明确的合并信息,则直接使用。

    • 如果未提供 merge 信息但存在 correlation 信息,则通过 handle_merge_info 动态生成合并信息。

  • 核心逻辑

    • 优先级: merge 的优先级高于 correlation

    • 处理了合并信息的多种来源,确保灵活性。


提取列宽信息

extract_width 这个函数的主要功能是从一组列(columns)中提取每列的宽度信息,并以 ColumnData 的形式返回。返回的宽度值是以 f32 类型表示的,并且该函数处理了几种不同的数据格式(数值、字符串等)。如果某列没有明确的宽度或格式错误,默认宽度为 100.0

fn?extract_width(columns:?&Vec<Column>)?->?Vec<Option<ColumnData>>?{ ????let?mut?cols?=?Vec::new(); ????for?column?in?columns?{ ????????utils::log!("width?{}",?column.width); ????????let?width:?f32?=?match?&column.width?{ ????????????serde_json::Value::Number(num)?=>?{ ????????????????num.as_f64() ????????????????????.map(|n|?n?as?f32) ????????????????????.unwrap_or(100.0) ????????????} ????????????serde_json::Value::String(s)?=>?{ ????????????????if?s.ends_with("px")?{ ????????????????????s.trim_end_matches("px").parse::<f32>().unwrap_or(100.0) ????????????????}?else?{ ????????????????????s.parse::<f32>().unwrap_or(100.0) ????????????????} ????????????} ????????????_?=>?100.0, ????????}; ????????cols.push(Some(ColumnData?{?width?})); ????} ????cols }

其中最核心的部分就是解析列宽

let?width:?f32?=?match?&column.width?{ ????serde_json::Value::Number(num)?=>?{ ????????num.as_f64() ????????????.map(|n|?n?as?f32) ????????????.unwrap_or(100.0) ????} ????serde_json::Value::String(s)?=>?{ ????????if?s.ends_with("px")?{ ????????????s.trim_end_matches("px").parse::<f32>().unwrap_or(100.0) ????????}?else?{ ????????????s.parse::<f32>().unwrap_or(100.0) ????????} ????} ????_?=>?100.0, };

  • 作用

    • 这部分代码处理不同格式的列宽数据,并将其统一转换为 f32 类型的宽度。

    • 详细步骤

      1. serde_json::Value::Number(num)

        • 如果 column.width 是一个数字类型(如 100),则尝试将其转换为 f64 类型,再转换为 f32

        • 如果转换失败,则使用默认值 100.0

      2. serde_json::Value::String(s)

        • 如果 column.width 是字符串类型(如 "100px""100"),首先检查字符串是否以 "px" 结尾。

          • 如果是 px,去掉 "px" 后缀,再尝试将剩余的部分转换为 f32

          • 如果字符串没有 px 后缀,则直接尝试将其转换为 f32

        • 如果解析失败,则使用默认值 100.0

      3. 其他类型

        • 如果 column.width 既不是数字也不是字符串,则返回默认宽度 100.0

合并单元格的处理

这段代码的功能是根据给定的列索引和相关联的列数据,计算出需要合并的单元格信息。具体来说,它依据 correlation 中的列和行的值,确定哪些单元格的内容是相同的,并根据这些相同的值来决定哪些单元格需要合并。最后,返回一个 MergedCell 的集合,其中包含了所有需要合并的单元格区域。

fn?handle_merge_info( ????correlation:?Vec<String>, ????source:?&[HashMap<String,?serde_json::Value>], ????columns:?&[Column] )?->?Vec<MergedCell>?{ ????let?mut?merged_cells?=?Vec::new(); ????//?找到所有?correlation?对应的列索引 ????let?column_index_map:?HashMap<&String,?usize>?=?columns ????????.iter() ????????.enumerate() ????????.map(|(idx,?col)|?(&col.dataIndex,?idx)) ????????.collect(); ????//?遍历?correlation?列,按顺序依次处理 ????for?(level,?correlation_key)?in?correlation.iter().enumerate()?{ ????????if?let?Some(&col_idx)?=?column_index_map.get(correlation_key)?{ ????????????let?mut?row_span_start?=?None;?//?记录当前合并区域的起始行 ????????????for?(row_idx,?item)?in?source.iter().enumerate()?{ ????????????????//?当前行该列的值 ????????????????let?current_value?=?item.get(correlation_key).cloned(); ????????????????//?上一行该列的值 ????????????????let?previous_value?=?if?row_idx?>?0?{ ????????????????????source[row_idx?-?1].get(correlation_key).cloned() ????????????????}?else?{ ????????????????????None ????????????????}; ????????????????//?检查当前列是否需要合并 ????????????????let?is_same_value?=?current_value?==?previous_value; ????????????????//?如果是多列(level?>?0),检查前置列的值是否也一致 ????????????????let?is_parent_matching?=?if?level?>?0?{ ????????????????????(0..level).all(|parent_level|?{ ????????????????????????if?let?Some(parent_key)?=?correlation.get(parent_level)?{ ????????????????????????????let?parent_current?=?item.get(parent_key); ????????????????????????????let?parent_previous?=?if?row_idx?>?0?{ ????????????????????????????????source[row_idx?-?1].get(parent_key) ????????????????????????????}?else?{ ????????????????????????????????None ????????????????????????????}; ????????????????????????????parent_current?==?parent_previous ????????????????????????}?else?{ ????????????????????????????false ????????????????????????} ????????????????????}) ????????????????}?else?{ ????????????????????true ????????????????}; ????????????????if?is_same_value?&&?is_parent_matching?{ ????????????????????//?如果满足合并条件,继续处理 ????????????????????if?row_span_start.is_none()?{ ????????????????????????row_span_start?=?Some(row_idx);?//?记录合并起点 ????????????????????} ????????????????}?else?{ ????????????????????//?如果不满足条件,结束当前合并区域 ????????????????????if?let?Some(start_row)?=?row_span_start?{ ????????????????????????merged_cells.push(MergedCell?{ ????????????????????????????from:?CellCoords::new(start_row,?col_idx), ????????????????????????????to:?CellCoords::new(row_idx,?col_idx), ????????????????????????}); ????????????????????????row_span_start?=?None;?//?重置合并起点 ????????????????????} ????????????????} ????????????} ????????????//?处理最后一个合并区域 ????????????if?let?Some(start_row)?=?row_span_start?{ ????????????????merged_cells.push(MergedCell?{ ????????????????????from:?CellCoords::new(start_row,?col_idx), ????????????????????to:?CellCoords::new(source.len(),?col_idx), ????????????????}); ????????????} ????????} ????} ????merged_cells }

以下是比较重要的点

1. 创建列索引映射 column_index_map

let?column_index_map:?HashMap<&String,?usize>?=?columns ????.iter() ????.enumerate() ????.map(|(idx,?col)|?(&col.dataIndex,?idx)) ????.collect();

  • 作用

    • 创建一个映射,将列的 dataIndex 映射到其对应的索引位置。这个映射用于快速查找每个列的索引。

    • column_index_map&String(列的 dataIndex)为键,列的索引 usize 为值。


2. 遍历 correlation 中的每个关联列

for?(level,?correlation_key)?in?correlation.iter().enumerate()?{ ????if?let?Some(&col_idx)?=?column_index_map.get(correlation_key)?{ ????????let?mut?row_span_start?=?None;?//?记录当前合并区域的起始行

  • 作用

    • 遍历 correlation 列表中的每个 correlation_key,表示需要根据该列的数据来判断是否合并单元格。

    • 如果当前列的 dataIndexcolumn_index_map 中存在,表示可以找到该列的索引 col_idx

    • row_span_start 用于标记当前合并区域的起始行,初始值为 None


3. 遍历 source 中的数据行

for?(row_idx,?item)?in?source.iter().enumerate()?{ ????let?current_value?=?item.get(correlation_key).cloned(); ????let?previous_value?=?if?row_idx?>?0?{ ????????source[row_idx?-?1].get(correlation_key).cloned() ????}?else?{ ????????None ????}; ????let?is_same_value?=?current_value?==?previous_value;

  • 作用

    • 遍历表格的每一行 source,在每一行中,根据当前的 correlation_key 获取该列的值 ( current_value),并与上一行相同列的值进行比较 ( previous_value)。

    • is_same_value 用来判断当前行与上一行该列的值是否相同。


4. 检查前置列是否匹配(多级合并)

let?is_parent_matching?=?if?level?>?0?{ ????(0..level).all(|parent_level|?{ ????????if?let?Some(parent_key)?=?correlation.get(parent_level)?{ ????????????let?parent_current?=?item.get(parent_key); ????????????let?parent_previous?=?if?row_idx?>?0?{ ????????????????source[row_idx?-?1].get(parent_key) ????????????}?else?{ ????????????????None ????????????}; ????????????parent_current?==?parent_previous ????????}?else?{ ????????????false ????????} ????}) }?else?{ ????true };

  • 作用

    • 如果 level > 0,表示这是一个多级合并,需要检查前置列的数据是否也相同。具体来说,检查在 correlation 中的前置列是否满足合并条件。

    • 使用 (0..level).all() 检查 correlation 列表中前 level 个列是否都满足合并条件,即当前行的前置列与上一行的值是否一致。

    • is_parent_matching 表示当前行是否符合前置列的匹配条件。


5. 合并条件判断与合并区域记录

if?is_same_value?&&?is_parent_matching?{ ????if?row_span_start.is_none()?{ ????????row_span_start?=?Some(row_idx);?//?记录合并起点 ????} }?else?{ ????if?let?Some(start_row)?=?row_span_start?{ ????????merged_cells.push(MergedCell?{ ????????????from:?CellCoords::new(start_row,?col_idx), ????????????to:?CellCoords::new(row_idx,?col_idx), ????????}); ????????row_span_start?=?None;?//?重置合并起点 ????} }

  • 作用

    • 如果当前行与上一行在 correlation_key 列的数据值相同,并且所有前置列( parent)的值也一致,则认为满足合并条件。

    • 如果 row_span_startNone,则表示当前合并区域还没有开始,记录下合并的起始行( row_idx)。

    • 如果当前行的数据值与上一行不相同,说明合并区域结束,记录当前合并区域,生成一个 MergedCell 对象,表示从 start_rowrow_idx 的合并区域。

    • 重置 row_span_startNone


6. 处理最后一个合并区域

if?let?Some(start_row)?=?row_span_start?{ ????merged_cells.push(MergedCell?{ ????????from:?CellCoords::new(start_row,?col_idx), ????????to:?CellCoords::new(source.len(),?col_idx), ????}); }

  • 作用

    • 在遍历结束后,如果 row_span_start 仍然存在,说明最后一个合并区域没有被处理,使用该区域的起始行和最后一行( source.len())来生成最后一个合并区域,并将其添加到 merged_cells 中。

4. 将构造好的函数,扔给我们的excel渲染引擎

pub?fn?generate_excel(raw_data:?&JsValue)?->?Vec<u8>?{ ????utils::set_panic_hook(); ????let?data?=?process_json(raw_data); ????//?省略部分代码 }

由于这块的篇幅有点冗长,为了行文的方便,这里就不在继续介绍了。

最后,我们会为大家提供编译好的wasm。然后以供大家使用。(由于文章是多平台发布的,如果大家找不到相关的资源,可以在评论区或者后台私聊我,要前端项目代码wasm


4. TODO

其实呢,无论是上面的使用示例还是代码,我们还有很多的未完善的部分。

  1. 表头合并

  2. 设置样式

  3. 多表导出(一次导出多个 sheet)

  4. 支持 excel公式

  5. ...


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。

Reference

[1]

SheetJS: https://docs.sheetjs.com/docs/

[2]

antd-table表格行/列合并: https://ant-design.antgroup.com/components/table-cn#table-demo-colspan-rowspan

本文由 mdnice 多平台发布

相关推荐
如若12317 分钟前
对文件内的文件名生成目录,方便查阅
java·前端·python
热心市民运维小孙17 分钟前
Ubuntu重命名默认账户
linux·ubuntu·excel
滚雪球~1 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语1 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport1 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg1 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww1 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_748254881 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
唐 城2 小时前
curl 放弃对 Hyper Rust HTTP 后端的支持
开发语言·http·rust
星就前端叭2 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc