本文讲解如何在 Blazor Server 项目中优雅地实现 Excel 导出和导入功能。技术栈:Blazor Server + Radzen UI + MiniExcel + Entity Framework Core。

一、为什么选择 MiniExcel?

在 .NET 生态中,处理 Excel 的主流库有:

特点 适用场景
EPPlus 功能强大,支持图表/公式 复杂报表,需商业授权(v5+)
NPOI 老牌库,兼容 .xls/.xlsx 需要兼容旧格式
ClosedXML API 友好,基于 OpenXML 中等复杂度报表
MiniExcel 轻量、高性能、零依赖 大数据量导出/导入

选择 MiniExcel 的理由:

  1. 零外部依赖 — 不需要安装 Office,不需要 COM 组件
  2. 极低内存占用 — 基于流式读写,100 万行数据仅需几十 MB 内存
  3. API 极简 — 导出只需一行 SaveAs,导入只需一行 Query<T>
  4. MIT 开源协议 — 商业项目无顾虑
<PackageReference Include="MiniExcel" Version="1.42.0" />

二、项目架构

采用经典的三层架构:

BlazorApp.Server          ← Blazor Server 前端(Razor 页面 + Radzen 组件)
    ├── Pages/
    │   └── DataList.razor          ← 数据列表页面(含导出/导入按钮)
    └── wwwroot/
        └── _Layout.cshtml          ← 包含 saveAsFile JS 函数

BlazorApp.Application     ← 业务逻辑层(Service)
    └── Services/
        └── DataService.cs          ← 数据导出/导入逻辑

BlazorApp.Core            ← 实体模型 + 接口定义
BlazorApp.Infrastructure  ← EF Core 数据访问层

核心设计原则:Service 层返回 byte[],Blazor 页面负责触发浏览器下载。

三、Excel 导出实现

3.1 Service 层:构建数据并生成 Excel

public async Task<byte[]> ExportDataAsync(
    string? keyword = null,
    int? categoryId = null,
    string? status = null,
    DateTime? startDate = null,
    DateTime? endDate = null)
{
    // 1. 根据筛选条件查询数据
    var items = await GetDataAsync(keyword, categoryId, status, startDate, endDate);

    // 2. 投影为匿名类型,属性名即为 Excel 列名
    var exportData = items.Select(x => new
    {
        编号 = x.Code ?? "",
        名称 = x.Name,
        简称 = x.ShortName ?? "",
        联系人 = x.Contact ?? "",
        电话 = x.Phone ?? "",
        类型 = x.Type switch { 1 => "类型A", 2 => "类型B", 3 => "类型C", _ => "" },
        等级 = x.Level switch { 1 => "A", 2 => "B", 3 => "C", 4 => "D", _ => "" },
        状态 = x.IsLocked == true ? "锁定" : "正常",
        金额 = x.Amount,
        创建时间 = x.CreatedTime.ToString("yyyy-MM-dd HH:mm")
    }).ToList();

    // 3. 使用 MiniExcel 生成 Excel 字节数组
    using var memoryStream = new MemoryStream();
    MiniExcel.SaveAs(memoryStream, exportData);
    return memoryStream.ToArray();
}

关键点:

  • 匿名类型属性名 = Excel 列名:MiniExcel 会自动将匿名类型的属性名作为表头,支持中文属性名,无需额外配置
  • switch 表达式做枚举映射:将数字类型(如 Type=1)转换为可读文本(如 "类型A"),避免导出后用户看不懂
  • 空值保护:所有可空字段使用 ?? "" 兜底,避免 Excel 中出现空白单元格

3.2 Blazor 页面:触发下载

@page "/DataList"
@inject IDataService DataService
@inject IJSRuntime JSRuntime
@inject NotificationService NotificationService

<DataGridToolbar>
    <RadzenButton Text="导出" Icon="download" 
                  ButtonStyle="ButtonStyle.Info" 
                  Click="@ExportData" />
</DataGridToolbar>

@code {
    private string? searchKeyword;
    private int? searchCategoryId;
    private string? searchStatus;

    private async Task ExportData()
    {
        try
        {
            // 调用 Service 获取 Excel 字节数组
            var bytes = await DataService.ExportDataAsync(
                searchKeyword, searchCategoryId, searchStatus);

            // 生成带时间戳的文件名
            var fileName = $"数据列表_{DateTime.Now:yyyyMMddHHmmss}.xlsx";

            // 通过 JS 触发浏览器下载
            await JSRuntime.InvokeVoidAsync("saveAsFile", 
                fileName, Convert.ToBase64String(bytes));

            NotificationService.Notify(NotificationSeverity.Success, "成功", "导出完成");
        }
        catch (Exception ex)
        {
            NotificationService.Notify(NotificationSeverity.Error, "导出失败", ex.Message);
        }
    }
}

3.3 JS 辅助函数:Base64 → 文件下载

_Layout.cshtml 中定义全局函数:

<script>
    window.saveAsFile = function (fileName, base64Content) {
        const link = document.createElement('a');
        link.download = fileName;
        link.href = 'data:application/octet-stream;base64,' + base64Content;
        link.click();
    };
</script>

为什么用 Base64 传输?

Blazor Server 通过 SignalR 连接通信,二进制数据需要编码传输。Base64 是最简单可靠的方式。对于大文件(>10MB),可以考虑改用流式下载或临时文件 + URL 的方式。

四、Excel 导入实现

导入比导出稍复杂,需要定义 DTO 来映射 Excel 列。

4.1 定义导入 DTO

internal class DataImportDto
{
    public string 编号 { get; set; } = "";
    public string 名称 { get; set; } = "";
    public string 简称 { get; set; } = "";
    public string 联系人 { get; set; } = "";
    public string 电话 { get; set; } = "";
    public string 类型 { get; set; } = "";
    public string 等级 { get; set; } = "";
    public decimal 金额 { get; set; }
    public string 状态 { get; set; } = "";
    // ... 与导出列名一一对应
}

关键:DTO 属性名必须与 Excel 表头完全一致(包括中文)。

4.2 Service 层:解析并入库

public async Task<(int success, int failed, List<string> errors)> 
    ImportDataAsync(Stream stream)
{
    using var memoryStream = new MemoryStream();
    await stream.CopyToAsync(memoryStream);
    memoryStream.Position = 0;

    // MiniExcel 自动将 Excel 列映射到 DTO 属性
    var rows = MiniExcel.Query<DataImportDto>(memoryStream);

    var success = 0;
    var failed = 0;
    var errors = new List<string>();

    await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
    var rowIndex = 0;

    foreach (var row in rows)
    {
        rowIndex++;
        try
        {
            // 数据校验
            if (string.IsNullOrWhiteSpace(row.名称))
            {
                errors.Add($"第{rowIndex + 1}行:名称不能为空");
                failed++;
                continue;
            }

            // 检查是否已存在(按编码去重)
            var existing = await dbContext.Items
                .FirstOrDefaultAsync(x => x.Code == row.编号);

            if (existing != null)
            {
                // 更新已有记录
                existing.Name = row.名称;
                existing.ShortName = row.简称;
                existing.Contact = row.联系人;
            }
            else
            {
                // 插入新记录
                dbContext.Items.Add(new Item
                {
                    Code = row.编号,
                    Name = row.名称,
                    ShortName = row.简称,
                    Contact = row.联系人
                });
            }

            success++;
        }
        catch (Exception ex)
        {
            errors.Add($"第{rowIndex + 1}行:{ex.Message}");
            failed++;
        }
    }

    await dbContext.SaveChangesAsync();
    return (success, failed, errors);
}

4.3 Blazor 页面:文件上传

<!-- 隐藏的文件输入框 -->
<InputFile id="importFileInput" 
           OnChange="@HandleImportFile" 
           accept=".xlsx,.xls" 
           style="display:none" />

<RadzenButton Text="导入" Icon="upload" Click="@TriggerImport" />

@code {
    private async Task TriggerImport()
    {
        // 通过 JS 触发隐藏的文件输入框
        await JSRuntime.InvokeVoidAsync("triggerFileInput", "importFileInput");
    }

    private async Task HandleImportFile(InputFileChangeEventArgs e)
    {
        var file = e.File;
        if (file == null) return;

        try
        {
            // 限制文件大小 5MB
            using var stream = file.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
            var (success, failed, errors) = await DataService.ImportDataAsync(stream);

            if (errors.Count > 0)
            {
                var errorMsg = string.Join("\n", errors.Take(5));
                NotificationService.Notify(NotificationSeverity.Warning, 
                    $"导入完成:成功{success}条,失败{failed}条", errorMsg);
            }
            else
            {
                NotificationService.Notify(NotificationSeverity.Success, 
                    "成功", $"导入完成,共{success}条记录");
            }

            // 刷新列表
            await LoadData();
        }
        catch (Exception ex)
        {
            NotificationService.Notify(NotificationSeverity.Error, "导入失败", ex.Message);
        }
    }
}

五、进阶技巧

5.1 大数据量导出:避免内存溢出

当数据量超过 10 万行时,ToList() 会将所有数据加载到内存。可以改用流式写入

public async Task<byte[]> ExportLargeDataAsync()
{
    using var memoryStream = new MemoryStream();
    
    // 使用 IEnumerable 延迟执行,MiniExcel 会逐行写入
    var data = GetLargeDataEnumerable(); // 返回 IEnumerable<T>,不要 ToList()
    
    MiniExcel.SaveAs(memoryStream, data);
    return memoryStream.ToArray();
}

5.2 自定义表头样式

MiniExcel 支持通过 ExcelAttribute 自定义列名和样式:

public class ExportDto
{
    [ExcelColumnName("编号")]
    public string Code { get; set; }

    [ExcelColumnName("日期")]
    [ExcelFormat("yyyy-MM-dd")]
    public DateTime Date { get; set; }

    [ExcelColumnName("金额")]
    [ExcelFormat("#,##0.00")]
    public decimal Amount { get; set; }
}

5.3 多 Sheet 导出

var data = new Dictionary<string, object>
{
    ["列表A"] = listA,
    ["列表B"] = listB,
    ["列表C"] = listC
};

MiniExcel.SaveAs(memoryStream, data);

5.4 导出时保持与表格一致的列顺序

在 Blazor 页面中,RadzenDataGrid 的列顺序由 Razor 标记决定。导出时匿名类型的属性顺序就是 Excel 列顺序,确保两者一致即可。

六、常见问题与解决方案

Q1: 导出的 Excel 打开时报"文件格式损坏"

原因:文件扩展名与内容不匹配,或传输过程中 Base64 编码被截断。

解决:确保文件名以 .xlsx 结尾,检查 SignalR 消息大小限制(默认 32KB,需调大)。

// Program.cs 中配置
builder.Services.AddServerSideBlazor()
    .AddHubOptions(options =>
    {
        options.MaximumReceiveMessageSize = 1024 * 1024 * 32; // 32MB
    });

Q2: 中文列名乱码

MiniExcel 默认使用 UTF-8 编码,一般不会出现乱码。如果仍有问题,确保系统区域设置正确。

Q3: 导入时日期格式解析失败

解决:在 DTO 中使用 string 类型接收,再手动解析:

public class ImportDto
{
    public string 日期 { get; set; } = "";
    
    public DateTime? ParsedDate => 
        DateTime.TryParse(日期, out var d) ? d : null;
}

Q4: 大文件导入超时

Blazor Server 的 SignalR 连接有超时限制。对于大文件导入,建议:

  • 前端显示进度条
  • 后端使用后台任务处理
  • 分批保存(每 100 行 SaveChanges 一次)

Q5: 导出文件过大导致 SignalR 断开

当 Excel 文件超过 SignalR 默认消息大小限制时,连接会断开。解决方案:

  1. 调大 SignalR 消息限制(见 Q1)
  2. 改用 HTTP 端点下载:Service 将文件写入临时目录,返回文件路径,前端通过 window.open(url) 下载
  3. 分页导出:让用户选择导出范围,避免一次性导出全部数据

七、总结

功能 核心代码 行数
导出 MiniExcel.SaveAs(stream, data) 1 行
导入 MiniExcel.Query<T>(stream) 1 行
浏览器下载 JSRuntime.InvokeVoidAsync("saveAsFile", ...) 1 行
文件上传 <InputFile OnChange="@HandleImportFile" /> 1 行

整个 Excel 导出/导入功能,核心代码不超过 50 行,这就是 MiniExcel 的魅力。

完整流程:

用户点击"导出" → Blazor 调用 Service → Service 查询数据库 
→ 投影为匿名类型 → MiniExcel.SaveAs 生成 byte[] 
→ Base64 编码 → JS 创建 <a> 标签触发下载
用户点击"导入" → JS 触发 <InputFile> → 用户选择文件 
→ Blazor 获取 Stream → Service 用 MiniExcel.Query<T> 解析 
→ 逐行校验/入库 → 返回成功/失败统计