简介:面向Electron与Vue开发者的一套小票打印实例,基于Vue CLI 3搭建,解决桌面端票据打印时需要手动选择打印机与静默输出的问题,适合收银、订单小票等场景。资源共25个文件,含14个JS、3个Vue、2个JSON、2个HTML等,JS负责主进程逻辑与打印处理,Vue负责界面交互,JSON保存依赖与工程配置,整体仅117KB,结构精简便于快速阅读。已有3653人学习下载。代码清晰演示了用户点击打印→读取本地electron-store中的打印机名称→已设置则直接静默打印,未设置则弹出打印机设置框→确认后打印的完整链路,并考虑到了打印机信息的持久化存储。通过阅读工程中的main-process与plugins等模块,可以学习到Electron主进程与渲染进程如何协作,以及如何封装打印功能。对需要在自己的Electron项目中集成小票打印的开发者,是一份可直接参考和改写的轻量示例。 最近在做收银系统的小票打印功能,技术栈是 electron + vue cli3,整个过程踩了不少坑,也把整体方案跑通了。这篇把完整实现思路、关键代码和常见问题都整理出来,给正在做类似需求的同学一个直接能照抄的参考。
很多人一听到“打印小票”,第一反应是调 window.print() 打印当前页面,这在纯 web 项目里勉强能用,一旦跑到 electron 里做商用的收银小票,问题就来了:用户可能需要在无弹窗状态下静默打印、需要指定某个热敏打印机、需要打印内容和主界面完全分开,这些只用 window.print() 根本搞不定。所以更靠谱的做法是:主进程创建隐藏窗口加载一个独立的小票模板,通过 IPC 把订单数据传过去,然后调用 webContents.print() 定向输出。这套方案在 electron 里非常通用,下面展开讲。
1. 项目整体思路与方案拆解
1.1 小票打印的本质:打印一个独立窗口,而不是当前页面
小票打印和普通文档打印有个本质区别:小票内容通常很窄,且要精确切纸、控制行高,和后台管理界面完全是两套布局。如果你直接把订单页或者管理后台整个打印出来,体验会非常差。
正确的思路是单独准备一个“小票模板页面”,里面不包含任何业务导航、弹窗、侧边栏,只有小票内容本身。然后在需要打印时,electron 主进程创建一个隐藏的 BrowserWindow,把这个模板页面加载进去,传入订单数据,等页面渲染完成后再触发打印。打印结束后这个隐藏窗口就可以销毁了,完全不影响用户正在操作的主窗口。
1.2 为什么不用纯 web 方案,而是 electron 原生能力
纯 web 页面确实也能打印小票,但有几个很现实的限制:
- 无法静默打印:浏览器出于安全考虑,window.print() 会强制弹出系统打印预览框。在餐饮、零售场景里,服务员只需要点一下“下单并打印”,系统就应该直接把后厨小票打出来,不允许人工确认环节。
- 无法精确指定打印机:web 端拿不到操作系统的打印机列表,用户只能手动选,这在多打印机门店(前台小票、后厨小票、外卖小票)里完全不可用。
- 样式兼容性差:不同浏览器对 @page、毫米单位、打印缩放的处理不一样,明明开发时看着没问题,换台电脑可能就错位了。
electron 通过 webContents.print() 提供了原生打印能力,支持 silent 参数来实现静默打印,支持 deviceName 指定具体打印机,这些都是商用小票场景的刚需。
1.3 两条技术路线对比:直接打印 vs 隐藏窗口打印
我先列一下两条路子的对比,后面会重点讲推荐方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 方案A:渲染进程直接打印 | 当前页面 window.print() | 实现简单,代码量少 | 无法静默;打印内容会带着后台界面;主进程拿不到打印状态 |
| 方案B:主进程隐藏窗口打印 | 新建 BrowserWindow 加载模板页,IPC 传数据,webContents.print() | 可静默、可指定打印机、打印内容干净、能拿到回调结果 | 代码相对复杂,需要管理隐藏窗口的生命周期 |
实际项目中,方案B才是正确选择,demo 里也是围绕方案B来实现。
2. 环境搭建与工程化准备
2.1 用 vue cli3 初始化项目并接入 electron
如果你还没搭好项目,直接按下面的命令走一遍:
vue create print-demo # 选择默认的 babel + router 即可,路由实际上用不太到,但保留无妨 cd print-demo vue add electron-buildervue add electron-builder 会自动帮你生成 electron 相关配置,包括 background.js(主进程入口)、package.json 里的 electron:serve 和 electron:build 脚本。这个插件的好处是开发环境支持热更新,主进程改了代码会自动重启 electron,不用手动重启。
启动开发环境:
npm run electron:serve第一次启动会下载 electron 二进制,国内网络可能比较慢,耐心等一会儿就行。
2.2 必须处理的配置坑:publicPath、nodeIntegration、contextIsolation
这三个配置项如果你不处理,后面会遇到各种莫名其妙的问题。
第一,publicPath 必须改成相对路径。vue cli 默认把 publicPath 设为 '/',在浏览器里没问题,但 electron 加载本地文件时,资源路径会变成类似 file:///C:/xxx/dist/index.html 这种形式,如果页面里引用 /js/app.js,它会去解析 file:///js/app.js,直接 404。所以 vue.config.js 里加上:
module.exports = { publicPath: './', // ... 其他配置 }第二,主进程创建窗口时,webPreferences 要手动开启 nodeIntegration。electron 5 之后默认禁用了渲染进程的 Node 能力,导致在 vue 组件里用不了 require、process 这些 API。如果你想在渲染进程里直接用 ipcRenderer,需要这样配置:
const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { nodeIntegration: true, contextIsolation: false } })注意:这个配置仅建议在 demo 或内部系统中使用。如果应用要面向公网用户,建议用 preload 脚本 + contextBridge 暴露最小 API,而不是完全放开 Node 集成。但从“能跑通”的角度来说,demo 先这么写,后续再收安全边界。
第三,background.js 和 main.js 的职责要分开。background.js 是 electron 主进程入口,负责创建窗口、监听 IPC、调用系统能力;main.js 是 vue 的挂载入口,只负责启动前端应用。这两者不要混在一起,否则后面维护起来脑壳疼。
3. 打印小票的完整实现
3.1 小票数据结构与模板页设计
先约定一份小票数据,这是后厨小票最常见的结构:
const receiptData = { title: '幸福小龙虾(总店)', orderNo: '202406070001', time: '2024-06-07 12:30:22', items: [ { name: '蒜蓉小龙虾(大份)', count: 1, price: 168 }, { name: '招牌毛豆', count: 2, price: 18 }, { name: '冰镇酸梅汤', count: 2, price: 12 } ], remark: '微辣,不要香菜', deviceName: 'XP-80C' }模板页我用一个独立的 HTML 文件,放在 public 目录下,避免和 vue 的组件体系耦合太深。原因很简单:小票模板不涉及复杂交互,用原生 HTML + 内联样式最稳,打印样式不容易被前端框架干扰。
3.2 核心思路:IPC 传数据 + 隐藏窗口打印
先理清整体流程,一共五步:
- 渲染进程(vue 组件)点击“打印”按钮,把小票数据通过 ipcRenderer 发给主进程。
- 主进程收到消息,创建一个隐藏 BrowserWindow,加载模板页。
- 模板页加载完成后,主进程再通过 webContents.send 把订单数据推给模板页。
- 模板页拿到数据,渲染出小票样式,然后调用 window.print() 或者由主进程调用 webContents.print()。
- 打印结束后关闭隐藏窗口,把结果返回给渲染进程。
为什么不在创建窗口的同时用 query 参数直接带数据?因为小票数据里可能有订单明细数组、备注文案,转成 query 字符串不仅又长又难读,遇到特殊字符还得 encode,debug 的时候非常痛苦。用 IPC 传对象是最干净的方式。
3.3 主进程打印核心代码
下面是 background.js 里打印小票的核心逻辑:
const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') let printWindow = null ipcMain.on('print-receipt', (event, payload) => { // 如果之前有打印窗口没关,先关掉,避免内存泄漏 if (printWindow) { printWindow.destroy() printWindow = null } printWindow = new BrowserWindow({ width: 400, height: 800, show: false, // 隐藏窗口 webPreferences: { nodeIntegration: true, contextIsolation: false } }) // 加载模板页 printWindow.loadFile(path.join(__dirname, 'public/print.html')) printWindow.webContents.on('did-finish-load', () => { // 1. 把数据传给模板页 printWindow.webContents.send('print-data', payload) // 2. 触发打印 printWindow.webContents.print( { silent: true, // 静默打印,不弹系统预览框 printBackground: true, // 打印背景色,否则深色块会丢失 deviceName: payload.deviceName // 指定打印机,不传则用默认打印机 }, (success, failureReason) => { console.log('打印结果:', success, failureReason) if (printWindow) { printWindow.destroy() printWindow = null } event.sender.send('print-result', { success, failureReason }) } ) }) })有两点必须提醒:
webContents.print() 的回调触发时机比想象中晚。它是打印任务真正开始执行后才回调,不是点击打印按钮立刻回调。在连续打印两张小票的场景里,如果你不等待上一次回调就发起第二次打印,很可能会丢失任务。后面第5部分会讲怎么做打印队列。
deviceName 参数在 Windows 上要传打印机全名。比如“XP-80C (复制 2)”,传错名字不会直接报错,而是静默失败,表现为打印任务消失了、什么都没打出来。建议先手动打印一次,或者在代码里临时去掉 silent 看系统预览框里显示的打印机名称是什么。
3.4 模板页实现
模板页 print.html 是这样的:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> @page { size: 80mm auto; /* 宽度80mm,高度自适应 */ margin: 0; } * { margin: 0; padding: 0; box-sizing: border-box; } body { width: 80mm; font-family: '宋体', monospace; font-size: 12px; padding: 10px 8px; } .header { text-align: center; margin-bottom: 8px; } .header .title { font-size: 16px; font-weight: bold; } .divider { border-top: 1px dashed #000; margin: 6px 0; } .row { display: flex; justify-content: space-between; margin-bottom: 2px; } .item { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 12px; } .item .name { width: 40mm; word-break: break-all; } .remark { margin-top: 6px; } </style> </head> <body> <div id="app"></div> <script> const { ipcRenderer } = require('electron') ipcRenderer.on('print-data', (event, data) => { const app = document.getElementById('app') let html = ` <div class="header"> <div class="title">${data.title}</div> <div>单号:${data.orderNo}</div> <div>${data.time}</div> </div> <div class="divider"></div> ` data.items.forEach(item => { html += ` <div class="item"> <span class="name">${item.name}</span> <span>${item.count}</span> <span>${item.price}</span> </div> ` }) if (data.remark) { html += `<div class="divider"></div><div class="remark">备注:${data.remark}</div>` } app.innerHTML = html // 等DOM渲染完成后,自动触发打印 setTimeout(() => { window.print() }, 200) }) </script> </body> </html>注意这里用的是 window.print() 而不是 webContents.print(),因为模板页本身就是一个小票页面,直接打印自己就对了。同时我在渲染后加了 200ms 的延时,确保 DOM 完全更新完毕,否则偶尔会出现打印白屏。这个延时纯粹是经验值,如果你要打印的数据量很大,可以改用 requestAnimationFrame 或者轮询判断。
4. 小票样式适配与热敏纸细节
4.1 80mm / 58mm 热敏纸的样式写法
热敏小票打印机一般有 80mm 和 58mm 两个常见规格,不管哪种,@page 的 size 都要和纸张宽度保持一致。比如 80mm 热敏纸实际可打印区域通常只有 72mm 左右,所以页面宽度会设置成 80mm,但 padding 左右各 4mm,内容区正好 72mm。
字体大小方面,实体字建议用 12px 或 14px,重点内容(店铺名、总金额)用 16px 加粗。小票不像手机页面有各种缩放适配,它就是一张固定宽度的纸,灵活适配反而会导致边界溢出,用固定像素 + 固定毫米反而是最稳的。
经验:千万不要在小票模板里使用 rem 或者百分比宽度,不同系统下的默认字号会把你坑惨。开发的时候用 fixed width + px 单位,打印出来是什么样就是什么样。
4.2 分页控制和切纸
如果订单明细特别长(比如一个订单有几十个商品),小票会自动分页继续打印,这时如果不控制分页,商品明细可能被硬生生切断,影响阅读。给每个明细项加上:
.item { page-break-inside: avoid; }另外,在明细结束、金额汇总开始之前,加一个分页保护:
.summary { page-break-before: avoid; }这样能防止“金额合计”被打到上一页底部,而明细还在下一页这种诡异情况。
收银小票的底部通常会留一些空白,方便撕纸。做法很简单,在页面最后加一个高度约为 20mm 的空白 div:
.print-tail { height: 20mm; }不同型号的切纸刀位置不一样,20mm 是经验值,实测了多款蓝牙 / USB 热敏打印机,基本都是够的。
4.3 调试技巧:先输出 PDF,后连真机
开发小票打印的过程中,如果每次都拿热敏纸测试,一方面浪费纸,另一方面调试节奏特别慢。更高效的做法是先把打印目标改成“另存为 PDF”,在系统打印对话框里选择 Microsoft Print to PDF,快速确认排版对不对。
electron 里也可以直接把打印内容生成 PDF:
printWindow.webContents.printToPDF({ pageSize: 'A4' }).then(data => { fs.writeFileSync('receipt.pdf', data) })不过说实话,printToPDF 的默认 pageSize 和你小票的 80mm 不一致,经常出现内容被缩放的问题。我的习惯是第一版先打开非静默打印,用系统打印对话框里的 PDF 打印机验证一次,然后直接真机测,因为热敏纸的物理宽度和打印效果只有真机才准。
5. 常见问题排查与避坑记录
这部分是整篇博客含金量最高的地方,我把实际开发中遇到的典型问题整理成了表格,方便直接对照排查:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 点打印后完全没反应,控制台无报错 | deviceName 传了错误的打印机名称 | 去掉 silent 打印一次,看系统弹窗里的真实打印机名称;用webContents.getPrintersAsync()枚举打印机列表 |
| 打印出来了,但背景色全是白的 | 没设置 printBackground: true | 在 webContents.print() 参数里加上 printBackground: true |
| 打印内容特别小,像缩略图 | @page size 设置错误,页面没有跟随纸张宽度 | 检查 @page 的 size 是否和小票纸宽度一致,body 宽度也同步设置 |
| 打印白纸,但不是每次必现 | 数据发送和 DOM 渲染存在时序问题 | 在模板页收到数据后使用 setTimeout 200ms 再打印,保证 DOM 更新完毕 |
| 有时打印前一张刚到一半,后一张任务就丢了 | webContents.print() 是异步的,连续调用相互覆盖 | 维护一个打印队列,上一次回调后再发起下一次打印 |
| 打包后打开应用,打印页面 404 | publicPath 还是绝对路径 '/', 导致加载不到 print.html | vue.config.js 设置 publicPath: './',模板文件加载用 path.join(__dirname, ...) |
| 小票里的 logo 图片打印不出来 | 网络图片加载需要时间,或者 file:// 下跨域被拦截 | 图片转 base64 内联,或者等图片 onload 后再打印 |
5.1 静默打印失效,总是弹出打印预览框
这是很多人遇到的第一个坑。silent: true 表示不弹打印预览框,直接发送到指定打印机,但有两个前提:
第一,deviceName 必须能匹配到系统打印机。如果找不到匹配设备,electron 会 fallback 到默认打印机,同时有些 Windows 版本会弹框提示。第二,silent 在某些 Linux 桌面环境下不生效,这是底层打印协议的限制,不只是 electron 的问题。遇到这种情况,先在系统打印设置里把目标打印机设为默认打印机,再让 electron 的 deviceName 和默认打印机保持一致,能规避大部分问题。
5.2 连续打印时任务丢失
餐饮场景下非常常见:用户同时下了两个订单,需要连续打印两张小票。如果你直接在处理打印的 IPC handler 里连续创建两个隐藏窗口并分别调用 print(),大概率第二张会静默失败。
原因在于 electron 的打印任务是异步的,而且内部分享同一个打印任务队列。你连续触发两次,后一次的调用可能会把前一次的覆盖掉。
我的解决方案是做一个极简的打印队列:
let printing = false const queue = [] function enqueuePrint(payload, event) { queue.push({ payload, event }) processQueue() } function processQueue() { if (printing || queue.length === 0) return const { payload, event } = queue.shift() printing = true // 走前面说的创建隐藏窗口 + 打印逻辑 actualPrint(payload, event, (result) => { printing = false processQueue() // 处理下一单 }) }核心思路就一句话:同一时间只允许一个打印任务在跑,上一个任务回调结束之后再处理下一个。
5.3 定制纸张不生效,内容被截断
如果你试过在 webContents.print() 的参数里传 pageSize、margins 之类的配置,会发现它在某些场景下根本不管用。electron 的页面尺寸优先级是这样的:CSS @page 规则 > 系统打印机默认设置 > webContents.print() 参数。所以想让小票适配纸张,正确姿势是写好 @page 的 size,而不是依赖 electron 参数。
@page { size: 80mm auto; margin: 0; }这个 size 里,80mm 是固定宽度,auto 表示高度根据内容自动扩展。如果打印机驱动的裁剪设置没问题,这样的写法能让热敏纸刚好打印完整个内容,然后自动切纸。
6. 从 demo 到上线:几点实战经验和后续扩展
6.1 打印机枚举是必需的
demo 里 deviceName 是写死的,但真实项目里打印机列表需要动态获取。electron 主进程提供了webContents.getPrintersAsync(),可以枚举出当前系统所有打印机,包括名称、状态、是否默认打印机、是否支持彩色等字段。
在设置页面做一个下拉框,把打印机列表展示给用户选择,选中的名称存到本地配置里。这样不同门店、不同型号的打印机都能适配,而不是每次改代码。
6.2 小票模板可以做成可配置的
很多商家的小票排版要求不一样,有的是店铺信息在顶部,有的是订单明细在中间,有的要在底部打印二维码。这个 demo 里我是写死模板的,但你可以把模板抽离成 JSON schema,比如配置哪些字段显示、字段顺序、是否包含二维码,然后渲染页面根据配置动态拼接 HTML。技术难度不大,但对产品来说价值很高,因为同一个安装包可以卖给不同需求的商家。
6.3 考虑打印结果的状态回传
之前代码里已经用了 event.sender.send('print-result', ...) 把结果回传给渲染进程,这就是一个很好的基础。你可以在这个基础上扩展:打印失败时,前端弹出重试按钮;打印超时(比如10秒没回调),自动标记为异常,方便排查故障打印机。
我在实际项目里踩过最大的坑,反而是“以为打印成功了,结果纸卷没装好”。后来我们会在打印钱检查打印机状态,用 webContents.getPrintersAsync() 找到目标打印机后判断它的 status,如果 status 不是 0(就绪状态),直接在前端提示“打印机未就绪,请检查电源/纸卷”,而不是让用户点了按钮之后摸不着头脑。
6.4 关于跨平台分发的注意点
electron 应用打包之后,在不同 Windows 和 macOS 机器上的表现会有差异,尤其是打印机驱动这块。建议在打包时把打印机型号相关的配置文件外置到用户目录,不要打包在 asar 里。因为不同用户机器上的打印机名称不一样,配置文件如果锁死,用户没法自行修改。Windows 下安装的打印机驱动种类五花八门,同一个型号也可能被识别成两个名字,提前做好配置界面能省掉不少售后维护的麻烦。
最后,这个 demo 的方案我从头到尾跑通过,简单场景完全够用。如果你想快速上手,先别急着封装各种高级功能,把“vue 点击按钮发数据 → electron 隐藏窗口打印 → 回调结果”这条链路跑通,后面再丰富细节。小票打印这个需求,核心就一句话:你要打印的不是当前页面,而是一个专门定制的小票页面,控制好数据传递和打印参数,就成功了一大半。
本文还有配套的精品资源,点击获取