用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个
一个 Excel 模板里塞了透视表、图片、合并单元格、跨表公式——我只需要往数据页写几行数,为什么这么难?
先说场景
做企业报表的同学大概都遇到过这种模板:
- 展示页:透视表、图表、嵌套合并单元格、图片、跨表公式,花里胡哨
- 数据页:干干净净一个表格,被展示页的公式引用
需求很简单:Node.js 后端往数据页里写数据,展示页自动算出结果。
就这么个事。
试了一圈,都不行
exceljs
生态里最流行的 Excel 库,用的人最多。
问题在于它的工作方式是解析 → 内存对象 → 重建。也就是说,读进来的是它能理解的部分,读不进去的就丢了。
如果你的模板里有透视表、复杂图表、某些特定格式的图片——写出来再打开,大概率面目全非。
这不是 exceljs 的锅,它的设计目标本来就不是"保真"。
xlsx-populate
这个库比 exceljs 好一点,设计上就考虑了模板场景。但问题是:
- 透视表?不支持
- 复杂图表?不支持
- 某些条件格式写完就丢
而且这个库更新频率不太稳定,有些 issue 挂很久。
SheetJS (xlsx)
性能好,能解析的东西多。但它本质上是个数据读取库,写入能力偏弱,尤其是样式和复杂对象的处理。
共同的问题
这些库都在做同一件事:把 xlsx 解析成内存对象,修改,再重新打包。
问题就在"重新打包"这一步。xlsx 内部有几十个 XML 文件,互相之间有引用关系。解析的时候丢信息,打包的时候自然就出问题。
换个思路:别重建,做手术
先搞清楚 xlsx 到底是什么。把 .xlsx 后缀改成 .zip,解压:
xl/
├── workbook.xml # 工作簿配置
├── _rels/
│ └── workbook.xml.rels # 工作表映射关系
├── worksheets/
│ ├── sheet1.xml # 工作表数据(不一定叫 sheet1)
│ └── sheet7.xml # 实际的工作表可能叫任何名字
├── styles.xml # 所有样式定义
├── drawings/ # 图片资源
├── pivotTables/ # 透视表定义
├── calcChain.xml # 公式计算链
└── sharedStrings.xml # 共享字符串表
关键发现:数据页的内容只存在 worksheets/sheetN.xml 的 <sheetData> 标签里。
也就是说,理论上我只需要:
- 打开 zip
- 找到目标 worksheet
- 只改
<sheetData>里的内容 - 其他文件一概不动
- 封包
样式、图片、透视表都不受影响——压根没碰它们。
设计原则
三条,很简单:
- 黑盒原则:
styles.xml、drawings/、pivotTables/一律不碰 - 片段手术:只改目标 worksheet 的
<sheetData>区域,其他 XML 片段原样保留 - 可诊断失败:遇到不支持的场景直接报错,不静默降级。报错带上错误码,好排查
核心实现
整个组件大概 600 行 TypeScript,只依赖 adm-zip(操作 zip)和 fast-xml-parser(局部辅助解析)。
1. worksheet 定位:不能假设 sheet1.xml
第一坑:worksheet 文件名不一定是 sheet1.xml。
实际项目中,Excel 内部的文件可能是 sheet7.xml、sheet3.xml,跟你在 Excel 里看到的标签顺序不一定对应。直接猜文件名会出 bug。
正确做法是通过 workbook.xml + workbook.xml.rels 做映射:
// workbook.xml 里有每个 sheet 的 name 和 r:id
// <sheet name="Data" sheetId="1" r:id="rId1"/>
// workbook.xml.rels 里有 r:id 到实际文件的映射
// <Relationship Id="rId1" Target="worksheets/sheet7.xml"/>
export function resolveWorksheetPath(
workbookXml: string,
relsXml: string,
sheetRef: SheetRef
): string {
// 1. 从 workbook.xml 找到目标 sheet 的 r:id
// 2. 从 rels 找到 r:id 对应的 Target
// 3. 拿到真实路径,比如 "xl/worksheets/sheet7.xml"
}
这样不管 Excel 内部怎么编号,都能精准定位。
2. 数据注入:直接拼 XML
数据注入的本质是生成 <row> 和 <c>(cell)节点,替换掉原来的 <sheetData> 内容。
不同类型的数据,生成的 XML 不一样:
function buildCellXml(cellRef: string, value: unknown, ...): string {
// 数字
if (typeof value === 'number') {
return '<c r="' + cellRef + '" t="n"><v>' + value + '</v></c>';
}
// 字符串:用 inlineStr,不走共享字符串表
// 为什么不用 sharedStrings?因为改那个索引太容易出错了
return '<c r="' + cellRef + '" t="inlineStr"><is><t>' + escapeXmlText(String(value)) + '</t></is></c>';
// 日期:转成序列号,当作数字写入
// 布尔:t="b",值写 0/1
}
注意字符串用的是 inlineStr 而不是共享字符串表(sharedStrings.xml)。原因是改共享字符串表的索引很容易搞乱其他单元格,inlineStr 虽然文件稍大一点,但安全。
3. 行扩展策略
写入数据时,数据行数可能比模板里的行多,也可能少。两种策略:
- 模式 A(覆盖):只往已有行里写数据,多出来的行不要。适合固定行数的模板。
- 模式 B(扩展):允许新增行,新行会继承附近行的样式索引。
样式继承的逻辑:
// 新增行时,向上扫描同列,找到最近的带样式的单元格
private resolveInheritedStyle(
existingRowsMap: Map<number, string>,
rowIndex: number,
col: number
): string | undefined {
let cursor = rowIndex - 1;
while (cursor > 0) {
const xml = existingRowsMap.get(cursor);
if (!xml) { cursor -= 1; continue; }
// 先找同列的样式
// 找不到就找这一行任意一个有样式的单元格
// 还找不到就继续往上一行找
}
return undefined;
}
这样新增的行不会变成"裸奔"状态,至少能继承模板的基本样式。
4. 冲突检测:行扩展前先扫雷
模式 B 扩展行的时候,新行可能覆盖到一些不能碰的东西:
- 合并单元格(mergeCells)
- 数据校验规则(dataValidations)
- 条件格式(conditionalFormatting)
- 表格对象(tableParts)
- 命名区域(definedNames)
所以扩展之前先做一次矩形碰撞检测:
export function detectRangeConflicts(
worksheetXml: string,
targetRange: RangeRect,
strictMode: boolean
): string[] {
// 从 XML 中提取 mergeCells、dataValidations、conditionalFormatting 的范围
// 跟目标写入范围做矩形相交判断
// 严格模式下直接抛 E_UNSUPPORTED_RANGE 错误
// 宽松模式下收集告警,继续执行
}
严格模式下,有冲突直接报错终止。
5. 日期体系的坑
Excel 有两套日期体系:1900 和 1904。macOS 版 Excel 默认用 1904,Windows 版用 1900。
同一个日期,两套体系算出来的序列号差 1462 天。如果不管这个,写入的日期就会偏移四年多。
更离谱的是,1900 体系里有个著名 bug:Excel 认为 1900 年是闰年,2 月 29 日是"存在的"(实际上 1900 不是闰年)。所以序列号 60 对应的是这个不存在的日期,60 以后的序列号都要 +1。
export function toExcelDate(date: Date, date1904: boolean): number {
if (date1904) {
// 1904 体系:从 1904-01-01 开始算
return Math.floor((utc.getTime() - base1904.getTime()) / DAY_MS);
}
// 1900 体系:从 1899-12-31 开始算
let serial = Math.floor((utc.getTime() - base1900.getTime()) / DAY_MS);
// 兼容 Excel 的 1900 闰年 bug
if (serial >= 60) {
serial += 1;
}
return serial;
}
组件会自动检测模板用的是哪套体系,按模板的体系转换。
6. 公式重算
数据写进去了,展示页的公式要重新算。但 Node.js 里没有 Excel 计算引擎,怎么办?
答案是:让 Excel 自己算。
private applyRecalcPolicy(mode: RecalcMode): void {
// 删掉 calcChain.xml(旧的计算缓存)
this.zip.deleteFile('xl/calcChain.xml');
// 在 workbook.xml 里设置全量重算标记
// Excel/WPS 打开文件时会自动重算所有公式
workbookObj.workbook.calcPr['@_fullCalcOnLoad'] = '1';
workbookObj.workbook.calcPr['@_forceFullCalc'] = '1';
}
这样用户打开文件的时候,Excel 会自动把所有公式重算一遍。代价是第一次打开会慢几秒(取决于公式数量),但结果一定是正确的。
完整用法
import { ExcelSurgicalLink } from './src';
// 从本地模板创建
const link = new ExcelSurgicalLink('template.xlsx');
// 注入数据
link.inject(
[
['商品A', 100, new Date('2026-04-01')],
['商品B', 120, new Date('2026-04-02')]
],
{
sheetRef: { name: 'Data' }, // 按名称定位工作表
rowExpansion: 'B', // 允许行扩展
dateHandling: 'serial', // 日期写序列号
recalcMode: 'full', // 全量重算
strictMode: 'strict', // 严格模式
onUnsupportedFeature: 'error', // 不支持的特性直接报错
startCell: 'A2' // 从 A2 开始写
}
);
// 保存
link.save('output.xlsx');
也支持远程模板——从 URL 拉模板,写完直接上传:
const link = await ExcelSurgicalLink.fromSource(
'https://your-server.com/template.xlsx',
{ headers: { Authorization: 'Bearer token' }, timeoutMs: 10000 }
);
// ... 注入数据 ...
await link.saveToRemote(
'https://your-server.com/output.xlsx',
{ method: 'PUT', headers: { Authorization: 'Bearer token' } }
);
效果
核心指标:
- 样式保全:
styles.xml、drawings/、pivotTables/字节级不变 - 公式正确:展示页公式打开后自动重算,结果与输入数据一致
- 可打开性:Excel(Windows/Mac)和 WPS 打开无修复提示
- 依赖极简:只依赖
adm-zip+fast-xml-parser
已知边界
实事求是,没做完的就是没做完:
| 功能 | 状态 | 说明 |
|---|---|---|
| 固定区域写入 | ✅ | 完全支持 |
| 样式/图片/透视表保全 | ✅ | 字节级不变 |
| 日期体系兼容 | ✅ | 1900/1904 自动识别 |
| 公式重算触发 | ✅ | fullCalcOnLoad |
| 行扩展 + 样式继承 | ✅ | 模式 B |
| 冲突检测 | ✅ | 五类对象 |
| 远程模板读写 | ✅ | HTTP(S) |
| 结构化表(ListObject)自动扩展 | ❌ | 还没做 |
| definedNames 动态重写 | ❌ | 当前为保护性拦截 |
| 大规模性能压测 | ❌ | SLO 报告待补 |
最后
这个组件的思路其实不复杂:别重建,只做手术。
xlsx 是个 zip 包,数据就在几个 XML 标签里。与其让库帮你解析→重建(顺便丢信息),不如直接上手改那几行 XML。
当然,这个方案也有适用范围——它适合"模板复杂、数据写入点固定"的场景。如果你需要动态创建图表、动态生成透视表,那还是得用更重的方案。
代码在本地跑着,等什么时候有空了整理一下放 GitHub。