在 Web 应用开发中,处理 Excel 文件的合并是一个常见需求。传统方案通常依赖后端服务来完成文件处理,但这种方式增加了服务器负担和网络传输成本。本文将介绍一种基于 WebAssembly 技术的纯前端解决方案,在浏览器端直接完成多个 Excel 文件的合并操作。
本文的实现基于 React 框架,通过加载 WASM 模块来实现 Excel 文件的读写能力。整个方案不依赖任何后端服务,所有数据处理均在本地完成,既保障了数据隐私,又降低了服务器压力。

技术方案概述
核心思路
整个方案的核心思路是在浏览器内存中构建一个虚拟文件系统(VFS),将用户选择的多个 Excel 文件写入该虚拟文件系统,然后使用 WASM 模块提供的 Excel 处理能力依次读取每个文件,将其中所有工作表复制到一个目标工作簿中,最后将合并结果导出并下载。
技术选型
- 前端框架:React(利用 Hooks 管理状态和副作用)
- Excel 处理引擎 :基于 WebAssembly 的 Excel 库(Spire.XLS for JavaScript 或其免费版)
- 虚拟文件系统:WASM 运行时自带的 FS(文件系统)模块
- 字体渲染支持:通过 VFS 加载 TrueType 字体文件,确保中文等字符正确渲染
核心实现详解
1. WASM 模块加载
WASM 模块的加载是整个应用的基础。代码使用 React 的 useEffect 钩子在组件挂载时完成加载,并通过 useState 保存模块实例供后续使用。
javascript
useEffect(() => {
let mounted = true;
(async () => {
try {
const publicUrl = process.env.PUBLIC_URL || '';
// 动态导入 WASM 模块的 JavaScript 胶水代码
const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.xls.js`);
const rawModule = spireModule.default || spireModule;
// 实例化 WASM 模块,指定 WASM 文件的查找路径
const module = typeof rawModule === 'function'
? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
: rawModule;
if (mounted) {
window.wasmModule = module;
setWasmModule(module);
}
} catch (err) {
console.error('WASM 加载失败:', err);
}
})();
return () => { mounted = false; };
}, []);
关键技术点:
- 使用
webpackIgnore: true注释告诉 Webpack 不要处理这个动态导入,保持路径原样。 locateFile回调函数用于指定 WASM 二进制文件的加载路径,确保模块能找到.wasm文件。- 将模块实例挂载到
window.wasmModule上,方便在事件处理函数中访问。
2. 虚拟文件系统(VFS)操作
WASM 运行时提供了虚拟文件系统,允许 JavaScript 代码在 WASM 的内存空间中读写文件。这是实现 Excel 合并的关键基础设施。
字体加载
Excel 文件的文本渲染需要字体支持。首次合并时,代码会将字体文件写入 VFS 的 /Library/Fonts/ 目录:
javascript
if (!fontLoadedRef.current) {
await window.spire.FetchFileToVFS(
'Arial.ttf',
'/Library/Fonts/',
`${process.env.PUBLIC_URL}/static/font/`
);
fontLoadedRef.current = true;
}
FetchFileToVFS 是一个从网络获取文件并写入 VFS 的工具函数,其内部实现大致为:
- 从指定 URL 获取字体文件的二进制数据
- 通过 WASM 运行时的 FS 模块将数据写入 VFS
用户文件写入
用户选择的每个 Excel 文件都需要写入 VFS,以便 WASM 模块能够读取:
javascript
for (const file of selectedFiles) {
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
window.dotnetRuntime.Module.FS.writeFile(file.name, uint8);
}
这里使用了 window.dotnetRuntime.Module.FS.writeFile 方法,它是 WASM 运行时暴露的文件系统 API,支持将二进制数据直接写入 VFS。
3. Excel 文件合并逻辑
合并逻辑是应用的核心,主要分为以下几个步骤:
3.1 创建目标工作簿
javascript
newbook = new mod.Workbook();
newbook.Version = mod.ExcelVersion.Version2016;
newbook.Worksheets.Clear();
创建一个新的工作簿,设置 Excel 版本为 2016,并清空默认创建的工作表,确保合并后的文件只包含用户文件中的数据。
3.2 遍历处理每个源文件
javascript
tempbook = new mod.Workbook();
for (const file of selectedFiles) {
tempbook.LoadFromFile(file.name);
const count = tempbook.Worksheets.Count;
for (let i = 0; i < count; i++) {
const sheet = tempbook.Worksheets.get(i);
mod.XlsWorksheetsCollection.Convert(newbook.Worksheets).AddCopy({
sheet: sheet,
flags: mod.WorksheetCopyType.CopyAll,
});
}
tempbook.Worksheets.Clear();
}
处理流程:
- 创建临时工作簿
tempbook - 通过
LoadFromFile从 VFS 加载用户文件 - 遍历临时工作簿中的所有工作表
- 使用
AddCopy方法将每个工作表完整复制到目标工作簿 - 复制完成后清空临时工作簿,准备处理下一个文件
注意: AddCopy 方法需要将 Worksheets 集合转换为适当的类型,因此使用了 XlsWorksheetsCollection.Convert 进行类型转换。
3.3 导出合并结果
javascript
const outputFileName = 'Merged_Result.xlsx';
newbook.SaveToFile({ fileName: outputFileName, version: mod.ExcelVersion.Version2013 });
const bytes = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([bytes], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
保存合并后的工作簿到 VFS,然后通过 FS 模块读取生成的二进制数据,构造为 Blob 对象供下载使用。
4. 内存管理与资源释放
WASM 模块创建的对象(如 Workbook)占用的是 WASM 线性内存,需要显式释放以避免内存泄漏:
javascript
finally {
try { newbook?.Dispose(); } catch (_) {}
try { tempbook?.Dispose(); } catch (_) {}
setIsMerging(false);
}
Dispose 方法会释放 WASM 对象占用的非托管资源,这是良好的资源管理实践。
📝 完整代码 - React 合并多个 Excel 文件
以下是完整组件代码,代码中包含了完整的交互逻辑和错误处理:
javascript
import React, { useState, useEffect, useRef } from 'react';
function App() {
const [wasmModule, setWasmModule] = useState(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [isMerging, setIsMerging] = useState(false);
const fileInputRef = useRef(null);
const fontLoadedRef = useRef(false);
// 加载 WASM 模块
useEffect(() => {
let mounted = true;
(async () => {
try {
const publicUrl = process.env.PUBLIC_URL || '';
const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.xls.js`);
const rawModule = spireModule.default || spireModule;
const module = typeof rawModule === 'function'
? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
: rawModule;
if (mounted) {
window.wasmModule = module;
setWasmModule(module);
}
} catch (err) {
console.error('WASM 加载失败:', err);
}
})();
return () => { mounted = false; };
}, []);
// 合并上传的多个 Excel 文件
const mergeUploadedExcel = async () => {
const mod = window.wasmModule?.spirexls;
if (!mod || selectedFiles.length === 0) {
console.warn('模块未就绪或未选择文件');
return;
}
if (!window.dotnetRuntime?.Module?.FS) {
console.warn('WASM 运行时未就绪');
return;
}
setIsMerging(true);
let newbook = null;
let tempbook = null;
try {
// 加载字体(仅首次)
if (!fontLoadedRef.current) {
await window.spire.FetchFileToVFS(
'Arial.ttf',
'/Library/Fonts/',
`${process.env.PUBLIC_URL}/static/font/`
);
fontLoadedRef.current = true;
}
// 写入所有用户文件到 VFS
for (const file of selectedFiles) {
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
window.dotnetRuntime.Module.FS.writeFile(file.name, uint8);
}
// 创建目标工作簿,清空默认工作表
newbook = new mod.Workbook();
newbook.Version = mod.ExcelVersion.Version2016;
newbook.Worksheets.Clear();
// 临时工作簿,用于加载每个源文件
tempbook = new mod.Workbook();
// 遍历每个文件,复制所有工作表
for (const file of selectedFiles) {
tempbook.LoadFromFile(file.name);
const count = tempbook.Worksheets.Count;
for (let i = 0; i < count; i++) {
const sheet = tempbook.Worksheets.get(i);
mod.XlsWorksheetsCollection.Convert(newbook.Worksheets).AddCopy({
sheet: sheet,
flags: mod.WorksheetCopyType.CopyAll,
});
}
tempbook.Worksheets.Clear();
}
const outputFileName = 'Merged_Result.xlsx';
newbook.SaveToFile({ fileName: outputFileName, version: mod.ExcelVersion.Version2013 });
// 读取生成的文件并下载
const bytes = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([bytes], {
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 (err) {
console.error('合并失败:', err);
} finally {
try { newbook?.Dispose(); } catch (_) {}
try { tempbook?.Dispose(); } catch (_) {}
setIsMerging(false);
}
};
return (
<div style={{ textAlign: 'center', padding: '40px 20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>📂 合并本地 Excel 文件</h2>
<div style={{ margin: '20px 0' }}>
<input
ref={fileInputRef}
type="file"
multiple
accept=".xlsx,.xls"
onChange={(e) => {
const fileList = Array.from(e.target.files);
const excelFiles = fileList.filter(f =>
f.name.toLowerCase().endsWith('.xlsx') || f.name.toLowerCase().endsWith('.xls')
);
setSelectedFiles(excelFiles);
}}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
style={{ padding: '10px 24px', fontSize: '16px', cursor: 'pointer' }}
disabled={!wasmModule}
>
{wasmModule ? '选择文件' : '加载中...'}
</button>
<div style={{ marginTop: 12, fontSize: '14px', color: '#555' }}>
{selectedFiles.length > 0 ? (
<>
<span>已选择 {selectedFiles.length} 个文件:</span>
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0' }}>
{selectedFiles.map((f, idx) => (
<li key={idx} style={{ background: '#f0f0f0', margin: '4px 0', padding: '4px 8px', borderRadius: '4px' }}>
{f.name} ({(f.size / 1024).toFixed(1)} KB)
</li>
))}
</ul>
</>
) : (
'请点击上方按钮选择 Excel 文件(.xlsx / .xls)'
)}
</div>
</div>
<button
onClick={mergeUploadedExcel}
disabled={!wasmModule || selectedFiles.length === 0 || isMerging}
style={{
padding: '10px 32px',
fontSize: '16px',
cursor: (!wasmModule || selectedFiles.length === 0 || isMerging) ? 'not-allowed' : 'pointer',
backgroundColor: '#1976d2',
color: '#fff',
border: 'none',
borderRadius: '4px',
transition: 'background 0.2s',
}}
>
{isMerging ? '⏳ 合并处理中...' : '开始合并并下载'}
</button>
</div>
);
}
export default App;
项目保存后,通过 npm start 命令会启动 React 开发服务器,默认在 http://localhost:3000 打开应用。待模块加载完成后:
-
点击"选择文件"按钮,在弹出的系统文件选择框中,按住 Ctrl 或 Shift 键同时选中多个 .xlsx 或 .xls 文件。文件选择后,页面会列出每个文件的名称和大小,方便确认。
-
随后点击"开始合并并下载 "按钮,应用将按顺序处理所选文件。处理过程中按钮变为"⏳ 合并处理中..."并禁用,防止重复点击。合并完成后,浏览器会自动下载生成的 Merged_Result.xlsx 文件。

扩展思路
基于当前实现,可以进一步扩展以下功能:
- 选择合并方式:允许用户选择合并所有工作表到单个工作表,还是保持多个工作表
- 合并范围控制:允许用户选择合并特定工作表,而不是全部
- 进度条显示:在处理大文件时显示进度,提升用户体验
- 云端存储集成:将合并结果直接保存到云存储服务
- 批量格式转换:在合并的同时进行格式转换(如 XLS 转 XLSX)
总结
本文详细介绍了如何使用 WebAssembly 技术在浏览器端实现多个 Excel 文件的合并。通过结合 React 的状态管理和 WASM 的高效处理能力,我们构建了一个完全在客户端运行的 Excel 合并工具,具有以下优势:
- 数据隐私:文件不上传服务器,完全在本地处理
- 低延迟:无需网络请求,处理速度快
- 跨平台:基于 Web 标准,可在所有现代浏览器中运行
- 易于集成:作为 React 组件,可以轻松集成到现有项目中
WASM 技术为 Web 应用带来了接近原生的性能,使得复杂的文档处理任务可以在浏览器端完成。随着 WebAssembly 生态的不断完善,将有更多桌面级应用能力迁移到 Web 平台,为用户提供更好的使用体验。