后台系统做到一定阶段,打印一定会变成最脏的活。
业务最初说"能打印就行",上线三个月后需求变成:表头要在每页重复、这张单要套打、批量打 200 张、不要弹框、能不能让业务自己改模板。等你把这些都补上,代码里已经堆了三千行排版逻辑,还没人敢动。
问题往往不在实现,而在第一步:方案选错了。
项目地址:https://gitee.com/theGreatOldFive/vue-print-designer
一、四条路线的本质区别
先说结论:这四条路线的差异,不在"能不能打出纸",而在排版逻辑放在哪、模板归谁维护。
配图1:四条打印路线的取舍
| window.print | 浏览器排版引擎 | CSS | 前端 | 0.5-2 人天 |
| html2canvas + jsPDF | 前端 canvas | 手动切片 | 前端 | 2-5 人天 |
| Puppeteer | 服务端 Chromium | 引擎控制 | 前端 + 后端 | 5-10 人天 |
| 模板设计器 | 渲染引擎 | 模板内置 | 业务自己 | 3-8 人天 |
成本不在开发,在变更。前三条路线,业务每改一次版式,都要走一遍"需求 → 开发 → 测试 → 发版";第四条只改配置。一年改 10 次模板,前三条累计要吃掉 20-40 人天。
二、window.print:能走多远
最短路径,也是最适合内部单据的一条。
@media print {
@page {
size: A4;
margin: 12mm 10mm;
}
/* 表头每页重复 */
thead { display: table-header-group; }
/* 行不要被劈开 */
tr { break-inside: avoid; page-break-inside: avoid; }
/* 交互元素不打印 */
.no-print, .toolbar, .pagination { display: none !important; }
}
它的边界很清楚:
- 必须弹打印对话框,用户要自己选打印机、点确认;
- 边距受浏览器影响,不同浏览器默认页边距不一致;
- 套打基本不可行,毫米级定位会被浏览器默认边距吃掉;
- 静默打印做不到,这是浏览器安全模型决定的。
所以它适合:内部审批单、对账单、报表——用户点一下、选打印机、确认,这个流程可以接受。
如果业务只是"把页面打出来",这条路能覆盖 60% 的需求,别过度设计。
三、截图方案:一页以内才划算
用户要的不是"打印",而是"下载一个 PDF 文件"时,html2canvas + jsPDF 最快。
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
async function exportPdf(el, filename) {
const canvas = await html2canvas(el, {
scale: 2, // 关键:至少 2 倍,否则模糊
useCORS: true, // 跨域图片
backgroundColor: '#ffffff',
logging: false,
})
const pdf = new jsPDF('p', 'mm', 'a4')
const pageW = 210
const imgH = (canvas.height * pageW) / canvas.width
pdf.addImage(canvas.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, pageW, imgH)
pdf.save(filename)
}
三个硬伤必须提前知道:
结论:一页以内能用,超过两页就该换路。它最适合电子回单、短凭证这类"打完就存档、不会二次编辑"的场景。
四、服务端渲染:最准,也最重
Puppeteer 出 PDF 的好处,是分页、页码、页眉页脚全部交给 Chromium 的排版引擎:
const browser = await puppeteer.launch({ args: ['–no-sandbox'] })
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle0' })
await page.evaluate(() => document.fonts.ready)
await page.pdf({
path: 'out.pdf',
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<div style="font-size:9px;width:100%;text-align:center">合同编号 {{no}}</div>',
footerTemplate:
'<div style="font-size:9px;width:100%;text-align:center">' +
'第 <span class="pageNumber"></span> / <span class="totalPages"></span> 页</div>',
})
await browser.close()
代价也很实在:
- 服务器要装 Chromium,镜像 400MB 起步;
- 并发高时内存吃紧,一台 2C4G 的机器同时跑 5 个渲染任务就开始排队;
- 前端还要额外维护一套"给后端用的 HTML",和页面样式容易漂移;
- 调试链路长:改一个像素,要重新部署才能看到。
适合:医院报告、检验单、对账单这类必须归档、分页严格的场景。
五、模板设计器:把排版从代码里拿出来
前三条路线的共同点是:模板写死在代码里。
业务想加一列规格、换一下公司抬头、把表头字体改大,都要提需求、排期、发版、回归测试。一个中型 ERP,这类需求一年能有几十条。
模板设计器的思路是把"排版"抽出来:业务在页面上拖字段、设字号、画表格线,导出 JSON;渲染时用同一套引擎填数据、算分页、出纸。
{
"schemaVersion": 3,
"paper": { "width": 210, "height": 297, "unit": "mm" },
"bands": [
{ "type": "pageHeader", "height": 25, "repeat": true },
{ "type": "detail", "height": 8, "repeat": true, "source": "items" },
{ "type": "summary", "height": 40, "repeat": false }
],
"elements": [
{ "type": "text", "x": 10, "y": 8, "w": 80, "h": 8, "binding": "title" },
{ "type": "barcode", "x": 8, "y": 220, "w": 84, "h": 20, "binding": "orderNo" }
]
}
关键要求只有一条:设计态和打印态共用一套渲染。如果设计器用一套 DOM、打印用另一套,设计稿和成品永远差两毫米,用户会觉得"这工具不靠谱"。
配图2:模板市场里现成的单据模板
这套结构我参考的是 vue-print-designer 的实现:模板 JSON + 统一渲染器 + 带(band)分页,三块都开源,优点是可以照着它的字段设计自己复刻一个渲染器,不用担心被锁死。
六、六个维度横向对比
| 输出质量 | 矢量 | 位图 | 矢量 | 矢量 |
| 分页可控性 | 中(靠 CSS) | 差 | 高 | 高 |
| 静默打印 | 不支持 | 不支持 | 需配合客户端 | 需配合客户端 |
| 套打精度 | 低 | 低 | 中 | 高(mm 定位) |
| 模板维护 | 前端 | 前端 | 前端+后端 | 业务自助 |
| 运维成本 | 无 | 低 | 高(Chromium) | 低 |
七、选型决策流程
按顺序问四个问题,问完基本就定了:
四个问题里只要有一个落在"设计器"那侧,就说明你的系统已经进入"打印是长期成本"的阶段了。
八、从 window.print 迁移到设计器的路径
很多团队不是从零开始,而是已经有一堆 @media print 的样式。迁移可以分三步,不用推倒重来:
第一步:把版式数据化。 把现在写死的表格列、字段,抽成一份配置(先不用做成可视化设计器)。
第二步:统一渲染入口。 所有打印走同一个 <PrintRenderer :template :data>,先只支持"配置渲染",不支持拖拽编辑。
第三步:接设计器。 业务方开始自己改模板,前端只维护渲染器。
这三步每步都能独立上线,风险可控。跳过前两步直接上设计器,通常会在"老模板怎么兼容"上卡住。
九、几个容易忽略的细节
配图3:热敏小票——最常见的打印形态之一
十、一张决策表收尾
| 内部审批单,模板不改 | window.print | 半天干完,别过度设计 |
| 要下载 PDF,一页内 | html2canvas + jsPDF | 前端自足,不用服务端 |
| 必须归档,分页严格 | Puppeteer | 分页页码最稳 |
| 模板由业务维护 | 模板设计器 | 改版式不发版 |
| 既要套打又要多联 | 设计器 + mm 定位 | 手写坐标维护不动 |
选型的关键不是技术先进,而是你愿意为"模板变更"付多少成本。前期省下的接入时间,后期会以排期的形式还回去。




