React 项目实战:用 JavaScript 合并多个本地 Excel 文件

在 Web 应用开发中,处理 Excel 文件的合并是一个常见需求。传统方案通常依赖后端服务来完成文件处理,但这种方式增加了服务器负担和网络传输成本。本文将介绍一种基于 WebAssembly 技术的纯前端解决方案,在浏览器端直接完成多个 Excel 文件的合并操作

本文的实现基于 React 框架,通过加载 WASM 模块来实现 Excel 文件的读写能力。整个方案不依赖任何后端服务,所有数据处理均在本地完成,既保障了数据隐私,又降低了服务器压力。


技术方案概述

核心思路

整个方案的核心思路是在浏览器内存中构建一个虚拟文件系统(VFS),将用户选择的多个 Excel 文件写入该虚拟文件系统,然后使用 WASM 模块提供的 Excel 处理能力依次读取每个文件,将其中所有工作表复制到一个目标工作簿中,最后将合并结果导出并下载。

技术选型

  • 前端框架:React(利用 Hooks 管理状态和副作用)
  • Excel 处理引擎 :基于 WebAssembly 的 Excel 库(Spire.XLS for JavaScript 或其免费版
  • 虚拟文件系统:WASM 运行时自带的 FS(文件系统)模块
  • 字体渲染支持:通过 VFS 加载 TrueType 字体文件,确保中文等字符正确渲染

💡 点击查看相关 JavaScript 库完整安装教程


核心实现详解

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 的工具函数,其内部实现大致为:

  1. 从指定 URL 获取字体文件的二进制数据
  2. 通过 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();
}

处理流程:

  1. 创建临时工作簿 tempbook
  2. 通过 LoadFromFile 从 VFS 加载用户文件
  3. 遍历临时工作簿中的所有工作表
  4. 使用 AddCopy 方法将每个工作表完整复制到目标工作簿
  5. 复制完成后清空临时工作簿,准备处理下一个文件

注意: 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 文件。


扩展思路

基于当前实现,可以进一步扩展以下功能:

  1. 选择合并方式:允许用户选择合并所有工作表到单个工作表,还是保持多个工作表
  2. 合并范围控制:允许用户选择合并特定工作表,而不是全部
  3. 进度条显示:在处理大文件时显示进度,提升用户体验
  4. 云端存储集成:将合并结果直接保存到云存储服务
  5. 批量格式转换:在合并的同时进行格式转换(如 XLS 转 XLSX)

总结

本文详细介绍了如何使用 WebAssembly 技术在浏览器端实现多个 Excel 文件的合并。通过结合 React 的状态管理和 WASM 的高效处理能力,我们构建了一个完全在客户端运行的 Excel 合并工具,具有以下优势:

  • 数据隐私:文件不上传服务器,完全在本地处理
  • 低延迟:无需网络请求,处理速度快
  • 跨平台:基于 Web 标准,可在所有现代浏览器中运行
  • 易于集成:作为 React 组件,可以轻松集成到现有项目中

WASM 技术为 Web 应用带来了接近原生的性能,使得复杂的文档处理任务可以在浏览器端完成。随着 WebAssembly 生态的不断完善,将有更多桌面级应用能力迁移到 Web 平台,为用户提供更好的使用体验。

相关推荐
水上冰石28 分钟前
【MuJoCo从入门到精通】第2章 第一个 MuJoCo 仿真
前端·人工智能·算法
深蓝AI1 小时前
TypeScript 7.0 原生编译器实战:8—12 倍提速,迁移前的 7 个检查
javascript
码事漫谈1 小时前
比尔·盖茨这次谈的不是模型,是账单
前端·后端
mayaairi2 小时前
JS BOM完全指南(二):事件轮询、三大尺寸家族与浏览器对象详解
开发语言·javascript·ecmascript
星栈2 小时前
AI 生成页面全是紫粉渐变?我用 ui-ux-pro-max-skill 重构了整个产品页
前端·weui
avi91112 小时前
【】js不同颜色(Vue 框架)今时今日2026年学编程入门(10月1日)
前端·vue.js·vue·vue框架·前端入门·vue入门·html上传
Fluxart.ai3 小时前
商品多角度图怎么做?Flux Art 从白底图到规格图、包装图的 10 步教程
开发语言·前端·javascript
前端繁华如梦3 小时前
React + Three.js 造了一个"乙烯基娃娃"3D 角色编辑器:配方驱动、程序化生成、还能跳舞
前端