Electron小票打印实战:隐藏窗口+IPC实现静默打印热敏小票
2026/9/8 22:28:35 网站建设 项目流程

简介:面向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-builder

vue 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 传数据 + 隐藏窗口打印

先理清整体流程,一共五步:

  1. 渲染进程(vue 组件)点击“打印”按钮,把小票数据通过 ipcRenderer 发给主进程。
  2. 主进程收到消息,创建一个隐藏 BrowserWindow,加载模板页。
  3. 模板页加载完成后,主进程再通过 webContents.send 把订单数据推给模板页。
  4. 模板页拿到数据,渲染出小票样式,然后调用 window.print() 或者由主进程调用 webContents.print()。
  5. 打印结束后关闭隐藏窗口,把结果返回给渲染进程。

为什么不在创建窗口的同时用 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() 是异步的,连续调用相互覆盖维护一个打印队列,上一次回调后再发起下一次打印
打包后打开应用,打印页面 404publicPath 还是绝对路径 '/', 导致加载不到 print.htmlvue.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 隐藏窗口打印 → 回调结果”这条链路跑通,后面再丰富细节。小票打印这个需求,核心就一句话:你要打印的不是当前页面,而是一个专门定制的小票页面,控制好数据传递和打印参数,就成功了一大半。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询