做后台系统的同行大概都有过这么一段经历:产品经理说"给我加个导出 Excel",你打开文件,发现要做的是十几个分组的汇总报表,每组的表头不一样,还要带小计行、合计行、页脚签字栏。第一版你咬着牙用代码一格一格地setCell,写了两百多行;第二版业务说列要加两列、顺序调一下,你回去改代码,改完发现某处的行号偏移全乱了。到第三版,你已经不敢动那个函数了。
这篇东西就是我从"代码画格子"彻底倒向模板渲染之后,折腾出来的一份实操记录。核心工具是ejsExcel—— 一个把 EJS 语法塞进 Excel 模板里的 Node.js 渲染引擎。它的定位很朴素:你做一个长得漂漂亮亮的 xlsx 模板,把要填数据的地方写成<%= %>,剩下的事情交给它。报表样式、合并单元格、打印区域、列宽,全都留在模板里由做表的人维护,代码只负责喂数据。这个分工一旦立起来,后面加列、改样式基本不需要动 Node.js 代码。
下面这些内容适合两类人看:一类是正在做导出功能、被格式需求反复折磨的后端;另一类是手里已经有一堆 Excel 模板、想把它自动化但不知道从哪下手的同学。我会把原理、模板写法、十个实打实的技巧、性能瓶颈和三次真实的翻车现场都摊开讲。
1. 从"代码画格子"到"模板填空":这次技术路线切换的账怎么算
1.1 命令式导出的三个真实病灶
先说说为什么我下定决心换路线。用代码逐格生成 Excel,本质上是命令式编程:你先建工作簿,再建表,再一行一行地写,每写一格都要算好坐标。这套模式在简单场景下没问题,但只要报表稍微复杂一点,就会同时爆发三个问题。
第一个是坐标耦合。你写sheet.getCell('C5').value = total,这个 C5 是硬编码的。业务哪天说"小计行上面加一行备注",你的 C5 就变成 C6,所有坐标全废。而且这种错误不会在编译期暴露,只会在用户打开文件时"哎这里数不对"。
第二个是样式维护成本。表头加粗、隔行变色、金额右对齐两位小数、超限标红——这些在模板里就是点几下鼠标的事,在代码里要写一堆font = { bold: true }、alignment = { horizontal: 'right' }、numFmt = '#,##0.00'。换个列宽都要改代码,设计和开发的协作完全断掉。
第三个是需求响应速度。财务同事改一版报表模板只要十分钟,走代码改一版要开发、提测、发版,两天起步。当你一年要做二十张不同报表的时候,这个成本是不可接受的。
判断标准很简单:如果这张表未来三个月内会被人改三次以上,就别用代码画,用模板。
1.2 模板引擎的本质:xlsx 其实是个装着 XML 的压缩包
理解原理很重要,因为后面 80% 的坑都跟这个原理有关。
一个.xlsx文件,你把后缀改成.zip,解压出来是一堆文件夹:xl/worksheets/sheet1.xml存单元格内容,xl/sharedStrings.xml存重复出现的文本,xl/styles.xml存样式,[Content_Types].xml描述类型清单。所以 Excel 文件本质上是一组结构化文本,只是被压缩了。
ejsExcel 干的事情就三步:
- 把模板 xlsx 解压缩(内存里做,不落盘);
- 把
sheet*.xml和sharedStrings.xml当成字符串模板,用 EJS 编译器编译; - 拿你的数据渲染出新的 XML,重新打包成 xlsx 的 Buffer 返回。
这个原理带来两个直接影响。好消息是:样式、合并单元格、列宽、图表这些你在模板里画好的东西,因为压根不属于"要渲染的文本",会被原样带过去。坏消息是:你的数据最终要变成 XML 文本,所以任何会破坏 XML 结构的字符(&、<)、任何会被 Excel 误判类型的内容(长数字串、以=开头的文本),都要在数据层提前处理。
我把这理解成"用字符串拼积木"——灵活,但也意味着没有类型检查帮你兜底,全靠自己把数据洗干净。
1.3 一次渲染的完整链路长什么样
先给一个能跑的最小例子,把链路串起来:
const path = require('path'); const fs = require('fs'); const ejsExcel = require('ejsexcel'); async function exportReport(data, outPath) { const tpl = path.join(__dirname, 'templates', 'monthly.xlsx'); const buf = await ejsExcel.renderExcel(tpl, data); fs.writeFileSync(outPath, buf); } exportReport({ title: '某业务月度汇总', month: '2024-06', rows: [ { name: '甲项目', amount: 12800.5, rate: 0.32 }, { name: '乙项目', amount: 8800, rate: 0.21 } ] }, './out.xlsx');对应模板里长这样:
| A | B | C | | 月度汇总表 | | | <- 标题行,A1 写 <%= title %> | 月份 | <%= month %> | | | 项目名称 | 金额 | 占比 | | <%= rows[i].name %> | <%= rows[i].amount %> | <%= rows[i].rate %> |注意这里的i——模板里的循环需要你自己控制索引,这点和普通 EJS 一样。实际模板中循环标签怎么摆,是下一节的重点,摆错了整个表就塌了。
整个链路的耗时分布大致是:解压 5%~10%,字符串编译和替换 60%~80%,重新压包 10%~20%。大头在字符串替换上,所以当数据量从 100 行涨到 5 万行时,耗时不是线性增长,而是明显加速恶化——这个后面第 4 节专门讲。
2. 模板文件本身的规矩:写得对不对,决定了后面所有的坑
2.1 单元格里的 EJS 标签怎么摆才不会被吃掉
EJS 一共三类标签,在 Excel 模板里语义完全对应:
| 标签 | 作用 | 在模板里的典型用途 |
|---|---|---|
<%= expr %> | 输出值(转义习惯由库决定,需实测) | 填充单元格数据 |
<% code %> | 执行代码,不输出 | 循环、条件判断、变量声明 |
<%- expr %> | 输出原始内容 | 极少用,慎用 |
实践中最容易出问题的是标签被 Excel 自动改写。比如你输入<%= name %>,Excel 有时会把它当成一段普通文本,但它也可能因为某些格式操作把<和%之间插入了不可见字符;更常见的是你把单元格设成了"文本"格式后输入,Excel 会把它当成一个纯字符串处理,这通常是好事,但如果你在单元格里换行、合并、套用富文本,标签内部就可能被拆到多个文本节点里,渲染引擎匹配不到,最后原样输出。
我的做法是三条规矩:
- 标签单元格一律不套富文本,需要加粗就给整个单元格加粗,不要在标签内部部分加粗;
- 输入标签前先把单元格格式设为"文本",再输入内容,避免 Excel 自作聪明;
- 写完模板后把后缀改成 zip 解压看一眼 XML,确认
<%= ... %>在 XML 里是完整连续的字符串。这一步花两分钟,能省掉后面两小时。
提醒:
%>和后面的中文之间不要贴太近,也不要让 Excel 触发"自动更正"。写完模板最好另存一次,再用编辑器打开检查标签有没有被改写。
2.2 循环行的排版:控制标签单独占行是最稳的写法
这是 ejsExcel 使用中最关键的一环,也是最容易翻车的地方。
核心思路:循环的开始标签和结束标签各占一整行(或一个独立单元格区域),中间夹着你要重复的那一行内容。渲染时引擎会识别出这一块是可重复区域,按数据条数复制。
最稳的模板排布:
第 5 行: <% for (let i = 0; i < rows.length; i++) { %> 第 6 行: <%= rows[i].name %> | <%= rows[i].amount %> | <%= rows[i].rate %> 第 7 行: <% } %>第 6 行就是你希望被复制 N 次的那一行。它上面的第 5 行和下面的第 7 行,渲染完之后不会出现在结果里(它们只承载控制逻辑)。
几个实战要点:
第一,循环行里的样式只对"第一行"生效。引擎复制的是模板中那一行的 XML 节点,样式会跟着复制,所以你在第 6 行设好的边框、底色、数字格式会跟着每一行数据走。但如果你想让"偶数行变灰"这种交替样式,EJS 拼不出来——那属于 Excel 的条件格式,得在模板里用"公式规则"配置好,别在循环里折腾。
第二,循环体内不要跨行合并单元格。如果你在第 6 行和第 7 行做了纵向合并,然后循环又复制这两个行,渲染结果会出现大量错位的合并区域,打开文件时 Excel 直接提示"发现不可读取的内容"。需要纵向合并的场景,宁可渲染完再后处理,也别在循环区里做。
第三,嵌套循环时给每一层用独立变量名。不要都用i,用i、j、k,或者语义化的groupIdx、itemIdx。EJS 编译后是普通 JS 作用域,let声明在循环体外层就没问题,但如果两层都用let i,内层会覆盖外层,数据全乱。
第四,如果循环区紧贴着"合计行",记得检查合计行的公式引用范围。循环展开后行数变了,原本写死的SUM(C6:C20)不会自动扩展。解决办法见第 5.3 节。
2.3 样式、合并单元格与图片:哪些能带过去,哪些带不过去
基于"只替换文本、不动结构"的原理,能带和不能带的基本可以这样划分:
| 元素 | 能否保留 | 备注 |
|---|---|---|
| 字体、颜色、边框、对齐 | 能 | 属于样式表,不受渲染影响 |
数字格式(#,##0.00) | 能 | 建议在模板里预设,别在数据里拼 |
| 列宽、行高 | 能 | 但循环行的高度会按模板首行复制 |
| 合并单元格 | 能 | 循环区内慎用 |
| 图片、页眉页脚 | 能 | 属于独立部件,一般不受影响 |
| 图表 | 能保留但不刷新 | 数据源范围写死的话,循环后图表不会自动扩展 |
| 数据透视表 | 基本不能 | 强烈建议另做一张表专门放透视 |
| 条件格式 | 能 | 但区域范围同样是写死的,需注意 |
这里我要强调一个反直觉的点:图表和数据透视表这类"引用型"元素,是模板方案的天然短板。它们引用的是固定的单元格区域,而循环展开后行数变了,引用不会跟着变。我的做法是:汇总图表单独做一个 sheet,用固定行数的汇总数据源去喂它,明细数据放另一个 sheet 随便展开。这样图表永远对着固定的几行数据,不会错乱。
图片还有一个额外注意点:模板里插的图片是存在xl/media/下的独立文件,体积可能不小。如果你把模板缓存到内存(第 3 节技巧九会讲),要注意这部分也占内存。曾经有个模板里塞了六张高清 logo,单个模板就占了 3MB,缓存五十个版本直接把进程撑爆了。
3. 十个实战技巧,按"省下来的时间"倒序排列
3.1 技巧一至三:数据在进模板之前就得洗干净
技巧一:建一个"数据整形层",模板里只做取值不做计算。这是我认为收益最大的一条。模板里只允许出现<%= item.field %>这种纯取值,任何toFixed、日期格式化、条件判断拼接字符串,统统挪到 Node.js 侧做。原因很实际:模板里的错误极难调试,渲染出来表格打不开,你根本不知道是第几行哪条数据的问题;而 JS 侧有堆栈、有断点、可以单测。
具体做法是写一个shape(rows)函数,把数据库出来的原始数据转成"刚好能填进模板"的形状:
function shapeRows(raw) { return raw.map(r => ({ name: String(r.name ?? '').trim(), // 金额保留两位,且强制转成数字,避免渲染成字符串 amount: Number((r.amount ?? 0).toFixed(2)), // 日期直接转成 Excel 序列号,模板里只放值 date: toExcelDate(r.createdAt), // 长编号强制字符串,防止变科学计数法 orderNo: String(r.orderNo ?? '') })); }技巧二:空值必须有兜底,否则表格里会印出undefined。EJS 的<%= undefined %>渲染结果就是字面量undefined,出现在报表里非常难看。而且更隐蔽的情况是null——渲染成null四个字母。所以数据整形层里每个字段都要?? ''。数字字段用?? 0,布尔用?? false。
还有一种情况:如果某个字段整个对象都不存在(比如r.extra.discount,而r.extra是undefined),模板里会直接抛TypeError,整个渲染失败。稳妥做法是整形层里把嵌套对象全部拍平,模板只访问一层的字段。
技巧三:日期转 Excel 序列号,别直接扔 Date 对象。这是我认为最容易被忽略但影响最大的细节。Excel 内部把日期存成一个数字序列号,1900-01-01 是 1,每过一天加 1。如果你把new Date()直接塞进模板,渲染出来是一串Mon Jun 10 2024 ...的字符串,占满整个单元格,还右对齐不了。
正确做法是转成序列号,模板单元格预设成日期格式:
function toExcelDate(d) { if (!d) return ''; const date = d instanceof Date ? d : new Date(d); if (isNaN(date.getTime())) return ''; // 减去本地时区偏移,避免日期整体偏移一天 const offsetMs = date.getTimezoneOffset() * 60000; // 25569 是 1970-01-01 对应的 Excel 序列号 return (date.getTime() - offsetMs) / 86400000 + 25569; }这里的25569不是拍脑袋来的:Excel 的 1900 日期系统里,1970-01-01 的序列号就是 25569(1900 年那个"闰年 bug"已经被这个常量吸收掉了)。至于getTimezoneOffset()那一步,我踩过一次坑——服务器跑在 UTC,用户在东八区,导出后发现所有日期都早了一天。加上时区偏移之后才对。
3.2 技巧四至六:多工作表、条件区块与公式保留
技巧四:一个模板管多个 sheet,用同一份数据驱动。很多报表需要"明细页 + 汇总页 + 说明页"三张表。你完全可以在一个模板文件里建三个 sheet,每个 sheet 里引用同一份数据的不同切片。比如明细页用<%= detail %>,汇总页用<%= summary %>,数据侧一次性传进去:
const data = { detail: shapeRows(raw), summary: buildSummary(raw), generatedAt: toExcelDate(new Date()), operator: '某业务系统' };好处是这三个 sheet 的样式、字体、页边距在模板里统一维护,不用代码拼接。坏处是所有 sheet 共用一个渲染时间,数据量大的时候整份文件都会变慢,所以第 4 节的分批策略要按 sheet 拆。
技巧五:用条件标签控制整块区域的显隐,避免出现空表头。最常见的需求是"没有数据就不显示这块"。如果直接让循环渲染 0 条,表头还在,就变成一张只有表头没有内容的空表,很难看。
做法是把整块(标题 + 表头 + 循环)包在条件里:
<% if (rows && rows.length > 0) { %> ... 有数据时的完整区块 ... <% } else { %> ... 只有一行"本期无数据"的占位区块 ... <% } %>注意一个细节:<% if %>这种控制标签所在的行,渲染后不会消失为空行,因为整行的 XML 节点被"吃掉"了。但我实测过某些版本会留下一个高度为默认值的空行,如果发现渲染结果顶部多了一条空白,就是这个原因——解决办法是把控制标签写在同一行的空单元格里,而不是单独占一行(具体行为跟你手上的版本有关,建议第一次用两三条数据验证一遍)。
技巧六:公式单元格留空,让 Excel 自己算。模板里通常会有合计行,写=SUM(...)。问题在于:xlsx 里公式单元格同时存着公式和上次计算结果的缓存值。你渲染出来的文件如果带着旧缓存值,用户打开时可能先看到旧数字,要触发重算才更新;某些阅读器(比如手机端预览)甚至只读缓存值,直接显示错误结果。
我试过三种方案,最后选的是最朴素的一种:模板里的公式单元格,把缓存值那一部分清掉,或者干脆在模板里不写公式,改用 EJS 把合计值算好写进去。
// 在数据层把合计算好,模板里直接输出数字 const totalAmount = rows.reduce((s, r) => s + r.amount, 0);这样渲染出来的就是静态数字,任何阅读器都正确显示,代价是失去了"用户改一格自动重算"的能力。如果业务确实需要活公式,那就得在渲染后做一次后处理:解压 xlsx,在xl/workbook.xml里加上<calcPr fullCalcOnLoad="1"/>,强制打开时全量重算,再重新打包。这一步稍微 hack,但如果库没暴露配置项,这是最直接的路子。
3.3 技巧七至九:分页导出、并发控制和模板常驻内存
技巧七:超过五千行就切片,别指望一次渲染出来。我做过一次压测:1000 行大概 200ms,1 万行到了 3.5 秒,5 万行的时候内存峰值冲到 700MB 以上,进程差点被容器 OOM 干掉。原因是字符串拼接的内存开销随行数超线性增长——每一次替换都产生新字符串,V8 的字符串合并虽然做了优化,但几十万个节点拼起来还是扛不住。
落地策略是:超过 N 行(我一般设 5000)就切成多个文件,或者切成多页放进同一个工作簿的不同 sheet 里。前者用户下载到的是一个 zip,后者是一个大文件但分页清晰。选择依据是使用场景:给财务归档的用多文件,给业务在线看的用多 sheet。
const CHUNK = 5000; const chunks = []; for (let i = 0; i < rows.length; i += CHUNK) { chunks.push(rows.slice(i, i + CHUNK)); } // 逐块渲染,逐块落盘,避免所有 Buffer 同时驻留内存 for (let idx = 0; idx < chunks.length; idx++) { const buf = await ejsExcel.renderExcel(tpl, { rows: chunks[idx], page: idx + 1 }); fs.writeFileSync(`./out/part-${idx + 1}.xlsx`, buf); }关键点是逐块落盘。如果你把所有 Buffer 收进数组再统一写,内存压力等于没减。这块我踩过坑,写完才发现峰值内存没降下来。
技巧八:渲染是 CPU 密集操作,别在主线程串行跑。这一点很多人忽略。渲染过程是纯字符串运算,Node.js 的单线程特性意味着渲染期间所有 HTTP 请求都会被卡住。如果导出接口并发三个请求,主线程就堵了,其他接口全部超时。
解决方案有轻重两档:
// 轻量档:并发闸门,控制同时渲染的数量 class Semaphore { constructor(n) { this.n = n; this.q = []; } async run(fn) { if (this.n <= 0) await new Promise(r => this.q.push(r)); this.n--; try { return await fn(); } finally { this.n++; if (this.q.length) this.q.shift()(); } } } const gate = new Semaphore(2); // 同时最多两个渲染任务 await gate.run(() => ejsExcel.renderExcel(tpl, data));// 重量档:丢到 worker_threads 里,彻底不占主线程 const { Worker } = require('worker_threads'); function renderInWorker(tpl, data) { return new Promise((resolve, reject) => { const w = new Worker('./render-worker.js', { workerData: { tpl, data } }); w.once('message', resolve); w.once('error', reject); }); }配套的 worker 文件:
// render-worker.js const { parentPort, workerData } = require('worker_threads'); const ejsExcel = require('ejsexcel'); ejsExcel.renderExcel(workerData.tpl, workerData.data) .then(buf => parentPort.postMessage(buf)) .catch(err => { throw err; });有个小坑:postMessage传 Buffer 会走结构化克隆,到主线程变成了Uint8Array,写文件前要Buffer.from(msg)转回来,否则写出来的文件打不开。
技巧九:模板文件常驻内存,别每次从磁盘读。模板往往是几百 KB 到几 MB,每次请求都fs.readFileSync一遍,在低并发下看不出来,高并发时磁盘 IO 会成为明显瓶颈。而且模板里的图片越多,这个开销越大。
最简单的优化是用一个 Map 按模板名 + 修改时间做缓存:
const tplCache = new Map(); function getTpl(name) { const p = path.join(__dirname, 'templates', `${name}.xlsx`); const mtime = fs.statSync(p).mtimeMs; const key = `${name}:${mtime}`; if (!tplCache.has(key)) { tplCache.set(key, p); // 存路径;若库支持 Buffer 入参,存 Buffer 更好 } return tplCache.get(key); }用mtime做 key 的好处是模板更新后缓存自动失效,不用重启服务,也不用写手动清除逻辑。如果库支持传 Buffer 作为模板来源,直接缓存 Buffer 更省事;只支持路径的话,至少路径缓存能帮你省掉路径拼接和存在性检查。
3.4 技巧十:把渲染错误定位到具体单元格
这条技巧的价值只有在你真正遇到过"文件打不开"的时候才能体会。
渲染报错的原始信息通常极其模糊,类似Invalid character in tag或者干脆一个Unexpected token,连是哪个 sheet 都不告诉你。我的做法是在渲染外面包一层,把模板名、数据规模、数据快照一起记下来:
async function safeRender(tplName, data) { try { return await ejsExcel.renderExcel(getTpl(tplName), data); } catch (err) { console.error('[EXCEL_RENDER_FAIL]', JSON.stringify({ tpl: tplName, rowCount: data?.rows?.length, sample: data?.rows?.slice(0, 3), message: err.message })); throw err; } }有了这份日志,排查路径就变成了固定动作:先看sample里的字段值有没有&、<、>,再看rowCount是不是超过内存阈值,最后才怀疑模板。90% 的渲染失败都是数据里混进了特殊字符,而不是模板写错了。
顺便分享一个小工具函数,专门清理要写进 XML 的文本:
function xmlSafe(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); }注意:这个函数要不要用,取决于库内部是不是已经做了转义。如果库已经转义了,你再来一遍就会变成
&amp;,表格里显示成一堆乱码。第一次用的时候拿一条带&的数据测一下,看渲染结果是&还是&,再决定要不要自己加。这个验证只要一分钟,但能避开一整类问题。
4. 数据量翻十倍之后:渲染耗时到底花在哪里
4.1 用一组对照实验找到真正的瓶颈
我先说结论:ejsExcel 这类基于字符串替换的方案,性能拐点在几千行。我做过的对照如下(同一台机器、同一模板、纯明细列表):
| 数据行数 | 平均耗时 | 内存峰值 | 观察 |
|---|---|---|---|
| 100 | 约 60ms | 80MB | 基本无感 |
| 1,000 | 约 200ms | 130MB | 可接受 |
| 5,000 | 约 1.2s | 320MB | 开始明显 |
| 10,000 | 约 3.5s | 520MB | 接口超时 |
| 50,000 | 30s 以上 | 700MB+ | 容器 OOM |
这组数字不是绝对的,跟模板复杂度、单元格数量、图片多少都有关,但趋势是确定的:耗时和内存都在超线性增长。
为什么会这样?因为本质上每一个<%= %>都要经历"定位、求值、字符串拼接"三步,而字符串拼接在 V8 里是创建新对象。1 万行 × 每行 8 列 = 8 万次求值和 8 万次拼接,再加上中间的临时字符串,内存占用自然飙高。
知道瓶颈在哪,优化方向就清楚了:减少单次渲染的数据量,而不是想办法"优化渲染速度"。后者基本无解,前者一招见效。
4.2 分批切片与多文件打包
具体的分批实现要解决两个问题:命名规则和汇总页。
命名规则上,我一般用报表名_生成时间_分片序号.xlsx,这样用户下载下来按文件名排序天然就是正确顺序。生成时间精确到分钟就够,避免文件名太长。
汇总页的处理有两种思路。思路一:每片都带汇总页,好处是单个文件信息完整,坏处是汇总数字重复出现在每个文件里,用户容易以为重复统计了。思路二:额外生成一个"汇总.xlsx",只包含总计和分片清单。我倾向后者,逻辑更清晰。
如果用户必须下载单个文件,那就把所有分片打成 zip:
const archiver = require('archiver'); async function exportZip(rows, outPath) { const output = fs.createWriteStream(outPath); const archive = archiver('zip', { zlib: { level: 6 } }); archive.pipe(output); const CHUNK = 5000; for (let i = 0; i < rows.length; i += CHUNK) { const part = rows.slice(i, i + CHUNK); const buf = await ejsExcel.renderExcel(tplPath, { rows: part }); archive.append(buf, { name: `part-${Math.floor(i / CHUNK) + 1}.xlsx` }); } await archive.finalize(); }注意archive.append传 Buffer 是支持的,所以不需要先把分片写到磁盘再打包,省一轮 IO。但这里的短板是zip 打包本身也是 CPU 操作,如果你在 worker 里渲染完还要回主线程打包,整体耗时并不会减少太多,只是不再阻塞请求。稳妥的做法是把打包也放进 worker。
4.3 worker_threads 隔离 CPU 密集渲染
前面提过用 worker 隔离,这里展开说说落地细节。
一个典型的问题是:worker 的启动成本。new Worker()每次创建大约需要几十毫秒,如果每个请求都新建,小数据量场景反而更慢。所以正确的姿势是维护一个 worker 池,任务来了分配一个空闲 worker。
class WorkerPool { constructor(size, script) { this.idle = []; this.queue = []; for (let i = 0; i < size; i++) this.idle.push(this.spawn(script)); } spawn(script) { const w = new Worker(script); w.busy = false; return w; } async run(payload) { const w = await this.acquire(); return new Promise((resolve, reject) => { const onMsg = (buf) => { cleanup(); resolve(Buffer.from(buf)); }; const onErr = (e) => { cleanup(); reject(e); }; const cleanup = () => { w.off('message', onMsg); w.off('error', onErr); w.busy = false; if (this.queue.length) this.queue.shift()(w); }; w.on('message', onMsg); w.on('error', onErr); w.postMessage(payload); }); } acquire() { const free = this.idle.find(w => !w.busy); if (free) { free.busy = true; return Promise.resolve(free); } return new Promise(res => this.queue.push(w => { w.busy = true; res(w); })); } }worker 的数量按 CPU 核数来定,os.cpus().length - 1是个合理起点——留一个核给主线程处理 HTTP。容器环境下要注意cgroup限制,os.cpus()拿到的可能是宿主机的核数,比实际配额大得多,结果就是启动了太多 worker 互相抢 CPU。这种情况可以用环境变量手动指定。
5. 三个让我熬夜的现场:踩坑链路完整复盘
5.1 一个 & 符号让整份文件打不开
症状:Excel 提示"发现 xx.xlsx 中的部分内容有问题。是否希望我们尝试恢复?"点"是"能打开,但部分单元格内容丢失,部分样式错乱。
排查链路:第一步,确认是数据相关还是模板相关——我用空数据渲染了一次模板,文件正常打开,说明模板没问题。第二步,二分数据。取前 500 条渲染正常,加上第 501 条就崩,锁定到具体一条。第三步,看这条数据的字段值,发现有个客户名里带&,比如"某某&某某联合项目"。
根因:&在 XML 里是实体引用的起始符,&某不是一个合法的实体,解析器直接报错。渲染时这串字符被原样写进了<t>节点里,XML 结构就被破坏了。
修复:数据整形层里对文本字段统一做xmlSafe转义。但要注意我前面说的那个坑——必须先验证库本身有没有转义。我这次的版本没转义,所以自己加是对症的。如果某个版本内部已经转义,你自己再加就会双重转义。
同类问题:<、>也是同理。还有一类更隐蔽的——文本以=开头,比如"=A+B"这种业务编号,Excel 会把它当公式解析,导致单元格显示#NAME?。解决办法是在数据前加一个单引号(Excel 的强制文本标记),或者把模板单元格预设为文本格式。
5.2 订单号变成 1.23E+18 的那次事故
症状:财务反馈导出的订单号显示成1.23457E+18,双击单元格能变回完整数字,但最后几位变成了 0。
根因:Excel 的数值类型只有 15 位有效数字,超过就会被截断。18 位的订单号作为数字写进去,精度必然丢失。而且单元格如果是"常规"格式,长数字会自动显示成科学计数法。
修复分两层:第一层,数据侧把订单号强制转成字符串,不要让它以数字形态存在。第二层,模板里把这一列的单元格格式设为文本(@)。只做第一层不够——因为如果模板单元格是"常规"格式,Excel 打开时仍可能尝试把看起来像数字的字符串转成数值。
这里有个细节值得说:在模板里给整列设文本格式,比在数据里加引号更可靠。加引号的方式('123456789012345678)在某些阅读器里会把引号也显示出来,而且导出后再二次编辑时引号会残留。模板预设格式是更干净的做法。
衍生问题:000123这样的前导零编号,如果按数字处理会变成123。同样的解法——数据转字符串 + 模板文本格式。还有身份证号、银行卡号、统一社会信用代码,全都是同一类问题。我在数据整形层里专门维护了一个"文本列白名单",这几类字段一律走字符串通道。
5.3 模板多出一个空行,汇总公式整体偏移
症状:明细数据渲染正常,但最下面的合计行显示的数字明显偏小,核对后发现少算了几行。
排查链路:先怀疑数据,手工把数据加起来对了一遍,总数没问题。再看渲染出来的文件,发现合计行的公式是=SUM(C6:C25),而实际数据从 C6 排到了 C28——公式区域没覆盖到最后几行。
根因:模板里为了美观,我在表头和数据区之间多留了一个空行。循环展开后,公式的起始行没变(模板里固定写死),但结束行因为空行的存在往前挪了一位,正好把最后几行数据排除在外。
修复思路有三种,我最后选的是第三种:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| A | 手工把公式范围写死成一个足够大的区间,比如SUM(C6:C10000) | 简单,但空白单元格也算进区间,某些统计函数会出偏差 |
| B | 用 EJS 动态算出范围,把公式当字符串拼出来 | 精确,但公式字符串里混 EJS 标签,模板很难维护 |
| C | 合计值在数据层算好,模板里只放静态数字 | 最稳,任何阅读器都对,代价是失去活公式 |
方案 B 具体长这样,供参考:
=SUM(C6:C<%= 5 + rows.length %>)这里的5是数据起始行减 1。这种写法能跑,但每次改模板都要重新数行号,非常容易错,所以我一般不推荐。
顺带一个经验:模板里的行号一旦被写死在任何地方(公式、图表数据源、条件格式区域、打印区域),就成了一种"隐式契约"。我现在的做法是模板定稿时把所有涉及行号的地方列一个清单,附在模板文件旁边的一个说明文档里。谁改模板谁更新这个清单,能挡掉大部分低级错误。
6. 接到业务里:接口分层、模板版本与出错兜底
6.1 导出接口的三层切分
一个能长期维护的导出功能,我一般拆成三层,职责不混。
第一层是查询层,只负责从数据源拿原始数据,不关心导出格式。参数是业务筛选条件,返回是原始记录数组。
第二层是整形层,就是我前面反复提的shapeRows。它负责字段改名、类型转换、空值兜底、日期序列号化、XML 转义、文本列处理。这一层是纯函数,最好写单测——因为它是所有诡异问题的集中地。
第三层是渲染层,只做"模板 + 数据 → Buffer"。它不认识业务字段,只知道data里有什么。
async function handleExport(req, res) { const raw = await queryOrders(req.query); // 查询层 const data = shapeForMonthly(raw, req.query); // 整形层 const buf = await safeRender('monthly', data); // 渲染层 res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="report.xlsx"`); res.end(buf); }这样分层之后,90% 的需求变更只落在整形层。改个字段名、加个百分比列、换个日期格式,都不用碰渲染代码。模板改样式则是另一条完全独立的线,由做表的人自己负责。
6.2 模板文件的版本管理与灰度替换
模板文件是二进制,直接扔 git 里不太优雅,但如果不做版本管理,出问题时你连"上一版长什么样"都找不回来。我的做法是折中:
- 模板目录纳入版本控制,文件名带上语义化版本号,比如
monthly.v2.xlsx; - 代码里配置当前使用的版本,不写死在业务逻辑里;
- 保留最近三个版本,更老的归档到备份目录。
替换流程上,新模板上线我一般走"影子对比":同一批数据分别用新旧模板渲染,把两份文件都存下来,让业务方打开对比确认无误再切。这个流程听起来笨,但比重现一次"上线后发现合计行错位"的线上事故便宜得多。
还有一种情况要提前想好:模板文件被非开发人员修改后直接覆盖。这种事一定会发生。我的应对是在模板目录上配一个文件监听,模板的mtime变化时打一条日志,记录文件名和改动时间。不阻止,但留痕。真出问题时这条日志能帮你快速锁定"是不是有人动了模板"。
6.3 出问题时先看什么
最后分享一个我自己的排查顺序,按命中率从高到低排:
- 数据里有没有
&、<、>、以=开头的文本——占问题总数的六成以上; - 有没有超长数字串被当成数值——订单号、身份证、银行卡,一看一个准;
- 数据量是不是超过了阈值——看日志里的
rowCount,跟平时的量级比一下; - 模板最近有没有被改动——看前面说的 mtime 日志;
- 模板里的公式和合并区域有没有落在循环区内——这是结构性错误,人工检查。
前两条属于数据层问题,加个xmlSafe和文本列白名单就能防住一大片。后三条属于模板层,靠流程和评审。我个人的体会是,这套方案真正的成本不在渲染本身,而在"数据和模板之间的契约"上——契约清晰了,报表需求就从"开发任务"变成了"配置任务",那才是模板引擎最大的价值所在。
至于模板引擎本身的局限,我也想得很清楚了:它擅长的是格式固定、数据变化的报表,不擅长的是用户在线编辑、实时联动、透视分析。如果你的需求里出现了"用户要能自己拖字段",那就该考虑换方案了,别硬套模板。工具选型的判断标准从来不是"哪个更强",而是"哪个更贴合你这一类需求的主要矛盾"。