如何在 JavaScript 和 React 中下载/导出 Excel 文件

日常开发中,将页面表格或报表数据导出为 Excel 文件,是一项出现频率极高的产品需求。无论内部管理系统、数据运营平台,还是面向外部用户的 SaaS 应用,都绕不开这个功能。

传统实现方案通常由后端服务借助相关库(如 Apache POI、EPPlus 等)生成文件后,再通过响应流返回前端下载。这套链路虽然成熟稳定,但也存在几项固有成本:每次导出都需要发起一次网络请求,后端需要额外处理并发及内存开销,同时数据需经过服务端中转,可能增加敏感信息暴露面。

近年来,随着 WebAssembly 技术在前端领域落地,陆续出现了一些可在浏览器端直接处理 Excel 文件的方案。这些方案将原本依赖服务端的能力迁移至客户端,为上述问题提供了不同的解决路径。

本文以 Spire.XLS for JavaScript 为例,介绍如何在不依赖后端服务的情况下,在原生 JavaScript 和 React 项目中完成 Excel 文件的创建、内容填充与导出下载。文章所述的方法与代码仅作为技术选型参考,不代表对该产品的推荐或背书。

使用前提与初始化

Spire.XLS for JavaScript 是一个基于 WebAssembly 的 Excel 操作库,支持 XLS、XLSX、XLSB、ODS 等常见格式,并提供单元格样式、公式、图表、数据透视表等功能。其核心工作逻辑与多数 WASM 库类似:先加载运行时环境,再通过 JavaScript 调用导出的 API。

安装

通过 npm 安装依赖包:

bash 复制代码
npm i spire.xls

安装完成后,node_modules 中会包含 spire.office 目录,内含 WebAssembly 运行时文件及核心库脚本。

初始化 WASM 模块

该库基于 WebAssembly,因此在调用任何 Excel API 之前,需要先完成模块的初始化。初始化过程会加载所需资源并设置运行时环境。这一步骤通常在应用启动时执行:

javascript 复制代码
// 首先导入并初始化公共模块
import('/node_modules/spire.office/spire.common.js').then(async (commonModule) => {
    // 初始化 WASM 运行时
    await commonModule.initializeWasm();
    
    // 加载 XLS 模块
    await import('/node_modules/spire.office/spire.xls.js');
    
    console.log('Spire.XLS 已就绪');
});

初始化完成后,库的 API 会挂载到全局对象 window.spirexlswindow.xlswasm 上。后续所有 Excel 操作均通过该全局对象调用。

不同版本中 API 挂载位置可能略有差异,建议在实际使用前通过 console.log(window.spirexls || window.xlswasm) 确认。

原生 JavaScript 环境下的导出实现

以下是一个完整的浏览器端导出流程:创建工作簿、填充数据、保存到虚拟文件系统,最后触发浏览器下载。

javascript 复制代码
// 确保 WASM 模块已初始化
if (!window.spirexls && !window.xlswasm) {
    console.error("Spire.XLS 未初始化。");
    return;
}

// 获取已初始化的 WebAssembly 模块
const wasmModule = window.spirexls || window.xlswasm;

// 创建新工作簿
const workbook = new wasmModule.Workbook();
const worksheet = workbook.Worksheets.get(0);

// 创建示例数据
const products = [
    ["产品", "数量", "价格"],
    ["笔记本电脑", 10, 999.99],
    ["鼠标", 50, 24.99]
];

// 将数据插入工作表
for (let i = 0; i < products.length; i++) {
    for (let j = 0; j < products[i].length; j++) {
        const cell = worksheet.Range.get({ row: i + 1, column: j + 1 });
        if (typeof products[i][j] === "string") {
            cell.Text = products[i][j];
        } else {
            cell.NumberValue = products[i][j];
        }
    }
}

// 添加总计列(第四列)
worksheet.Range.get({ row: 1, column: products[0].length + 1 }).Text = "总计";
worksheet.Range.get({ row: 2, column: products[0].length + 1 }).Formula = "=B2*C2";
worksheet.Range.get({ row: 3, column: products[0].length + 1 }).Formula = "=B3*C3";

// 将工作簿保存到虚拟文件系统 (VFS)
const outputFileName = "Report.xlsx";
workbook.SaveToFile({
    fileName: outputFileName,
    version: wasmModule.ExcelVersion.Version2016
});

// 释放工作簿资源
workbook.Dispose();

// 从 VFS 读取生成的文件
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);

// 创建 Blob 对象
const excelBlob = new Blob(
    [fileArray],
    {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    }
);

// 触发浏览器下载
const url = URL.createObjectURL(excelBlob);
const a = document.createElement("a");
a.href = url;
a.download = outputFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);

上述代码的几个关键点

  • 虚拟文件系统 (VFS) :该库的 SaveToFile() 不会将文件写入操作系统磁盘,而是存放在 WebAssembly 实例的内存文件系统中。导出时需通过 window.dotnetRuntime.Module.FS.readFile() 读出原始二进制数据,再转换为浏览器可识别的 Blob 对象。
  • 资源管理 :每个 Workbook 实例在完成保存后,建议显式调用 Dispose() 方法释放内存。对于频繁导出的场景,这一点尤为重要,否则可能导致内存占用逐步上升。
  • 行列索引:Excel 的行号和列号从 1 开始,而数组下标从 0 开始,赋值时需注意偏移。
  • 全局对象依赖window.dotnetRuntime 由 WASM 运行时自动注入,无需额外声明。

在 React 项目中封装导出组件

在 React 框架中使用该库时,推荐将导出逻辑抽离为独立的 Hook 或组件,以便在不同页面复用。同时需要借助 useState 管理加载状态,防止用户重复点击。

组件示例

jsx 复制代码
import { useState } from 'react';

const ExcelExportButton = ({ data, fileName = 'Export.xlsx' }) => {
  const [isExporting, setIsExporting] = useState(false);

  const handleExport = async () => {
    // 确保 WASM 模块已初始化
    const wasmModule = window.spirexls || window.xlswasm;
    if (!wasmModule || isExporting) {
      console.error("Spire.XLS 未初始化或正在导出中");
      return;
    }

    setIsExporting(true);

    try {
      const workbook = new wasmModule.Workbook();
      const worksheet = workbook.Worksheets.get(0);

      // 根据数据对象动态生成表头
      const headers = Object.keys(data[0] || {});
      // 写入表头(第一行)
      headers.forEach((key, index) => {
        worksheet.Range.get({ row: 1, column: index + 1 }).Text = key;
      });

      // 写入数据行(从第二行开始)
      data.forEach((item, rowIndex) => {
        headers.forEach((key, colIndex) => {
          const value = item[key];
          const cell = worksheet.Range.get({
            row: rowIndex + 2,
            column: colIndex + 1
          });
          if (typeof value === 'string') {
            cell.Text = value;
          } else if (typeof value === 'number') {
            cell.NumberValue = value;
          } else if (value instanceof Date) {
            cell.Text = value.toLocaleDateString();
          }
          // 其他类型(如 boolean)可根据需要扩展处理
        });
      });

      const exportFileName = fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`;
      workbook.SaveToFile({
        fileName: exportFileName,
        version: wasmModule.ExcelVersion.Version2016
      });

      workbook.Dispose();

      const fileArray = window.dotnetRuntime.Module.FS.readFile(exportFileName);
      const blob = new Blob([fileArray], {
        type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
      });

      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = exportFileName;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(url);

    } catch (error) {
      console.error('导出失败:', error);
      // 此处可补充用户提示逻辑,如 toast 消息
    } finally {
      setIsExporting(false);
    }
  };

  return (
    <button onClick={handleExport} disabled={isExporting}>
      {isExporting ? '生成中...' : '导出 Excel'}
    </button>
  );
};

export default ExcelExportButton;

使用示例

jsx 复制代码
const App = () => {
  const tableData = [
    { 姓名: '张三', 部门: '技术部', 工时: 40 },
    { 姓名: '李四', 部门: '产品部', 工时: 38 }
  ];

  return (
    <div>
      <ExcelExportButton data={tableData} fileName="月度工时表" />
    </div>
  );
};

备选方案:从已有 HTML 表格导出

如果页面上已经存在一个渲染好的 HTML 表格,可以直接将其转换为 Excel 文件,无需重新构建数据结构。官方示例中同时展示了如何为导出的表格应用内置样式(如标题行、隔行交替色)以及自动调整列宽。

javascript 复制代码
async function exportTableToExcel() {
    if (!window.spirexls && !window.xlswasm) {
        alert("Spire.XLS 模块尚未加载。");
        return;
    }

    const button = document.getElementById("exportBtn");
    button.disabled = true;
    button.innerText = "正在导出...";

    const wasmModule = window.spirexls || window.xlswasm;

    try {
        // 获取 HTML 表格
        const tableHtml = document.getElementById("salesTable").outerHTML;

        // 移除内联样式(避免样式干扰)
        const safeTableHtml = tableHtml.replace(/style="[^"]*"/g, '');

        const htmlContent = `
            <!DOCTYPE html>
            <html>
            <head>
                <meta charset="UTF-8">
            </head>
            <body>
                ${safeTableHtml}
            </body>
            </html>
        `;

        const htmlFileName = "Table.html";

        // 将 HTML 内容写入 VFS
        window.dotnetRuntime.Module.FS.writeFile(htmlFileName, htmlContent);

        // 加载 HTML 文件并转换为 Excel 工作簿
        const workbook = new wasmModule.Workbook();
        workbook.LoadFromHtml(htmlFileName);

        const sheet = workbook.Worksheets.get(0);

        // 获取表格的实际行列范围
        const lastRow = Number(sheet.LastRow);
        const lastCol = Number(sheet.LastColumn);

        // 为表头行应用内置标题样式
        const headerRow = sheet.Range.get_Item(1, 1, 1, lastCol);
        headerRow.BuiltInStyle = wasmModule.BuiltInStyles.Heading3;

        // 为数据行应用隔行交替背景色
        for (let i = 2; i <= lastRow; i++) {
            const row = sheet.Range.get_Item(i, 1, i, lastCol);
            row.BuiltInStyle = i % 2 === 0
                ? wasmModule.BuiltInStyles.Accent3_20
                : wasmModule.BuiltInStyles.Accent3_60;
        }

        // 自动调整所有列的宽度以适应内容
        for (let j = 1; j <= lastCol; j++) {
            sheet.AutoFitColumn(j);
        }

        const outputFileName = "SalesReport.xlsx";

        workbook.SaveToFile({
            fileName: outputFileName,
            version: wasmModule.ExcelVersion.Version2016
        });

        workbook.Dispose();

        const fileData = window.dotnetRuntime.Module.FS.readFile(outputFileName);
        const blob = new Blob([fileData], {
            type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
        });

        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = outputFileName;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

    } catch (error) {
        alert("导出失败:" + error.message);
    } finally {
        button.disabled = false;
        button.innerText = "导出 Excel";
    }
}

此方式的关键点在于:

  • 通过 FS.writeFile() 将 HTML 内容写入虚拟文件系统,再使用 LoadFromHtml() 加载。
  • 移除表格内联样式可以避免某些样式属性在转换时产生意外效果。
  • BuiltInStyle 属性对应库内置的预定义样式集,可用于快速美化导出表格。
  • AutoFitColumn() 方法可自动调整列宽,提升生成文件的可用性。

该方法适用于表格结构相对规整、对样式要求不高的场景。对于合并单元格、自定义字体颜色等复杂样式,转换效果可能因库的实现细节而有所差异,建议在实际使用前进行充分测试。

常见问题与注意事项

中文字体显示异常

某些环境下导出的 Excel 文件打开后,中文可能显示为方框或乱码。这通常是因为虚拟文件系统中缺少中文字体文件。解决方法是在初始化后,将字体文件(如 simsun.ttcarial.ttf)通过 FetchFileToVFS 方法加载到 /Library/Fonts/ 目录下。具体代码可参考官方示例中的字体加载部分。

模块加载与初始化时机

WebAssembly 文件体积较大,首次加载需要一定时间。建议在应用启动阶段异步执行初始化,而非等到用户点击导出按钮时才开始加载,以改善交互体验。

内存管理与大数据量

浏览器端处理 Excel 受限于 JavaScript 堆内存和 WASM 线性内存。对于超过 10MB 或数万行以上的文件,客户端方案可能遇到性能瓶颈。这种情况仍建议采用服务端生成方案。

浏览器兼容性

WebAssembly 已被所有现代桌面浏览器(Chrome 96+、Firefox 90+、Safari 15+、Edge 96+)原生支持。需注意 Internet Explorer 及部分旧版移动端浏览器不支持该技术。

总结

Spire.XLS for JavaScript 提供了一套基于 WebAssembly 的浏览器端 Excel 操作能力,使开发者可以在不依赖后端服务的前提下完成文件的创建、编辑与导出。其核心工作流程为:

初始化 WASM 模块 → 创建/操作 Workbook 对象 → 保存至虚拟文件系统 → 读取二进制数据 → 构造 Blob 并触发下载

在 React 项目中,可将导出逻辑封装为组件或自定义 Hook,配合状态管理提升用户体验。同时,该库也支持从 HTML 表格直接转换,并提供了内置样式应用、列宽自动调整等实用功能,为已有页面快速添加导出功能提供了便利。

与所有客户端方案类似,该方法在数据量、字体处理、兼容性方面有其适用边界。实际选型时,建议结合项目的数据规模、目标浏览器环境及团队维护成本综合考量。

相关推荐
你怎么知道我是队长1 小时前
JavaScript的介绍
开发语言·javascript·ecmascript
anyup1 小时前
uView Pro 正式支持 MCP 协议!让 AI 秒懂你的组件库
前端·uni-app·ai编程
触底反弹1 小时前
🔥 50 行代码,我手写了一个 MCP Server 给 Claude 用!
javascript·人工智能·程序员
Coffeeee1 小时前
从 Android 15 到 Android 17,谷歌猝不及防的音频改动
android·前端·google
大流星1 小时前
LangChainJs之Prompt提示词(二)
javascript·langchain
雪隐1 小时前
用Flutter做背单词APP-00前言
前端·人工智能·后端
何时梦醒1 小时前
前端存储全攻略 & JavaScript this 绑定完全指南
前端·javascript·面试
前端百草阁1 小时前
JavaScript 设计模式(23 种)
开发语言·前端·javascript·设计模式
用户69371750013842 小时前
AI 领域的 Harness,到底是什么意思?
android·前端·后端