普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月5日首页

用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个

作者 小凡同志
2026年4月4日 19:28

用 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> 标签里

也就是说,理论上我只需要:

  1. 打开 zip
  2. 找到目标 worksheet
  3. 只改 <sheetData> 里的内容
  4. 其他文件一概不动
  5. 封包

样式、图片、透视表都不受影响——压根没碰它们。


设计原则

三条,很简单:

  1. 黑盒原则styles.xmldrawings/pivotTables/ 一律不碰
  2. 片段手术:只改目标 worksheet 的 <sheetData> 区域,其他 XML 片段原样保留
  3. 可诊断失败:遇到不支持的场景直接报错,不静默降级。报错带上错误码,好排查

核心实现

整个组件大概 600 行 TypeScript,只依赖 adm-zip(操作 zip)和 fast-xml-parser(局部辅助解析)。

1. worksheet 定位:不能假设 sheet1.xml

第一坑:worksheet 文件名不一定是 sheet1.xml

实际项目中,Excel 内部的文件可能是 sheet7.xmlsheet3.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.xmldrawings/pivotTables/ 字节级不变
  • 公式正确:展示页公式打开后自动重算,结果与输入数据一致
  • 可打开性:Excel(Windows/Mac)和 WPS 打开无修复提示
  • 依赖极简:只依赖 adm-zip + fast-xml-parser

已知边界

实事求是,没做完的就是没做完:

功能 状态 说明
固定区域写入 完全支持
样式/图片/透视表保全 字节级不变
日期体系兼容 1900/1904 自动识别
公式重算触发 fullCalcOnLoad
行扩展 + 样式继承 模式 B
冲突检测 五类对象
远程模板读写 HTTP(S)
结构化表(ListObject)自动扩展 还没做
definedNames 动态重写 当前为保护性拦截
大规模性能压测 SLO 报告待补

最后

这个组件的思路其实不复杂:别重建,只做手术

xlsx 是个 zip 包,数据就在几个 XML 标签里。与其让库帮你解析→重建(顺便丢信息),不如直接上手改那几行 XML。

当然,这个方案也有适用范围——它适合"模板复杂、数据写入点固定"的场景。如果你需要动态创建图表、动态生成透视表,那还是得用更重的方案。

代码在本地跑着,等什么时候有空了整理一下放 GitHub。

❌
❌