在 React 中拆分 Excel 文件:从工作表到行列的实践

在后台管理系统的开发中,处理 Excel 文件导入导出是常见需求。当用户上传的 Excel 文件包含大量数据时,直接在前端进行拆分处理,往往能减轻后端压力,提升用户体验。本文将介绍在 React 中按工作表、按行、按列拆分 Excel 文件的具体实现。

为什么需要在浏览器端拆分 Excel?

在实际业务场景中,Excel 拆分有着广泛的应用需求:

  • 数据分发:总表包含多个部门的数据,按部门拆分成独立文件下发
  • 分批处理:单表数据量过大(如数万行),拆分成多个小文件便于查看和导入
  • 权限控制:不同角色只能查看特定列的数据,按列拆分后分别分发
  • 报表归档:按月、按季度拆分数据,便于长期存储

传统做法是在后端完成拆分,但这样做有几个问题:需要额外的存储空间、增加服务器计算压力、用户需要等待文件上传完成。如果能在浏览器端直接处理,用户选择文件后即可本地完成拆分并下载,体验会更好。

技术选型与准备

在浏览器端处理 Excel 并保留格式,目前主要有两类方案:

方案类型 代表库 优点 缺点
纯 JS 解析 xlsx (SheetJS) 轻量、启动快 写入时格式保留有限
WebAssembly 基于 WASM 的库 格式保真度高 体积大、加载慢

本文的实现基于 WebAssembly 方案,因为它在拆分时需要完整保留原文件的单元格样式、公式、合并单元格和列宽等细节。该库可通过 npm 安装,WASM 模块在浏览器端运行。

安装依赖:

bash 复制代码
npm i spire.xls

安装后需要将 spire.xls.js 和对应的 .wasm 文件放在 public 目录下,并在页面加载时初始化 WASM 模块。初始化过程通常需要几秒钟,建议在 UI 中给出加载提示。

准备工作:虚拟文件系统与资源加载

基于 WASM 的 Excel 库在浏览器中维护了一个虚拟文件系统(VFS)。所有文件操作都发生在这个内存文件系统中,不会触及用户的真实文件系统。

操作流程通常是:

  1. 将字体文件和源 Excel 文件通过 FetchFileToVFS 载入 VFS
  2. 从 VFS 读取并处理文件
  3. 将生成的文件写入 VFS
  4. 从 VFS 读取结果并触发下载

为什么需要加载字体?因为 Excel 文件中的文本渲染依赖字体信息,如果没有正确加载字体,导出的文件可能出现文本显示异常。

javascript 复制代码
// 加载字体到 VFS
await window.spire.FetchFileToVFS('arial.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
// 加载源 Excel 文件到 VFS
await window.spire.FetchFileToVFS('Sample.xlsx', '', `${process.env.PUBLIC_URL}data/`);

按工作表拆分

将多工作表工作簿中的每个工作表保存为独立的 Excel 文件,是最直接的拆分方式。

jsx 复制代码
const splitByWorksheet = async () => {
  const xlsModule = window.wasmModule?.spirexls;
  if (!xlsModule) {
    alert('Excel 处理模块尚未加载完成');
    return;
  }

  // 加载字体和源文件到虚拟文件系统
  await window.spire.FetchFileToVFS('arial.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
  const inputFileName = 'Sample.xlsx';
  await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

  const workbook = new xlsModule.Workbook();
  workbook.LoadFromFile({ fileName: inputFileName });

  // 遍历所有工作表
  for (let i = 0; i < workbook.Worksheets.Count; i++) {
    const sheet = workbook.Worksheets.get(i);
    
    // 创建新工作簿并复制当前工作表
    const newWorkbook = new xlsModule.Workbook();
    const newSheet = newWorkbook.Worksheets.get(0);
    newSheet.CopyFrom(sheet); // 复制全部内容及样式
    
    const outputFileName = `${sheet.Name}.xlsx`;
    newWorkbook.SaveToFile({ fileName: outputFileName, version: xlsModule.ExcelVersion.Version2010 });
    newWorkbook.Dispose();

    // 从虚拟文件系统读取并触发下载
    const fileData = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  }

  workbook.Dispose();
};

踩坑提醒new Workbook() 默认包含一个空白工作表,用 CopyFrom 覆盖后,空白表会被替换。如果不希望保留空白表,直接复制即可。

按行拆分

当数据行数过多时,按固定行数拆分是刚需。比如一个包含 10000 行数据的表格,每 2000 行拆成一个文件。

实现逻辑

  • 先复制表头(第 1 行)到每个拆分文件
  • 再将指定范围的数据行复制进去
  • 复制列宽以保证显示效果
jsx 复制代码
const splitByRow = async () => {
  const xlsModule = window.wasmModule?.spirexls;
  if (!xlsModule) {
    alert('Excel 处理模块尚未加载完成');
    return;
  }

  // 加载字体和源文件到虚拟文件系统
  await window.spire.FetchFileToVFS('arial.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
  const inputFileName = 'Sample.xlsx';
  await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

  const workbook = new xlsModule.Workbook();
  workbook.LoadFromFile({ fileName: inputFileName });
  const sheet = workbook.Worksheets.get(0);

  // 拆分第 1-5 行(含表头)到第一个文件
  const newWorkbook1 = new xlsModule.Workbook();
  const newSheet1 = newWorkbook1.Worksheets.get(0);

  let destRow = 1;
  // 复制表头
  sheet.Copy({
    sourceRange: sheet.Rows[0],
    worksheet: newSheet1,
    destRow: destRow,
    destColumn: 1,
    copyStyle: true
  });
  destRow++;

  // 复制数据行(第 2-5 行,索引 1-4)
  for (let i = 1; i < 5; i++) {
    sheet.Copy({
      sourceRange: sheet.Rows[i],
      worksheet: newSheet1,
      destRow: destRow,
      destColumn: 1,
      copyStyle: true
    });
    destRow++;
  }

  // 复制列宽
  for (let c = 0; c < sheet.Columns.length; c++) {
    newSheet1.SetColumnWidth(c + 1, sheet.GetColumnWidth(c + 1));
  }

  const outputFileName1 = "Rows1-5.xlsx";
  newWorkbook1.SaveToFile({ fileName: outputFileName1, version: xlsModule.ExcelVersion.Version2010 });
  newWorkbook1.Dispose();
  const fileData1 = window.dotnetRuntime.Module.FS.readFile(outputFileName1);

  // 拆分第 6-10 行(含表头)到第二个文件
  const newWorkbook2 = new xlsModule.Workbook();
  const newSheet2 = newWorkbook2.Worksheets.get(0);

  destRow = 1;
  // 复制表头
  sheet.Copy({
    sourceRange: sheet.Rows[0],
    worksheet: newSheet2,
    destRow: destRow,
    destColumn: 1,
    copyStyle: true
  });
  destRow++;

  // 复制数据行(第 6-10 行,索引 5-9)
  for (let i = 5; i < 10; i++) {
    sheet.Copy({
      sourceRange: sheet.Rows[i],
      worksheet: newSheet2,
      destRow: destRow,
      destColumn: 1,
      copyStyle: true
    });
    destRow++;
  }

  for (let c = 0; c < sheet.Columns.length; c++) {
    newSheet2.SetColumnWidth(c + 1, sheet.GetColumnWidth(c + 1));
  }

  const outputFileName2 = "Rows6-10.xlsx";
  newWorkbook2.SaveToFile({ fileName: outputFileName2, version: xlsModule.ExcelVersion.Version2010 });
  newWorkbook2.Dispose();
  const fileData2 = window.dotnetRuntime.Module.FS.readFile(outputFileName2);

  // 打包为 ZIP 下载
  const zip = new JSZip();
  zip.file(outputFileName1, fileData1);
  zip.file(outputFileName2, fileData2);
  const zipBlob = await zip.generateAsync({ type: 'blob' });
  const zipUrl = URL.createObjectURL(zipBlob);
  const a = document.createElement('a');
  a.href = zipUrl;
  a.download = "SplitByRows.zip";
  a.click();
  URL.revokeObjectURL(zipUrl);

  workbook.Dispose();
};

为什么用 Copy 而不是 CopyFrom CopyFrom 用于复制整个工作表,而 Copy 方法可以精确控制复制范围(行、列或单元格区域),更适合按行列拆分的场景。

按列拆分

当表格列数过多时,按列拆分可以让不同部门只看到自己关心的数据。比如一张包含员工信息(姓名、部门、薪资、绩效等)的表格,HR 部门只需要姓名和部门列,财务部门只需要薪资相关列。

jsx 复制代码
const splitByColumn = async () => {
  const xlsModule = window.wasmModule?.spirexls;
  if (!xlsModule) {
    alert('Excel 处理模块尚未加载完成');
    return;
  }

  // 加载字体和源文件到虚拟文件系统
  await window.spire.FetchFileToVFS('arial.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
  const inputFileName = 'Sample.xlsx';
  await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

  const workbook = new xlsModule.Workbook();
  workbook.LoadFromFile({ fileName: inputFileName });
  const sheet = workbook.Worksheets.get(0);

  // 拆分第 1-2 列(A、B 列)到第一个文件
  const newWorkbook1 = new xlsModule.Workbook();
  const newSheet1 = newWorkbook1.Worksheets.get(0);

  for (let i = 1; i <= 2; i++) {
    sheet.Copy({
      sourceRange: sheet.Columns[i - 1],
      worksheet: newSheet1,
      destRow: 1,
      destColumn: i,
      copyStyle: true
    });
  }

  // 复制列宽
  for (let i = 1; i <= 2; i++) {
    newSheet1.SetColumnWidth(i, sheet.GetColumnWidth(i));
  }

  const outputFileName1 = "ColumnsAB.xlsx";
  newWorkbook1.SaveToFile({ fileName: outputFileName1, version: xlsModule.ExcelVersion.Version2010 });
  newWorkbook1.Dispose();
  const fileData1 = window.dotnetRuntime.Module.FS.readFile(outputFileName1);

  // 拆分第 3-4 列(C、D 列)到第二个文件
  const newWorkbook2 = new xlsModule.Workbook();
  const newSheet2 = newWorkbook2.Worksheets.get(0);

  for (let i = 3; i <= 4; i++) {
    sheet.Copy({
      sourceRange: sheet.Columns[i - 1],
      worksheet: newSheet2,
      destRow: 1,
      destColumn: i - 2,
      copyStyle: true
    });
  }

  for (let i = 3; i <= 4; i++) {
    newSheet2.SetColumnWidth(i - 2, sheet.GetColumnWidth(i));
  }

  const outputFileName2 = "ColumnsCD.xlsx";
  newWorkbook2.SaveToFile({ fileName: outputFileName2, version: xlsModule.ExcelVersion.Version2010 });
  newWorkbook2.Dispose();
  const fileData2 = window.dotnetRuntime.Module.FS.readFile(outputFileName2);

  // 打包为 ZIP 下载
  const zip = new JSZip();
  zip.file(outputFileName1, fileData1);
  zip.file(outputFileName2, fileData2);
  const zipBlob = await zip.generateAsync({ type: 'blob' });
  const zipUrl = URL.createObjectURL(zipBlob);
  const a = document.createElement('a');
  a.href = zipUrl;
  a.download = "SplitByColumns.zip";
  a.click();
  URL.revokeObjectURL(zipUrl);

  workbook.Dispose();
};

注意 :复制列时,sourceRange 指定的是 sheet.Columns[i - 1],它是 Column 对象,代表整列。destColumn 控制目标位置,确保列的顺序与原文件一致。

多文件下载的处理

当拆分产生多个文件时,逐个触发浏览器下载弹窗显然不是好体验。常见的做法是使用 JSZip 将所有文件打包成一个 ZIP 压缩包,用户一次下载即可获得所有文件。

bash 复制代码
npm i jszip
javascript 复制代码
import JSZip from 'jszip';

// 收集所有拆分文件的数据
const files = [];
// ... 拆分逻辑,将 { name, data } 存入 files 数组

// 打包下载
const zip = new JSZip();
files.forEach(f => zip.file(f.name, f.data));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipUrl = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = zipUrl;
a.download = 'SplitFiles.zip';
a.click();
URL.revokeObjectURL(zipUrl);

实践中遇到的一些问题

1. WASM 加载失败

如果在本地开发时遇到 Failed to fetchCompileError,通常是因为 .wasm 文件路径不正确。检查 public 目录下的文件结构,确保 locateFile 函数返回的路径能正确访问到 .wasm 文件。

2. 导出的文件打开后样式丢失

最常见的原因是字体没有正确加载。确认字体文件已经通过 FetchFileToVFS 加载到 /Library/Fonts/ 目录,并且路径和文件名正确(注意大小写)。

3. 内存占用过高

处理大文件时,如果循环中创建了大量 Workbook 对象且没有及时释放,浏览器可能会崩溃。务必在每次循环结束前调用 Dispose(),并且用 try...finally 确保资源释放。

javascript 复制代码
try {
  // 处理逻辑
} finally {
  newWorkbook.Dispose();
}

4. 拆分后的列宽不准确

Copy 方法复制列数据时不会自动复制列宽,需要手动调用 SetColumnWidth 逐个设置。这是 API 设计的特点,需要开发者留意。

总结

在 React 中拆分 Excel 文件,技术方案已经比较成熟。通过基于 WebAssembly 的库,可以在浏览器端完整保留原始文档的样式和格式,避免文件上传到服务端的延迟和安全风险。三种拆分方式各有适用的场景:按工作表拆分适合处理包含多个独立数据表的工作簿,每个工作表独立成文件便于分发和归档;按行拆分适合处理数据量庞大的表格,将大量数据分批处理或按固定行数切分,同时保留表头以维持数据的可读性;按列拆分适合表格列数过多或不同角色只需查看部分列数据的场景,能够按权限或业务模块精准分发列数据。实际开发中需要根据业务需求选择合适的拆分方式,并注意 WASM 加载、内存释放、字体依赖等技术细节,确保应用稳定高效运行。希望本文的实践能够为你在 React 中处理 Excel 拆分需求提供一些参考。

相关推荐
想要成为糕糕手1 小时前
238. 除了自身以外数组的乘积 — 面试向深度解析
javascript·算法·面试
kisshyshy1 小时前
前端存储、表单异步提交与 this 指向完全指南
前端·javascript·html
Kel1 小时前
管道、IPC、Socket:你以为的三种技术,其实是同一件事
javascript·设计模式·node.js
心中有国也有家1 小时前
AtomGit Flutter 鸿蒙客户端: AnimatedScale 与 AnimatedContainer 联合实战
android·javascript·flutter·华为·harmonyos
嘉泰姆半导体官方1 小时前
CXAR41337C 双通道数字音频功放 | 2×20W | EQ+DRC | I²S | MCLK-Less - 嘉泰姆电子
前端·javascript·数据库
胡萝卜术1 小时前
从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路
前端·javascript·面试
名字还没想好☜1 小时前
React 受控与非受控组件:表单场景怎么选
前端·javascript·react.js·react·表单·受控组件
weedsfly2 小时前
前端开发中的策略模式——告别 if-else 的优雅之道
前端·javascript·面试
不坑老师2 小时前
怎么在Word中制作汉字书写笔顺——不坑盒子汉字笔顺步骤在小学低段识字教学中的应用
前端·数据库·人工智能·word·excel·office