做后端的朋友大概都接过这类需求:运营每月要一份按区域、渠道、品类拆开的多 sheet 报表,财务要一份带合计行和环比列的对账单,业务方还会在你交付前一天突然说"能不能再加一列"。一开始我用纯代码的方式拼 Excel,一列列写 width,一个个 set 样式,边框颜色对不齐、冻结窗格位置算错,改一次需求就要重排一遍坐标。后来我把思路换掉了:让业务方在 Excel 里把报表"画"出来,我这边只负责把数据灌进去。这就是 ejsExcel 这类 Node.js Excel 模板引擎的核心价值——模板即报表,代码只做数据绑定。这篇内容讲的就是我这两年用 ejsExcel 做动态报表踩出来的东西:它到底怎么工作、模板里那几种标签怎么写、数据层怎么组织、十个能真正提速的技巧,以及那些文档里不会写的坑。适合已经会用 Node.js、正在被 Excel 导出需求反复折腾的后端和全栈同学,也适合刚接手报表模块、想快速摸清套路的新人。
1. 先想清楚为什么用模板引擎,再动手写第一行代码
1.1 纯代码拼 Excel 的痛点到底卡在哪
用 exceljs 这类库从零构建工作簿,本质是"用代码描述一张表格的所有视觉属性"。这在报表只有一张、字段只有五列的时候完全没问题,代码量也就百来行。但只要报表进了真实业务,麻烦就成倍放大:表头有三行合并、明细区有斑马纹、合计行要加粗加底色、金额列要千分位、日期列要按区域格式化、打印还要设置横向和缩放比例。这些东西用代码写,每一个都是一次试错,改一次样式就得跑一次生成再打开看效果,反馈循环特别长。
更要命的是需求变更。业务方说"品类那列挪到区域前面",纯代码方案里你面对的是列索引的整体位移,所有 setCell 的下标、所有样式的 apply 位置、所有合并单元格的区间都要跟着改。我见过一个报表模块,因为中途插了一列,合并单元格的坐标全错,花了半天定位。而模板方案里,这类改动就是打开 Excel 拖一下列,存盘,重启服务,完事。
还有一个隐性成本:业务方看不懂代码,所以每次调整都得经过你。用模板以后,如果你们愿意,甚至可以把模板文件交给业务方自己维护版式,你只保证数据字段名不变。这一层解耦带来的沟通成本下降,是很多人一开始没意识到的。
1.2 ejsExcel 的工作机制:Excel 当模板,EJS 当胶水
ejsExcel 的思路很朴素:把一个普通的 .xlsx 文件当成模板,你在单元格里写 EJS 标签,Node.js 读取这个文件的二进制内容,逐单元格解析标签、执行 JavaScript、把结果写回去,最后输出一个新的 .xlsx Buffer。整个过程不依赖任何 Office 组件,纯 JS 完成,因此在 Linux 容器里也能跑。
它有几个关键特性值得先建立印象。第一,模板是真正的 Excel 文件,你在 Excel、WPS 或者在线表格里排版都行,字体、颜色、边框、行高列宽、合并、冻结窗格、数据验证这些都会原样保留——引擎只处理含标签的单元格,其他单元格原封不动复制过去。第二,标签的书写位置决定了行为,控制流标签放在不同行会被理解为纵向循环,放在同一行的不同列则会被理解为横向循环,这一点跟很多"占位符替换"式的模板引擎不一样。第三,它是整本工作簿范围渲染,一个模板里可以有多个 sheet,每个 sheet 都能独立写标签。
一个最小的调用长这样:
const fs = require('fs'); const path = require('path'); const ejsExcel = require('ejsexcel'); async function main() { const tplBuf = fs.readFileSync(path.join(__dirname, 'tpl', 'monthly-report.xlsx')); const data = { title: '2024 年 5 月区域销售月报', rows: [ { region: '华东', channel: '直营', amount: 128900.5, rate: 0.132 }, { region: '华南', channel: '分销', amount: 87600, rate: 0.087 }, ], }; const outBuf = await ejsExcel.renderExcel(tplBuf, data); fs.writeFileSync(path.join(__dirname, 'out', 'monthly-report.xlsx'), outBuf); } main().catch(console.error);注意renderExcel的第一个参数是模板文件的Buffer,不是路径。这个设计其实挺好,意味着你可以把模板放在内存、放在对象存储、放在数据库里,只要有 Buffer 就能渲染,不用落地到磁盘。
1.3 几种主流方案横向对比
选型这件事不能只看功能,还要看维护成本和团队接受度。下面这张表是我自己整理过的,基于实际项目里的体感:
| 方案 | 模板来源 | 版式控制 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| ejsExcel | 真实 xlsx 文件 | 完全在 Excel 里完成 | 低(会 EJS 即可) | 版式复杂、频繁调整的报表 |
| exceljs 纯代码 | 无 | 全部代码描述 | 中高 | 结构固定、需要精细控制的导出 |
| 简单占位符替换类库 | xlsx 文件 | 单元格内替换 | 很低 | 只填几个字段的通知单 |
| 直接输出 CSV | 无 | 无 | 极低 | 只给数据、不需要格式 |
选 ejsExcel 的判断标准其实很简单:这张表的"长相"会不会变。会变、变得还比较频繁,就用模板方案;十年不变的工具型导出,纯代码一次写完更省心。我自己现在的习惯是:只要报表里出现合并单元格或者多 sheet,一律走模板。
注意:ejsExcel 处理的是 .xlsx,不要用老式 .xls 模板,后者是二进制格式,解析行为不一致,容易出现样式丢失或者直接报错。
2. 模板写法:把 Excel 当页面来排版
2.1 单元格里的输出标签与表达式
最基础的用法是输出标签<%= ... %>,它会把表达式的值写进当前单元格。这个写法和 EJS 网页模板一致,理解起来没门槛:
A2 单元格:<%= report.title %> B3 单元格:<%= item.region %> C3 单元格:<%= item.amount.toFixed(2) %>这里有两个实操细节。第一,不要依赖引擎做格式化。金额想显示千分位、日期想显示成2024-05-01、百分比想显示成13.2%,最稳的做法是在数据层就转好字符串,或者在模板里显式调用toFixed/ 自己写的格式化函数。原因后面第 3 节会细说,简单讲就是 Excel 有很强的"类型猜测"能力,你给一个"2024-05-01"字符串,它可能自作主张把它转成日期序列值,然后按单元格格式显示成5/1/2024,跟你要的不一样。
第二,表达式里能用的东西只有你传进去的 data 和 JS 全局对象,模板里拿不到外部变量,也不能require。所以像格式化函数、字典映射表这类东西,要在调renderExcel之前塞进 data 里:
const data = { rows: list.map(it => ({ ...it, amountText: Number(it.amount).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','), statusText: ({ 1: '已确认', 2: '待确认', 3: '已作废' })[it.status] || '-', })), };把格式化放在数据层还有个额外好处:同一个值可能在报表里出现多次(明细区、合计区、页脚),格式化逻辑写一次,到处复用,不会出现同一份报表里金额一个带千分位一个不带的情况。
2.2 行循环与列循环的边界怎么定
这是 ejsExcel 最容易让人懵的地方,也是最需要建立直觉的地方。控制流标签用<% %>包裹,本身不输出内容。它的行为取决于开始标签和结束标签所在的相对位置。
纵向循环(最常见的明细行展开):
第 4 行 A 列:<% for (let i = 0; i < rows.length; i++) { %> 第 4 行 B 列:<%= rows[i].region %> 第 4 行 C 列:<%= rows[i].channel %> 第 4 行 D 列:<%= rows[i].amountText %> 第 4 行 E 列:<% } %>开始和结束标签在同一行、不同列,中间夹着要输出的格子。渲染时这一整行会按rows.length复制,每份填入对应下标的数据,行高、边框、字体、背景色全部跟着复制。循环结束后,原来那一行不会额外留空,位置刚好接上。
横向循环则是开始和结束标签在同一列、不同行:
A4:<% for (let i = 0; i < months.length; i++) { %> A5:<%= months[i] %> A6:<% } %>这种情况下是整列按次数复制到右边。实际项目里横向循环用得少一些,主要出现在"月份横排"的交叉表里。
有一个坑我必须提前说:不要把<% } %>单独扔在数据区最后一行之外的地方。比如你在第 4 行开始循环,把结束标签放在第 8 行,那么第 4 到第 8 行会被当作一个整体重复 N 次,中间那几行空行也会被复制 N 次,最终报表里会多出一堆空白行,而且你很难一眼看出是模板的问题还是数据的问题。规则很简单:开始和结束标签尽量贴近,中间只放这一行真正需要的内容。
2.3 样式到底是跟着谁走的
很多人第一次用模板引擎,最担心的就是"样式会不会乱"。实测下来,ejsExcel 的样式继承逻辑是符合直觉的:循环复制出来的每一行,继承的都是模板里那一行的样式。所以做斑马纹的正确姿势不是写判断逻辑,而是在模板里把两行的样式都设好,然后用取模的方式决定走哪一套——不过更省事的做法是:
- 明细区只设一种底色,靠 Excel 的"表格样式"或者条件格式做斑马纹;
- 或者干脆放弃斑马纹,用细横线分隔,视觉上更干净,也省得纠结。
合并单元格是另一个重灾区。在循环区域里使用跨行的合并单元格,渲染结果基本不可控,因为引擎是按行复制单元格的,合并信息在复制过程中的处理在不同版本里表现不完全一致。我的建议是:明细区一律不合并,需要视觉分组的地方,把合并放在循环区域之外的固定行(如表头、合计行)。
还有一种情况是"内容太长要自动换行"。模板里必须提前把单元格设成"自动换行",引擎不会帮你打开这个开关。设好之后,行高如果设的是固定值,内容多了会被截断,所以明细行的行高最好留一点余量,或者设成"自动调整行高"——但要注意,某些版本的自动行高在复制后不会重新计算,稳妥做法是给一个稍大的固定行高。
2.4 公式、图片与多 Sheet 的处理
公式这块,模板里写的公式(比如合计行的SUM(D5:D200))会原样保留在输出文件里。但这里有个陷阱:如果你在模板里写死了SUM(D5:D200),而循环展开后的数据只有 20 行,公式范围就会包含大量空行,虽然 SUM 结果对,但看起来别扭;反过来数据有 500 行,公式就漏算了。所以合计行我一般不用公式,而是在数据层算好总和再输出,或者写公式时用能自适应范围的形式。如果业务方一定要能二次编辑的活公式,那就在渲染完成后用 exceljs 打开输出 Buffer,按实际行数重写公式,多一步但可控。
图片(比如公司 logo、二维码)直接插在模板里就行,引擎会把它当普通图形对象保留。但如果图片是动态的——比如每个客户一份带二维码的报表——那就不能靠模板,得在渲染后另行插入。多 Sheet是 ejsExcel 的加分项:模板里放三张表,一张封面、一张明细、一张汇总,各自写自己的标签,一次渲染全部出来,不用生成多个文件再合并。这也是我倾向用它的重要原因之一,很多同类库只支持单 sheet。
3. 数据层:喂给模板的数据该怎么组织
3.1 扁平化优先,别在模板里做数据加工
我在早期项目里犯过一个错:把后端的原始数据直接丢进模板,然后在单元格里写<%= order.customer.address.city %>这种深层取值,甚至写<%= items.filter(x => x.type === 'A').length %>。当时觉得挺优雅,模板自给自足。结果后来数据结构一变(比如address从对象变成数组),模板全线报错,而且报错信息指向的是某个单元格的表达式,排查起来非常难受。
现在的原则是:模板里只做取值和最简单的展示逻辑,所有筛选、排序、聚合、映射都在渲染前完成。数据层输出的对象结构应当尽量扁平、字段名语义化:
const viewModel = { meta: { title: '2024 年 5 月区域销售月报', generatedAt: '2024-06-01 09:30', owner: '数据中心', }, summary: { totalAmountText: '1,286,430.00', totalCountText: '1,842', avgRateText: '10.8%', }, details: [ { region: '华东', channel: '直营', amountText: '128,900.50', rateText: '13.2%' }, ], };这个结构的好处是:模板里全是<%= details[i].region %>这种一眼能懂的表达式,任何人接手都能看懂;单元测试也好写,直接断言 viewModel 的字段,不用去解 xlsx。
3.2 数字、日期、空值这三类最容易翻车
数字:Excel 会自动把形似数字的字符串转成数值。你输出"0123",打开变成123;输出"1E5",可能变成科学计数;输出长数字串(比如 18 位订单号),会被转成1.23457E+17,精度直接丢失。解决办法有两个:一是在数据层就把长编号转成带前缀的字符串(如NO.123456789012345678),二是把模板里对应单元格的格式设成"文本"。后者更彻底,但要注意:设成文本后,真正的数值列就不能这么干,否则排序和求和会失效。所以要按列区分对待。
日期:这是重灾区中的重灾区。"2024-05-01"、"2024/5/1"、"05/01/2024"在不同区域设置下解析结果完全不同,甚至可能月日颠倒。我现在的做法是:日期一律在数据层格式化成目标字符串(如"2024-05-01"),同时把单元格格式设成文本。如果业务方明确要求"能按日期排序筛选",那就输出 Date 对象或者 Excel 序列值,并在模板里把单元格格式设成日期格式,这一步不能省。
空值:null、undefined、""渲染出来的效果不一样,undefined在表达式里如果直接取属性会抛错。统一在数据层做兜底,把所有可能为空的值转成'-'或者空字符串:
const safe = (v, fallback = '-') => (v === null || v === undefined || v === '' ? fallback : v);一个小提醒:如果某列需要参与后续的求和或者透视,空值请给0或者真正的空单元格,不要给'-',否则那一列就变成文本列了。
3.3 大数据量下的内存与耗时控制
Excel 单 sheet 的理论上限是 104 万行,但 ejsExcel 是把整个工作簿读进内存、解析、重建、再序列化输出的,所以真正的瓶颈是内存而不是行数。我实测过,纯文本、10 列左右的明细,1 万行大概几百毫秒、内存峰值几十兆;到 10 万行时,内存会飙到几百兆并且 GC 压力明显,生成时间也到了十几秒量级。这个量级下如果服务是多进程的,几个请求并发就可能把容器打爆。
几个应对手段,按推荐顺序:一是分页拆文件,超过 5 万行就按业务维度拆成多个文件打包下载,用户其实也不愿意在一个 Excel 里翻十万行;二是拆 sheet,按区域或月份拆成多个 sheet,单个 sheet 行数降下来,主观体验反而更好;三是异步化,把渲染放进任务队列,前端拿到任务 ID 后轮询或者等通知,不要让 HTTP 请求一直挂着。最后才是考虑换流式写出的方案——但那就要放弃模板了,是另一套取舍。
3.4 把模板当成契约来管理
模板文件本质上是一份接口契约:字段名变了,模板就废了。所以模板不能随手扔在某个目录里靠记忆维护。我的做法是给它建一个独立目录,按业务分文件夹,文件名带上版本号或者生效日期:
templates/ sales/ monthly-report.v3.xlsx monthly-report.v3.schema.json配套一个 schema 文件,把模板需要的字段、类型、是否必填列清楚。渲染前用一层轻量校验(哪怕就是几个if (!data.details) throw)把明显不对的数据拦住,比让引擎在解析到第 200 个单元格时抛一个Cannot read property 'x' of undefined要友好得多。这套东西看起来有点啰嗦,但等到模板被别人改过、字段被上游悄悄改名的时候,你会庆幸有它。
4. 十个真正能提速的技巧
4.1 模板结构层面的四个技巧
技巧一:模板里不写 if,判断全部前置。这是收益最大的一条。模板里每出现一个<% if (...) { %>,就意味着多一段分支行,分支行在渲染时会因为条件不成立而被留空或者复制出多余行,样式也容易错位。把所有条件判断搬到数据层:要显示就给它值,不显示就给空字符串或者干脆不放进数组。模板永远只做"遍历 + 取值",直线逻辑,出问题的概率下降一大截。
技巧二:循环标签贴边放,别留缓冲行。前面提过一次,这里作为独立技巧强调。具体做法是:明细区的开始标签放在第一行数据的最后一列(或者单独占一列),结束标签紧跟在同一行的末尾,中间不要插入空行做"视觉分隔"。要分隔就用行高或者边框,不要用物理空行。我见过一个模板因为在循环中间插了一行空行做间隔,结果 200 条数据渲染出 200 个空行,文件体积翻倍。
技巧三:空数组一定要兜底。当details是空数组时,循环体会一次都不执行,原本明细区的位置会变成一片空白,紧接着下面的合计行就贴到表头下面了,看起来像是模板坏了。处理办法有两种:一是在数据层保证数组至少有一个占位元素(内容为"暂无数据",并且跨列合并),二是把"无数据"做成一个独立的固定行,通过数据层的标志位决定是否显示。第一种更简单,我一般用第一种,占位行用浅灰字,视觉上也不难看。
技巧四:一个业务一张模板,不要做万能模板。早期我喜欢搞一个"通用报表模板",靠传入不同的配置来切换表头、列数和 sheet 结构。结果模板里全是嵌套的 if 和动态列判断,改一次需求要动的地方特别多,而且每次渲染都要跑一大堆分支。后来彻底拆开:月度报表一个模板、季度报表一个模板、对账单一个模板,重复的部分靠共享同一份数据预处理函数来复用。模板文件多几个不占什么成本,但每个都很简单,改起来五分钟。
4.2 性能与调用层面的三个技巧
技巧五:模板 Buffer 缓存在进程内存里。renderExcel接受 Buffer,而读盘是同步 IO,在 QPS 稍微高一点的场景下会成为明显瓶颈。做法很简单,进程启动或者首次使用时读一次,放进 Map,后续直接用:
const templateCache = new Map(); function getTemplate(absPath) { if (!templateCache.has(absPath)) { templateCache.set(absPath, fs.readFileSync(absPath)); } return templateCache.get(absPath); }模板文件本身不大(通常几十到几百 KB),缓存几十个完全没压力。要注意的是模板更新后需要重启或者提供一个手动清理缓存的入口,否则会一直用旧版本——这是个很容易踩的坑,尤其是在测试环境调模板的时候,改了半天没生效,最后发现是缓存。
技巧六:并发要限流,不要 Promise.all 一把梭。生成 Excel 是 CPU 密集型操作,Promise.all开一百个并不会更快,只会把内存打满然后触发频繁 GC,整体耗时反而更长。一个简单可用的并发池就够:
async function runWithLimit(tasks, limit = 4) { const results = []; const executing = new Set(); for (const task of tasks) { const p = Promise.resolve().then(task); results.push(p); executing.add(p); const clean = () => executing.delete(p); p.then(clean, clean); if (executing.size >= limit) { await Promise.race(executing); } } return Promise.all(results); }并发数取多少,取决于容器的 CPU 核数和单个任务的内存占用。我一般设成Math.max(1, os.cpus().length - 1),内存紧张的环境再往下压。
技巧七:输出走流式下载,不要先落盘再读。拿到 Buffer 之后直接res.end(buf)就行,配合正确的响应头:
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}.xlsx"`); res.setHeader('Content-Length', buf.length); res.end(buf);文件名里有中文的时候,Content-Disposition必须编码,否则某些浏览器会显示乱码或者直接截断。这一步踩过坑的人应该不少。
4.3 调试与工程化的三个技巧
技巧八:准备一个"三格模板"做最小复现。遇到渲染结果不对,不要在原模板上改来改去——原模板几十个单元格,你根本不知道是哪个标签的问题。新建一个只有三个单元格的模板:一个输出、一对循环,先把最小场景跑通,再逐步把原模板的内容往里加,加到哪一步出错就定位到了。这个方法救过我很多次,尤其是样式相关的问题,最小模板能让你一眼看出是"引擎没复制样式"还是"模板本身样式没设"。
技巧九:把渲染结果的结构断言写进测试。不要用"打开看看对不对"来验收。测试里读回输出的 Buffer,用 exceljs 解析,断言关键单元格的值:
const wb = new ExcelJS.Workbook(); await wb.xlsx.load(outBuf); const ws = wb.getWorksheet('明细'); expect(ws.getCell('A5').value).toBe('华东'); expect(ws.getCell('D5').value).toBe('128,900.50'); expect(ws.rowCount).toBe(4 + details.length); // 表头3行 + 数据 + 合计rowCount那个断言特别有用,它能立刻发现"多了一行空行"这类肉眼容易忽略的问题。
技巧十:模板变更走版本号和灰度。模板改版不要直接覆盖线上文件。我的做法是文件名带版本号,配置里指定当前生效版本;新版本先在测试环境跑一批真实数据,比对关键字段,确认无误再切。切换保留旧版本一段时间,出问题能秒回滚。听起来是小题大做,但报表是对外交付物,一张金额错的表发出去,麻烦远大于多做的这点工作。
5. 常见问题与排查实录
5.1 报错速查表
下面这些是我在实际项目里反复遇到的,按出现频率排:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
Cannot read property 'xxx' of undefined | 模板引用的字段在 data 里不存在,或数组元素缺字段 | 检查 viewModel 结构,加空值兜底 |
| 渲染成功但打开提示"文件已损坏" | 输出时没有用fs.writeFileSync的二进制模式,或中途做了字符串转换 | 全程操作 Buffer,不要 toString |
| 明细行数量翻倍或出现大量空行 | 循环标签之间有额外空行被一起复制 | 收紧开始/结束标签的间距 |
| 样式全部丢失、变成白底黑字 | 模板被某些工具另存过,样式定义被重写 | 用 Excel 重新保存一次,或用已知可用的模板重做 |
| 中文表头正常,数据单元格乱码 | 通常是数据字符串本身编码问题,不是 xlsx 的问题 | 检查上游数据源编码 |
长数字变成1.23E+17 | Excel 自动类型转换 | 该列设为文本格式,或数据层加前缀 |
| 渲染很慢、内存飙高 | 数据量过大,或模板里有大量嵌套逻辑 | 分页拆文件、简化模板逻辑 |
5.2 为什么明明模板改了却没生效
最常见的原因是模板缓存没清。第二种情况更隐蔽:你改的是本地文件,但服务读的是打包进镜像或者部署目录的那一份。排查顺序建议是:先在代码里打印当前读取的模板绝对路径和文件大小,确认读的确实是你在改的那个文件;再检查有没有缓存层(进程内 Map、CDN、对象存储);最后确认模板文件确实被保存了——听着很蠢,但用在线表格编辑的时候忘点保存,我干过不止一次。
还有一种情况是改动本身生效了,但你看到的是浏览器缓存的旧文件。下载链接带上时间戳或者随机参数就能排除这个变量。
5.3 循环区域合并单元格的替代方案
前面说过循环区域内合并容易出问题。如果业务方坚持要有"按区域分组、区域名合并显示"的效果,有两套可行方案。
方案一是分组预处理:在数据层把明细按区域分组,每个分组的第一行带区域名,其余行留空,同时给每一行加一个isGroupStart标记。模板里用条件判断决定是否输出区域名,再用条件格式或者预留的边框做出视觉区隔。这样没有真正的合并单元格,但看起来是分组的。缺点是判断逻辑回到模板里了,跟我们前面说的原则有点冲突,属于必要妥协。
方案二是后置处理:模板先按普通行输出,渲染完成拿到 Buffer 后,用 exceljs 加载,根据数据中的分组边界计算合并区间,调用mergeCells做真正的合并。多一步处理,但逻辑完全在代码里,可控性强,也能复用。我现在的项目用的是方案二。
5.4 一个容易被忽略的细节:单元格文本格式
模板里写<%= x %>这种内容,如果单元格格式是"常规",通常没问题。但如果你在单元格里写了以=开头的内容(比如想输出一个公式片段),Excel 会把它当公式解析,存盘的时候直接报错或者变形。解决办法是把这些单元格提前设成文本格式,或者在编辑时用单引号前缀强制文本。
另外,标签里的花括号{}、百分号%、尖括号<>,在 Excel 里都是完全合法的文本字符,不需要转义。这一点比 HTML 模板省心,不用担心<之类的问题。唯一需要注意的是 Excel 的自动更正有时候会把连续的符号改成别的字符(比如自动转成箭头),如果发现标签被改了,去选项里把相关自动更正关掉。
6. 落到工程里的几点收尾建议
6.1 目录结构与职责划分
我目前项目里的组织方式是这样:templates/放模板和 schema,view/放数据组装函数(每个模板一个),render/放渲染调用和并发控制、缓存,routes/只负责参数校验和响应。核心约束是数据组装函数必须是纯函数,输入业务参数,输出 viewModel,不碰 IO。这样一来,模板要什么数据、数据从哪来,边界清清楚楚,改模板的时候只需要盯着对应的那个 view 函数。
这个分层还有个隐性收益:报表需求变更时,你能快速判断工作量。如果只是列顺序变了,改模板,五分钟;如果新增一列需要新的计算,改 view 函数,半小时;如果涉及新的数据源,那才是真正的工作量。把这三类变更区分开,排期的时候心里有数,也不会被"就加一列嘛"这种话带偏。
6.2 部署时的几个注意点
容器镜像里要确保模板文件被正确复制进去,而且路径用的是绝对路径或者基于__dirname拼接,不要用process.cwd()——后者在不同启动方式下结果不一样,是这类问题的经典来源。另外文件权限也要注意,以非 root 用户跑容器时,模板目录的可读权限要确认。
如果模板允许热更新(比如放在共享存储里),要处理好"读到一半文件被替换"的情况。简单做法是更新时先写临时文件再原子重命名,读的一侧就不会拿到半截内容。
还有一个实际会遇到的:字体。模板里用了某些中文字体,服务器上没装,渲染出来的文件在别的机器上打开时可能被替换成默认字体,导致列宽变化、换行位置不对。如果报表对版式要求严格,服务器上装一套常用中文字体,或者干脆在模板里用通用字体。
6.3 什么情况下我会放弃模板方案
说句实在话,模板引擎不是万能的。有两种场景我会绕开它:一是纯数据导出,用户拿到就要做透视分析的,直接给 xlsx 的裸数据表格(甚至 CSV),不需要任何样式,用流式写出更快更省内存;二是需要精确控制每一个单元格的程序化报表,比如带复杂图表、条件格式规则由数据动态决定的看板导出,这时候用 exceljs 直接构建反而更直接。
判断标准还是那句:版式是"画"出来的还是"算"出来的。画出来的,用模板;算出来的,用代码。混着用的项目里,我会把两类导出分成两条独立链路,不要试图用一套抽象统一它们,那样只会两边都不好用。
最后分享一个我自己养成的小习惯:每做一个新模板,先在模板文件旁边放一张"样例输出"的截图,标注这份输出是用哪份 mock 数据渲染的。半年后接手的人(包括未来的自己)打开目录,一眼就知道这个模板长什么样、需要什么数据,比读十行注释管用。这个习惯成本几乎为零,但省下来的沟通时间远超想象。