ClosedXML Excel 讀寫指南
用途:在不安裝 Microsoft Office 的情況下,於 .NET 程式中讀寫 Excel (.xlsx) 檔案
NuGet:
ClosedXML(目前穩定版 0.105.0)授權:MIT(完全免費,包含商業用途)
支援:.NET 6+、.NET Framework 4.6.2+
為什麼選 ClosedXML
| 方案 | 授權 | 特點 |
|---|---|---|
| ClosedXML | MIT(免費) | API 直覺、功能完整、社群活躍 |
| EPPlus 5+ | 商用付費(Polyform) | 功能更豐富,但商用必須購買授權 |
| NPOI | Apache 2.0 | 支援 .xls,但 API 較低階 |
| OpenXML SDK | MIT | 微軟官方,但 API 極度低階 |
| Interop.Excel | 需安裝 Office | 效能差、伺服器不適用 |
ClosedXML 建立在 OpenXML SDK 之上,提供高階 API,是 EPPlus 改商用授權後最佳的免費替代方案。
安裝
dotnet add package ClosedXML
using ClosedXML.Excel;
核心 API 結構
XLWorkbook(活頁簿)
└── IXLWorksheet(工作表)
├── IXLCell(儲存格)
├── IXLRow(列)
├── IXLColumn(欄)
└── IXLRange(範圍)
基本操作
建立 Workbook 並寫入資料
using var workbook = new XLWorkbook();
var worksheet = workbook.AddWorksheet("設備參數");
// 寫入標題列
worksheet.Cell("A1").Value = "參數名稱";
worksheet.Cell("B1").Value = "設定值";
worksheet.Cell("C1").Value = "單位";
worksheet.Cell("D1").Value = "更新時間";
// 寫入資料
worksheet.Cell("A2").Value = "主軸轉速";
worksheet.Cell("B2").Value = 1500;
worksheet.Cell("C2").Value = "RPM";
worksheet.Cell("D2").Value = DateTime.Now;
workbook.SaveAs("設備參數報表.xlsx");
用座標寫入(Row, Column)
// Row 和 Column 從 1 開始
worksheet.Cell(1, 1).Value = "參數名稱"; // A1
worksheet.Cell(1, 2).Value = "設定值"; // B1
worksheet.Cell(2, 1).Value = "溫度上限"; // A2
worksheet.Cell(2, 2).Value = 85.5; // B2
整列寫入
// 從指定儲存格開始,依序向右填入
var row = worksheet.Row(2);
row.Cell(1).Value = "溫度上限";
row.Cell(2).Value = 85.5;
row.Cell(3).Value = "°C";
從集合匯出
從 List<T> 匯出
InsertTable 自動處理標頭和資料:
public record DeviceParameter(
string Name,
double Value,
string Unit,
DateTime UpdatedAt);
var parameters = new List<DeviceParameter>
{
new("主軸轉速", 1500, "RPM", DateTime.Now),
new("溫度上限", 85.5, "°C", DateTime.Now),
new("壓力設定", 2.4, "MPa", DateTime.Now),
};
using var workbook = new XLWorkbook();
var worksheet = workbook.AddWorksheet("參數表");
// InsertTable 自動產生表頭(使用屬性名稱)+ 資料
var table = worksheet.Cell("A1").InsertTable(parameters);
// 自動調整欄寬
worksheet.Columns().AdjustToContents();
workbook.SaveAs("參數匯出.xlsx");
從 DataTable 匯出
DataTable dt = GetProductionRecords(); // 從資料庫取得
using var workbook = new XLWorkbook();
var worksheet = workbook.AddWorksheet("生產記錄");
worksheet.Cell("A1").InsertTable(dt);
worksheet.Columns().AdjustToContents();
workbook.SaveAs("生產記錄.xlsx");
自訂表頭名稱
// 先寫自訂標頭
var headers = new[] { "參數名稱", "設定值", "單位", "更新時間" };
for (int i = 0; i < headers.Length; i++)
worksheet.Cell(1, i + 1).Value = headers[i];
// 從第 2 列開始寫入資料(不用 InsertTable 的自動標頭)
int row = 2;
foreach (var param in parameters)
{
worksheet.Cell(row, 1).Value = param.Name;
worksheet.Cell(row, 2).Value = param.Value;
worksheet.Cell(row, 3).Value = param.Unit;
worksheet.Cell(row, 4).Value = param.UpdatedAt;
row++;
}
格式設定
字體與顏色
var headerRange = worksheet.Range("A1:D1");
// 標題列樣式
headerRange.Style.Font.Bold = true;
headerRange.Style.Font.FontColor = XLColor.White;
headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#2E4057");
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
框線
var dataRange = worksheet.Range("A1:D10");
// 外框線
dataRange.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
// 內部格線
dataRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
dataRange.Style.Border.InsideBorderColor = XLColor.FromHtml("#CCCCCC");
數值格式
// 數值格式
worksheet.Cell("B2").Style.NumberFormat.Format = "#,##0.00";
// 日期格式
worksheet.Cell("D2").Style.DateFormat.Format = "yyyy-MM-dd HH:mm:ss";
// 百分比
worksheet.Cell("E2").Style.NumberFormat.Format = "0.0%";
合併儲存格
// 合併 A1:D1 作為報表標題
worksheet.Range("A1:D1").Merge();
worksheet.Cell("A1").Value = "設備參數日報表";
worksheet.Cell("A1").Style.Font.FontSize = 16;
worksheet.Cell("A1").Style.Font.Bold = true;
worksheet.Cell("A1").Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
條件格式
// 溫度超過 80 度標紅色
worksheet.Range("B2:B100")
.AddConditionalFormat()
.WhenGreaterThan(80)
.Fill.SetBackgroundColor(XLColor.Red)
.Font.SetFontColor(XLColor.White);
凍結窗格
// 凍結第一列(標題列),向下滾動時標題不動
worksheet.SheetView.FreezeRows(1);
// 凍結前兩欄 + 第一列
worksheet.SheetView.FreezeColumns(2);
worksheet.SheetView.FreezeRows(1);
公式
// 加總
worksheet.Cell("B11").FormulaA1 = "SUM(B2:B10)";
// 平均
worksheet.Cell("B12").FormulaA1 = "AVERAGE(B2:B10)";
// IF 條件
worksheet.Cell("E2").FormulaA1 = "IF(B2>80,\"異常\",\"正常\")";
警告
ClosedXML 沒有內建公式計算引擎。cell.Value 回傳的是上次在 Excel 中開啟時的快取結果。如果是在伺服器端新寫入的公式,值會是空的,直到使用者用 Excel 開啟檔案後才會計算。
圖片插入
var image = worksheet.AddPicture("公司Logo.png")
.MoveTo(worksheet.Cell("A1")) // 放在 A1 位置
.WithSize(200, 60); // 寬 200px、高 60px
讀取 Excel 檔案
using var workbook = new XLWorkbook("既有報表.xlsx");
var worksheet = workbook.Worksheet(1); // 第一個工作表(從 1 開始)
// 讀取特定儲存格
string name = worksheet.Cell("A2").GetString();
double value = worksheet.Cell("B2").GetDouble();
DateTime date = worksheet.Cell("D2").GetDateTime();
// 遍歷有資料的列
var rows = worksheet.RowsUsed().Skip(1); // 跳過標題列
foreach (var row in rows)
{
var paramName = row.Cell(1).GetString();
var paramValue = row.Cell(2).GetDouble();
Console.WriteLine($"{paramName}: {paramValue}");
}
讀取為強型別集合
public static List<DeviceParameter> ReadParameters(string filePath)
{
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheet("參數表");
return worksheet.RowsUsed()
.Skip(1) // 跳過標題
.Select(row => new DeviceParameter(
row.Cell(1).GetString(),
row.Cell(2).GetDouble(),
row.Cell(3).GetString(),
row.Cell(4).GetDateTime()))
.ToList();
}
大量資料效能注意事項
ClosedXML 將整個 Workbook 載入記憶體。對於大量資料(超過 10 萬列),需要注意:
減少記憶體消耗
// 1. 避免逐格設定樣式,用 Range 一次套用
var range = worksheet.Range(1, 1, 100000, 5);
range.Style.Font.FontName = "Microsoft JhengHei";
// 2. 寫完後再調整欄寬(避免每列都重新計算)
// 先寫入所有資料...
// 最後才呼叫
worksheet.Columns().AdjustToContents();
// 3. 不需要的功能不要開
workbook.CalculateMode = XLCalculateMode.Manual; // 關閉自動計算
超大檔案考慮替代方案
如果資料量超過 50 萬列,可考慮:
- 分割成多個工作表(每表 10 萬列)
- 使用 OpenXML SDK 的 SAX 寫入模式(低階但省記憶體)
- 匯出為 CSV 再由 Excel 開啟
公司場景範例
設備參數報表匯出
public static void ExportDeviceReport(
IEnumerable<DeviceParameter> parameters,
string outputPath)
{
using var workbook = new XLWorkbook();
var ws = workbook.AddWorksheet("設備參數報表");
// 報表標題
ws.Range("A1:E1").Merge();
ws.Cell("A1").Value = $"設備參數報表 - {DateTime.Now:yyyy/MM/dd HH:mm}";
ws.Cell("A1").Style.Font.FontSize = 14;
ws.Cell("A1").Style.Font.Bold = true;
ws.Cell("A1").Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
// 表頭
var headers = new[] { "參數名稱", "設定值", "單位", "上限", "下限" };
for (int i = 0; i < headers.Length; i++)
{
ws.Cell(3, i + 1).Value = headers[i];
}
var headerRange = ws.Range(3, 1, 3, headers.Length);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#2E4057");
headerRange.Style.Font.FontColor = XLColor.White;
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
// 資料
int row = 4;
foreach (var param in parameters)
{
ws.Cell(row, 1).Value = param.Name;
ws.Cell(row, 2).Value = param.Value;
ws.Cell(row, 3).Value = param.Unit;
row++;
}
// 格式化
var dataRange = ws.Range(3, 1, row - 1, headers.Length);
dataRange.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
dataRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
ws.Columns().AdjustToContents();
workbook.SaveAs(outputPath);
}
Recipe 批次匯入
public static List<RecipeStep> ImportRecipe(string filePath)
{
using var workbook = new XLWorkbook(filePath);
var ws = workbook.Worksheet("Recipe");
var steps = new List<RecipeStep>();
foreach (var row in ws.RowsUsed().Skip(1))
{
// 跳過空列
if (row.Cell(1).IsEmpty()) continue;
steps.Add(new RecipeStep
{
StepNo = row.Cell(1).GetValue<int>(),
Action = row.Cell(2).GetString(),
Parameter = row.Cell(3).GetString(),
Value = row.Cell(4).GetDouble(),
Unit = row.Cell(5).GetString(),
Duration = TimeSpan.FromSeconds(row.Cell(6).GetDouble()),
});
}
return steps;
}
生產記錄報表(含合併儲存格)
public static void ExportProductionReport(
Dictionary<string, List<ProductionRecord>> recordsByLine,
string outputPath)
{
using var workbook = new XLWorkbook();
var ws = workbook.AddWorksheet("生產記錄");
var headers = new[] { "產線", "時間", "產品", "產量", "良率" };
for (int i = 0; i < headers.Length; i++)
ws.Cell(1, i + 1).Value = headers[i];
int row = 2;
foreach (var (line, records) in recordsByLine)
{
int startRow = row;
foreach (var record in records)
{
ws.Cell(row, 2).Value = record.Timestamp;
ws.Cell(row, 2).Style.DateFormat.Format = "HH:mm:ss";
ws.Cell(row, 3).Value = record.ProductName;
ws.Cell(row, 4).Value = record.Quantity;
ws.Cell(row, 5).Value = record.YieldRate;
ws.Cell(row, 5).Style.NumberFormat.Format = "0.0%";
row++;
}
// 合併產線名稱儲存格
if (row > startRow)
{
ws.Range(startRow, 1, row - 1, 1).Merge();
ws.Cell(startRow, 1).Value = line;
ws.Cell(startRow, 1).Style.Alignment.Vertical =
XLAlignmentVerticalValues.Center;
}
}
ws.Columns().AdjustToContents();
workbook.SaveAs(outputPath);
}
QA 測試結果匯出
public static void ExportTestResults(
List<TestResult> results,
string outputPath)
{
using var workbook = new XLWorkbook();
var ws = workbook.AddWorksheet("測試結果");
// 標頭
ws.Cell("A1").InsertTable(results);
// 標記 FAIL 為紅色
foreach (var row in ws.RowsUsed().Skip(1))
{
var statusCell = row.Cell("Status"); // 假設 Status 是第 N 欄
if (statusCell.GetString() == "FAIL")
{
row.Style.Fill.BackgroundColor = XLColor.FromHtml("#FFE0E0");
statusCell.Style.Font.FontColor = XLColor.Red;
statusCell.Style.Font.Bold = true;
}
}
// 摘要
int dataRows = results.Count;
int summaryRow = dataRows + 3;
ws.Cell(summaryRow, 1).Value = "總計";
ws.Cell(summaryRow, 2).Value = results.Count;
ws.Cell(summaryRow + 1, 1).Value = "通過";
ws.Cell(summaryRow + 1, 2).Value = results.Count(r => r.Status == "PASS");
ws.Cell(summaryRow + 2, 1).Value = "失敗";
ws.Cell(summaryRow + 2, 2).Value = results.Count(r => r.Status == "FAIL");
ws.Columns().AdjustToContents();
workbook.SaveAs(outputPath);
}
常見問題
| 問題 | 解法 |
|---|---|
| 中文亂碼 | ClosedXML 預設 UTF-8,通常不會亂碼。確認讀取時使用 GetString() |
| 日期顯示為數字 | 設定 Style.DateFormat.Format |
| 欄寬太窄 | 呼叫 worksheet.Columns().AdjustToContents() |
| 記憶體不足 | 資料量過大,考慮分批或用 OpenXML SDK |
| SaveAs 失敗 | 確認檔案沒被 Excel 開啟鎖定 |
| 讀取 .xls 失敗 | ClosedXML 只支援 .xlsx(Excel 2007+) |