ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Electron小票打印实战:隐藏窗口+IPC实现静默打印热敏小票

Electron小票打印实战:隐藏窗口+IPC实现静默打印热敏小票 简介面向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 字符串不仅又长又难读遇到特殊字符还得 encodedebug 的时候非常痛苦。用 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 charsetutf-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 idapp/div script const { ipcRenderer } require(electron) ipcRenderer.on(print-data, (event, data) { const app document.getElementById(app) let html div classheader div classtitle${data.title}/div div单号${data.orderNo}/div div${data.time}/div /div div classdivider/div data.items.forEach(item { html div classitem span classname${item.name}/span span${item.count}/span span${item.price}/span /div }) if (data.remark) { html div classdivider/divdiv classremark备注${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 里也可以直接把打印内容生成 PDFprintWindow.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 隐藏窗口打印 → 回调结果”这条链路跑通后面再丰富细节。小票打印这个需求核心就一句话你要打印的不是当前页面而是一个专门定制的小票页面控制好数据传递和打印参数就成功了一大半。本文还有配套的精品资源点击获取
返回列表