
简介这是一个面向C#开发者的Excel读写辅助工具包基于NPOI组件实现无需安装Office即可将Excel文件导入DataSet或DataTable也能将内存数据表写出为Excel文件适合需要批量导入导出报表、初始化测试数据等场景。资源共包含8个文件其中6个DLL组件分别负责解析xls/xlsx格式、处理OpenXml格式与底层数据访问1个CS代码文件封装常用读写方法并带详尽注释方便二次修改与功能扩展另有1个XML配置文件辅助程序集加载压缩包整体仅1.27MB轻量易集成。目前已有216人学习下载适合需要在WinForm、ASP.NET等项目中快速处理Excel数据、又不想依赖Office环境的开发者参考使用。需要留意的是作者注明导出性能一般若需一次性导出上万行数据建议先做分批或改用其他方案日常中小数据量场景足够实用。1. 当 C# 项目需要一个 ExcelHelperDataTable 与 Excel 的日常边界C# 里做 Excel 操作第一反应常常是去搜“ExcelHelper”然后拿一个 DataTable 直接导出。真正上手后才发现问题不在导出本身而在 DataTable 和 Excel 的边界DataTable 只有列、行、类型三件事Excel 却有工作表、单元格样式、合并单元格、数据格式、冻结窗格这些额外状态。很多“能用”的导出代码是把 DataTable 的行列硬套到 Excel 上遇到日期变成字符串、长数字变成科学计数法、Excel 打开后提示文件损坏才回头补 NPOI 的细节。本文会从 DataTable 与 Excel 的映射关系讲起给出一套可以直接复制进工具库的 ExcelHelper 实现覆盖导出、读取、批量导入以及扫码枪和剪切板这类高频输入场景。适合正在维护 C# 工具库、上位机软件或数据导入导出功能的人。2. ExcelHelper 的底层原理DataTable 与 Excel 表格的映射关系2.1 列、行、单元格类型DataTable 与 Excel 的结构对应DataTable 靠 DataColumn 定义列DataRow 存值列的顺序由 Columns 集合决定。Excel 的 .xlsx 文件则是一张二维表行索引从 0 开始列索引也是从 0 开始第一行可以当作表头。用 NPOI 读写时IRow代表一行ICell代表一个单元格单元格的值类型由CellType区分。把 DataTable 映射到 Excel关键在三点。第一DataTable 的列名写入 Excel 的哪一个单元格通常写在第 0 行第 i 列。第二DataColumn.DataType 要和 Excel 的CellType做转换字符串用SetCellValue(string)数字用SetCellValue(double)日期用SetCellValue(DateTime)。第三空值不能直接调用SetCellValue(null)否则 NPOI 会抛异常应该跳过或者写成空字符串。反向映射 Excel 到 DataTable 时Excel 列本身没有强类型所有单元格读出来都是ICell对象。要得到 DataTable 的列类型常见做法是全部读成string必要时再按 DataColumn.DataType 做Convert.ChangeType。我一般会在 ExcelHelper 里加参数控制hasHeader true时把 Excel 第一行作为列名false时自动生成 Column1、Column2。2.2 选择库NPOI、EPPlus、Interop 的取舍写 ExcelHelper 前先定库。Microsoft.Office.Interop.Excel 需要安装 Office在服务器上跑容易出权限问题我几乎不用。EPPlus 功能丰富但 5.0 之后商用要授权。NPOI 是 Apache 2.0 协议免费支持 .xls 和 .xlsx社区活跃所以下面的实现以 NPOI 为准。库协议是否需要 Office.xlsx 支持内存占用适用场景NPOIApache 2.0否是一般服务端、工具库EPPlusPolyform Noncommercial否是较高个人/非商用项目Interop商业是是极高仅在客户端临时用ClosedXMLMIT否是一般轻量导出NPOI 用起来偏底层但好处是 API 稳定。它把 Excel 抽象成IWorkbook、ISheet、IRow、ICell不区分 .xls 和 .xlsx 的接口。新建 .xlsx 用XSSFWorkbook新建 .xls 用HSSFWorkbook。读取时根据扩展名选择对应的IWorkbook实现。2.3 提前设计好的 ExcelHelper 方法签名我会把 ExcelHelper 设计成静态类方法只做一件事不掺业务逻辑。核心是这两个方法DataTableToExcel负责导出ExcelToDataTable负责读取。再加一个扩展方法处理剪切板文本后面章节会用到。using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using NPOI.HSSF.UserModel; using System.Data; using System.IO; public static class ExcelHelper { /// summary /// 将 DataTable 导出为 .xlsx 文件 /// /summary public static void DataTableToExcel(DataTable dt, string filePath, string sheetName Sheet1, bool autoFit true, bool freezeHeader true) { IWorkbook workbook new XSSFWorkbook(); ISheet sheet workbook.CreateSheet(sheetName); IRow headerRow sheet.CreateRow(0); for (int i 0; i dt.Columns.Count; i) { headerRow.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName); } for (int row 0; row dt.Rows.Count; row) { IRow dataRow sheet.CreateRow(row 1); for (int col 0; col dt.Columns.Count; col) { object value dt.Rows[row][col]; if (value null || value DBNull.Value) continue; ICell cell dataRow.CreateCell(col); if (value is string s) cell.SetCellValue(s); else if (value is int || value is long || value is float || value is double || value is decimal) cell.SetCellValue(Convert.ToDouble(value)); else if (value is DateTime dtValue) cell.SetCellValue(dtValue); else cell.SetCellValue(value.ToString()); } } if (autoFit) { for (int i 0; i dt.Columns.Count; i) { sheet.AutoSizeColumn(i); } } if (freezeHeader) { sheet.CreateFreezePane(0, 1); } using (FileStream fs new FileStream(filePath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } workbook.Close(); } }autoFit参数控制是否调用AutoSizeColumn。需要注意AutoSizeColumn会遍历所有行数据量大时耗时明显所以把它做成开关。freezeHeader用CreateFreezePane(0, 1)固定第一行方便用户往下滚动时仍看到列名。这两个参数在导出 10 万行数据时能感受到明显差异。3. 用 NPOI 实现 DataTable 导出 Excel代码与参数3.1 导出 DataTable 到 .xlsx 的核心代码上一章的代码已经可以导出但实际项目里通常要处理更多类型。比如 DataSet 多表导出、多个 DataTable 写到一个 Excel 文件的不同 Sheet。常见的扩展方式是给DataTableToExcel增加一个DataSetToExcel重载public static void DataSetToExcel(DataSet ds, string filePath) { IWorkbook workbook new XSSFWorkbook(); for (int i 0; i ds.Tables.Count; i) { DataTable dt ds.Tables[i]; ISheet sheet workbook.CreateSheet(dt.TableName ?? Sheet (i 1)); IRow headerRow sheet.CreateRow(0); for (int col 0; col dt.Columns.Count; col) { headerRow.CreateCell(col).SetCellValue(dt.Columns[col].ColumnName); } for (int row 0; row dt.Rows.Count; row) { IRow dataRow sheet.CreateRow(row 1); for (int col 0; col dt.Columns.Count; col) { object value dt.Rows[row][col]; if (value null || value DBNull.Value) continue; dataRow.CreateCell(col).SetCellValue(value.ToString()); } } sheet.AutoSizeColumn(0); } using (FileStream fs new FileStream(filePath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } workbook.Close(); }这段代码把DataSet.Tables里的每个DataTable变成一个 Sheet。需要注意Sheet 名不能重复也不能包含/\*?等非法字符所以TableName为空时直接生成 Sheet1、Sheet2。这里我把单元格统一写成value.ToString()避免类型转换复杂化。如果你需要 Excel 里的单元格保持数字类型就要像之前那样按类型判断。3.2 必调参数自动列宽、冻结窗格、单元格格式导出 Excel 时参数设置直接影响第一批用户的使用体验。列宽过窄会让内容显示成###日期格式不对会让 Excel 里显示一串数字。下面这几个参数值得每次导出都检查参数方法说明建议值自动列宽sheet.AutoSizeColumn(i)按内容长度调整列宽数据量小于 1 万行可用冻结首行sheet.CreateFreezePane(0, 1)滚动时固定表头导出超过 100 行建议开启日期格式cell.CellStyle.DataFormat workbook.CreateDataFormat().GetFormat(yyyy-mm-dd)让日期显示为文本格式有 DateTime 列时必须设置单元格背景cellStyle.FillForegroundColor IndexedColors.Grey25Percent.Index区分表头和正文常用日期格式是导出时最容易被忽略的。如果直接SetCellValue(DateTime)Excel 显示的是日期序列号比如 45292。正确做法是给日期单元格单独设置CellStyleICellStyle dateStyle workbook.CreateCellStyle(); dateStyle.DataFormat workbook.CreateDataFormat().GetFormat(yyyy-mm-dd HH:mm:ss); // 在循环里对有 DateTime 的列应用 dateStyle这样生成的 Excel 打开后就是可读的日期文本而不是一串数字。3.3 导出时的 3 个常见坑与规避第一个坑是长数字串变成科学计数法。DataTable里存的是string类型Excel 却把它识别成数字。规避方法是把人名列值全部按文本方式写入调用cell.SetCellValue(value.ToString())之前先把单元格类型设为字符串cell.SetCellType(CellType.String)。这样身份证号、订单号这类长字符串就不会变形。第二个坑是AutoSizeColumn在数据量大时卡顿。10 万行、20 列的表调用AutoSizeColumn耗时能到几十秒。解决办法是只对前几列自动列宽或者先设置固定列宽再手动调整超宽的列。sheet.SetColumnWidth(0, 20 * 256); // 第1列宽度为20个字符第三个坑是写入到一半时文件被占用。FileStream使用FileMode.Create如果目标文件已经被 Excel 打开new FileStream会抛IOException。我在工具库里通常先检测文件是否被锁定或直接允许用户选择覆盖并在 catch 里给出明确提示。4. 反向读取Excel 转回 DataTable 与批量导入实践4.1 读取 Excel 到 DataTable 的实现读取比导出更容易出问题因为 Excel 单元格可能是空指针也可能同一列里混着数字和文本。下面这个方法把 Excel 的第一个工作表或指定名称的工作表读成 DataTablepublic static DataTable ExcelToDataTable(string filePath, string sheetName null, bool hasHeader true, int startRow 0) { IWorkbook workbook; using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read)) { if (filePath.EndsWith(.xlsx)) workbook new XSSFWorkbook(fs); else if (filePath.EndsWith(.xls)) workbook new HSSFWorkbook(fs); else throw new NotSupportedException(仅支持 .xlsx/.xls); ISheet sheet sheetName null ? workbook.GetSheetAt(0) : workbook.GetSheet(sheetName); if (sheet null) throw new Exception(指定的工作表不存在); DataTable dt new DataTable(); int headerRowIndex startRow; int dataStartIndex hasHeader ? headerRowIndex 1 : headerRowIndex; IRow headerRow sheet.GetRow(headerRowIndex); int colCount headerRow.LastCellNum; for (int i 0; i colCount; i) { string colName hasHeader ? headerRow.GetCell(i)?.ToString() : $Column{i 1}; dt.Columns.Add(string.IsNullOrEmpty(colName) ? $Column{i 1} : colName); } for (int rowIdx dataStartIndex; rowIdx sheet.LastRowNum; rowIdx) { IRow row sheet.GetRow(rowIdx); if (row null) continue; DataRow dr dt.NewRow(); for (int colIdx 0; colIdx colCount; colIdx) { ICell cell row.GetCell(colIdx); dr[colIdx] cell null ? : cell.ToString(); } dt.Rows.Add(dr); } workbook.Close(); return dt; } }这个方法把 Excel 单元格全部转成字符串。如果希望日期列保持DateTime类型可以在colName循环里按列名判断例如列名包含“时间”就设置dt.Columns[i].DataType typeof(DateTime)然后在读取循环里用DateUtil.IsCellDateFormatted(cell)判断再转换。4.2 大文件读取与 UI 刷新卡顿的配合问题WinForm 或 WPF 里读 5 万行 Excel 时如果直接在 UI 线程执行ExcelToDataTable界面会卡住。解决思路是把读取放到后台线程用Task.Run或async/await完成后再切回 UI 线程绑定 DataGridView。private async void btnLoad_Click(object sender, EventArgs e) { OpenFileDialog ofd new OpenFileDialog(); if (ofd.ShowDialog() ! DialogResult.OK) return; btnLoad.Enabled false; try { DataTable result await Task.Run(() ExcelHelper.ExcelToDataTable(ofd.FileName)); dataGridView1.DataSource result; } catch (Exception ex) { MessageBox.Show(读取失败 ex.Message); } finally { btnLoad.Enabled true; } }这里的关键是await Task.Run把读取操作放到了线程池UI 线程可以继续响应用户操作。DataGridView 绑定数据源时已经在 UI 上下文所以直接赋值是安全的。如果读取过程中要刷新进度条我一般会在ExcelToDataTable内部加一个IProgressint参数每处理 1000 行报告一次。4.3 Excel 导入数据库的桥接方式Excel 拿到 DataTable 之后导入数据库就有固定的套路。SQL Server 可以用SqlBulkCopyMySql 可以用MySqlBulkCopy。这里以SqlBulkCopy为例public static void DataTableToSqlServer(DataTable dt, string connectionString, string tableName) { using (SqlConnection conn new SqlConnection(connectionString)) { conn.Open(); using (SqlBulkCopy bulk new SqlBulkCopy(conn)) { bulk.DestinationTableName tableName; bulk.BatchSize 5000; bulk.WriteToServer(dt); } } }ExcelToDataTable返回的 DataTable 列名可能和数据库表列名不完全一致。可以在SqlBulkCopy里显式映射列bulk.ColumnMappings.Add(客户名称, CustomerName);这样避免了“列名不一致导致导入失败”的问题。导入前我还习惯先调用dt.Columns.Remove去掉 Excel 里的辅助列。5. 进阶用 ExcelHelper 处理扫码枪、剪切板和上位机数据采集5.1 扫码枪触发事件后写到 DataTable 再导出 Excel扫码枪通常被系统识别为键盘设备扫完一个条码后会在焦点控件里输入一串字符并附加一个回车。在 WinForm 里监听KeyDown事件判断回车间隔就能区分扫描输入和手动输入。private StringBuilder scanBuffer new StringBuilder(); private DataTable dtScan new DataTable(); private void textBox1_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Enter) { string barcode scanBuffer.ToString(); if (barcode.Length 0) { dtScan.Rows.Add(barcode, DateTime.Now); scanBuffer.Clear(); dataGridView1.DataSource null; dataGridView1.DataSource dtScan; } e.SuppressKeyPress true; } else if (e.KeyCode Keys.A e.KeyCode Keys.Z || e.KeyCode Keys.D0 e.KeyCode Keys.D9) { scanBuffer.Append((char)e.KeyValue); } }每次扫码后把数据写入内存里的 DataTable界面刷新会卡顿所以我会在数据量超过 1000 行时只追加不刷新等扫码结束后统一调用ExcelHelper.DataTableToExcel(dtScan, scan.xlsx)导出。这样扫码枪连续触发时UI 不会一卡一卡。5.2 把剪切板中的表格直接转成 DataTable从 Excel 或网页里选中单元格按 CtrlC剪切板里存的是制表符分隔的文本。我常写一个扩展方法处理public static DataTable ClipboardToDataTable(string clipboardText) { DataTable dt new DataTable(); string[] lines clipboardText.Split(new[] { \r\n, \n }, StringSplitOptions.RemoveEmptyEntries); if (lines.Length 0) return dt; string[] header lines[0].Split(\t); foreach (string col in header) dt.Columns.Add(col); for (int i 1; i lines.Length; i) { string[] cells lines[i].Split(\t); dt.Rows.Add(cells); } return dt; }调用时用Clipboard.GetText()拿剪切板内容然后交给这个方法。它和ExcelToDataTable的区别是不经过文件系统适合用户临时从外部表格粘贴数据到 C# 上位机界面。5.3 用 ExcelHelper 减少 Excel 操作的内存占用最后说一个我在维护工具库时踩过的性能问题NPOI 的XSSFWorkbook会把整个工作簿加载到内存读 100MB 的 Excel 可能吃掉 1GB 内存。解决办法是读取时只保留需要的工作表或者用流式读取XSSFReader。导出时则分批次写入每 5 万行刷新一次IRow并调用GC.Collect()。if (row % 50000 0) { workbook.Write(new FileStream(filePath, FileMode.Create, FileAccess.Write)); workbook.Close(); workbook new XSSFWorkbook(); sheet workbook.CreateSheet(sheetName); }这个分批次导出的技巧在服务端内存受限时很有效。把 ExcelHelper 改成支持分批写入后就能在不改调用方的前提下稳定处理百万行级别的数据导出。本文还有配套的精品资源点击获取