专栏名称: 前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
目录
相关文章推荐
中国能源报  ·  关于举办绿电、绿证、CCER交易培训的通知 ·  2 天前  
中国能源报  ·  关于举办绿电、绿证、CCER交易培训的通知 ·  2 天前  
龙船风电网  ·  建设进度过半!这座海上风电场成本上涨 ·  2 天前  
南方能源观察  ·  深化新能源上网电价市场化改革正当其时 ·  2 天前  
中国舞台美术学会  ·  通知丨文化和旅游部艺术司关于征集戏曲创作优秀 ... ·  4 天前  
51好读  ›  专栏  ›  前端从进阶到入院

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

前端从进阶到入院  · 公众号  ·  · 2024-11-29 08:00

正文

此篇文章所涉及到的技术有
  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 等方案来接收后端的导出结果。但是呢,这种方式无疑增加业务的复杂度。
  • 纯前端处理,我们可以借助一些第三方的库例如SheetJS [1] 来执行数据的导出。
    • 优点:导出结果能够及时看到。
    • 缺点:处理数据量大的表格,性能就有点慢。同时,比如做一些表格合并(静态/动态)就有点麻烦,然后如果我们想对导出的 excel 某些 cell 做样式处理,这块也有很大的上手难度。

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

    运行效果

    静态表格

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

    静态表格合并

    动态表格合并


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



    我们能所学到的知识点

    1. 初衷
    2. 案例展示
    3. 源码解析
    4. TODO

    1. 初衷

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

    1. 在前端页面中发起一个异步任务
    2. 后端将特定的数据填充到 excel
    3. 后端向前端在返回一个 Blob 对象
      export const exportxxRecord = (data) => {
        return axiosInstance({
          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 的动态合并 。这你能受的了吗。

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

    更深的逻辑

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

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

    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,
        source: Vec<:collections::hashmap style="color: #e6c07b;line-height: 26px;">String, serde_json::Value>>,
        merge: Option <Vec>,
        correlation: Option<Vec<String>>,
    }
    1. name :接收一个 String 类型的数据,用于配置生成 excel sheetName
    2. columns :看 Struct 我们得知,它接收的是 Column 的数组,而 Column 是用于定义我们每列的具体信息。
    • 可以看到,类似 title/width/dataIndex 都是我们在前端构建 Table ( Antd )中用到的字段。(当然,当使用 Element 时,你可以将对应的结构转换成此种类型)
    • 其实这里有一个警告 ,在 Rust 中我们定义变量名,都是使用 蛇形命名法(snake_case) 是指每个空格皆以底线( _ )取代的书写风格,且每个单字的第一个字母皆为 小写 。但是,我们为最大程度的兼容前端的数据,不需要再转换,这里就使用了驼峰命名法
    • 当然,我们也不需要在传人的时候,在前端处理 columns 相关字段,无脑传即可
    • 这里还有一点需要说明,在前端 columns 我们定义 width 时候,是可以接收 number string 类型,在 Rust 中我们使用 serde_json::Value 来定义类型
  • source :这里我们用 Vec<:collections::hashmap serde_json::value>> 定义,对标前端数据的数据类型就是 对象
    • 也就是说,我们 source 传人对象即可,也是无脑传即可
  • merge : 是一个可选项,用于接收静态表格合并的信息
    • 其中 MergedCell 接收 from to 的相关信息。
  • correlation :这也是一个可选项,用于接收对于针对 列合并 时对应列的 dataIndex 信息。
    • 如果传人多个字段,那么这些字段默认有 关联关系 ,后面的字段会以前面字段分组后,才会执行合并操作。

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


    项目初始化

    还是熟悉的套路,我们使用 npx f_cli_f create table2excel 的前端项目。(发布到 npm f_cli_f rspack 版本的 .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}msspan>
        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 :该参数用于生成 excel sheetName

    效果展示

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

    导出耗时

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


    大数据表格导出

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

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

    const fs = require('fs');

    const dataStructure = {
      columns: [
        {
          title"A前端",
          width100,
          dataIndex"a"
        },
        {
          title"B柒八九",
          width150,
          dataIndex"b"
        },
        {
          title"C北宸",
          width100,
          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, null2), '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}msspan>
                div>
                <Table
                    columns={json.columns}
                    bordered
                    dataSource={json.source}
                    pagination={false}
                    virtual
                />

            div>
        section>

    );
    };

    export default Home;

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

    1. JSON Rspack 的一等公民,我们可以直接导入。 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 { colSpan0 };
      }
      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: {
                        column0,
                        row10,
                    },
                    to: {
                        column3,
                        row10,
                    },
                },
                {
                    from: {
                        column3,
                        row1,
                    },
                    to: {
                        column3,
                        row9,
                    },
                },
            ],
        });
        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}msspan>
                  div>
                  <Table
                      columns={mergeTable.columns}
                      bordered
                      dataSource={mergeTable.source}
                      pagination={false}
                  />

              div>
          section>

      );
    };

    export default Home;

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

    merge: [
        {
            from: {
                column0,
                row10,
            },
            to: {
                column3,
                row10,
            },
        },
        {
            from: {
                column3,
                row1,
            },
            to: {
                column3,
                row9,
            },
        },
    ],

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

    1. from :用于标识 起始 cell 位置
    2. to :用于标识 结束 cell 位置
    3. column row 就不必多解释了

    还有一点需要说明,由于我们在处理的时候,将 columns 中的 title 也抽离出来作为了 excel cell 。所以在 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<MapMap>>(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}msspan>
                  div>
                  <Table
                      columns ={columns}
                      bordered
                      dataSource={source}
                      pagination={false}
                  />

              div>
          section>

      );
    };

    export default Home;

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

    1. 我们在 useEffect 中通过 emulateAsync 来模拟一个异步任务。
    2. 随后,我们通过 calculateRowMerge 来计算 data columns dataIndex 的相关的合并信息。
    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;
          },
          [00as RowSpanTuple
        );

        return keyIndexMap;
      }, []);

    这一步其实,就是通过遍历 data ( data.reduce )来收集每列中数据相同的信息。然后,存放到一个 Map > 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 { rowSpan0 };
            }

            return { rowSpan0 };
        };

        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 { rowSpan0 };
            }

            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<StringString>;

    #[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>>,
        merged: Option<Vec>,
    }

    #[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> = 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 中。
      • 使用 Some(col.title.clone()) 包装标题,表示每个单元格的值可能为 Option

    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
    • 核心逻辑

      • 动态数据处理 :根据列的 dataIndex source_row 中提取对应的值。
      • 类型处理 :处理可能的 JSON 数据类型,包括字符串、数字、布尔值等,将它们统一转换为字符串。
      • 默认值处理 :如果数据类型不匹配或数据不存在,返回空字符串。

    4. 提取列宽信息

    let cols = extract_width(&input.columns);
    • 作用
      • 提取每一列的宽度信息。
      • extract_width 应该是一个自定义函数,用于从 columns 中获取列的 width 属性或其默认值。

    5. 合并单元格的处理

    let merged: Option<Vec> = 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







    请到「今天看啥」查看全文