在后台管理系统的开发中,处理 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)。所有文件操作都发生在这个内存文件系统中,不会触及用户的真实文件系统。
操作流程通常是:
- 将字体文件和源 Excel 文件通过
FetchFileToVFS载入 VFS - 从 VFS 读取并处理文件
- 将生成的文件写入 VFS
- 从 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 fetch 或 CompileError,通常是因为 .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 拆分需求提供一些参考。