首页 / 资讯中心 / 文章详情

Electron+Vue实现小票打印:从方案选型到避坑实践全解析

Electron+Vue实现小票打印:从方案选型到避坑实践全解析 ★ FEATURED ARTICLE
简介面向 Electron 桌面应用开发者这份代码演示了如何用 Electron Vue CLI 3 实现小票打印用户点击打印后程序从 electron-store 读取本地打印机配置未设置时弹出设置窗口确认后即可静默打印。压缩包共 25 个文件以 JavaScript 逻辑和 Vue 组件为主包含 14 个 js、3 个 vue、2 个 html 模板及 2 个 json 配置压缩后仅 117KB结构精简便于快速浏览。项目内含完整源码、主进程处理、打印页面及 ESLint 等配置可帮助开发者理解 Electron 主进程与渲染进程协作、本地持久化存储和打印流程设计稍作改造即可接入实际业务。目前已有 3660 人学习/下载适合希望基于 Electron 实现打印功能的中高级前端开发者参考。1. 从「打印空白」到「单票吻合」electronvuecli3 实现小票打印到底在做什么做收银类桌面应用的人迟早会被小票打印逼疯。浏览器里 window.print 打出来的是 A4 逻辑到了 58mm 热敏纸上要么内容被截断、要么字体大得离谱改用 lodop 控件又绕不开安装注册的坑。这个 electron-vue-print-demo 解决的就是这么一件事用 Electron 主进程直接驱动系统打印机渲染进程只负责排版本地小票页面整套流程收在 Vue CLI 3 工程里。对正在用 Electron 做门店收银、餐饮点单、会员开卡这类场景的从业者来说这套方案能让你从「能打印」进化到「打得对、打得稳」不再需要用户手动去弹窗里选打印机。我第一次接手类似需求时团队直接调webContents.print()结果在 XP 老收银机上各种翻车。后来把打印参数、纸张类型、静默模式拆开调才真正稳住。这篇文章我会先把 Electron 打印的几种方案讲明白再给你一套可以直接套用的 Vue CLI 3 项目骨架最后把小票排版、避坑和队列控制全部交代清楚。2. 方案选型为什么是 Electron 主进程打印而不是浏览器打印或第三方控件2.1 三条技术路线的取舍window.print、第三方控件、Electron 原生打印先说结论Electron 应用里做小票打印优先走主进程的webContents.print()不要走渲染进程的window.print()。原因很简单window.print()在 Electron 里会触发 Chromium 的打印预览用户必须手动点确认而且打印结果受页面 CSS 影响极大页边距稍微没控制好58mm 纸上的内容就错位。第三方打印控件比如某些 ActiveX 方案在装驱动、注册组件、权限配置上成本太高门店运维根本hold不住。Electron 主进程打印的核心优势在于你可以通过webContents.print(options, callback)直接指定打印机、纸张尺寸、是否静默、打印份数整个过程不需要用户介入非常适合收银台这种高频、快速出票的场景。它的本质是调用 Chromium 的打印后端再转交给操作系统打印机驱动所以打印机装好驱动、设置成默认设备之后应用层只需要关心参数和排版。2.2 Vue CLI 3 项目骨架从插件安装到主进程入口我习惯用vue-cli-plugin-electron-builder把 Vue CLI 3 和 Electron 接起来。这个插件会把 Electron 的主进程、预加载脚本和渲染进程的构建串成一套命令开发时一条npm run electron:serve就能起热更新比手动维护两套 webpack 配置省事得多。# 在已有的 Vue CLI 3 项目里安装插件假设你已经在项目根目录 vue add electron-builder # 安装完成后package.json 里会自动多出这些 scripts # electron:serve —— 开发模式起 Electron 并加载本地 dev server # electron:build —— 打包出安装程序安装完以后项目根目录会出现background.js也可能是electron/main.js取决于插件版本这就是 Electron 主进程入口。这里有一个关键改动在插件默认生成的app.whenReady()回调里我们先注册好后面要用的 IPC 监听器再创建BrowserWindow。顺序不能反否则渲染进程在加载页面时触发 IPC 调用主进程还没监听事件就丢了。// background.js 的核心结构节选 const { app, BrowserWindow, ipcMain } require(electron) const path require(path) let mainWindow null function createWindow() { mainWindow new BrowserWindow({ width: 1280, height: 800, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, preload.js) } }) // 开发模式下加载 dev server 地址生产模式加载构建后的文件 if (process.env.WEBPACK_DEV_SERVER_URL) { mainWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL) } else { mainWindow.loadFile(path.join(__dirname, index.html)) } } app.whenReady().then(() { registerPrintIpc() // 注册打印相关的 IPC 监听 createWindow() })contextIsolation: true这一步不要为了省事关掉。渲染进程里跑着 Vue 代码如果不做隔离任何 XSS 漏洞都可能直接拿到 Node 权限小票打印这类门店系统往往还连着钱箱、扫码枪风险太高。我们通过preload.js暴露一个白名单 API 给渲染进程调用职责单一也方便后续加权限控制。// preload.js const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(printAPI, { // 渲染进程调用这个方法把小票内容交给主进程去打印 printReceipt: (payload) ipcRenderer.invoke(print:receipt, payload) })到这里项目骨架已经通了渲染进程通过window.printAPI.printReceipt()传数据主进程监听print:receipt执行真正的打印。中间这一层 IPC 通信就是整个方案的核心动脉我在下一章拆开讲。3. 核心链路渲染进程排版、IPC 传数据、主进程打印3.1 小票页面怎么组织单独窗口还是隐藏窗口很多第一次做的人会把小票内容直接放在主页面里打印时把整个窗口拿去 alert 打。这是误解。正确做法是单独开一个无边框的隐藏BrowserWindow专门用来加载小票模板打印时往这个窗口里注入数据。这样主界面不会被打印逻辑污染也不会因为切窗口出现白屏闪烁。// 主进程维护一个小票专门窗口 let printWindow null function getPrintWindow() { if (printWindow !printWindow.isDestroyed()) return printWindow printWindow new BrowserWindow({ show: false, // 永远不显示 width: 400, height: 600, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, preload.js) } }) printWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL /#/print) // 指向模板路由 return printWindow }关键点窗口是show: false但加载完成后webContents是可用的。这种「隐式窗口」方案的好处是模板页面常驻内存第二次打印时不用重新加载速度会快很多对高频收银场景帮助明显。代价是占一点内存大概 2030MB门店机器能接受。3.2 IPC 通信和数据注入invoke 和 handle 怎么配合数据从 Vue 组件传到打印窗口有三种常见做法全局变量、query 参数、IPC。全局变量在contextIsolation开启后不好使query 参数只适合短数据小票内容一长就受 URL 长度限制IPC 是最干净的做法。// 主进程注册打印监听注册逻辑写在一个函数里便于维护 ipcMain.handle(print:receipt, async (event, payload) { const win getPrintWindow() // 等打印窗口的页面加载完成再传数据 await didFinishLoad(win) // 通过 webContents.send 把数据推给打印窗口 win.webContents.send(print:data, payload) // 这里先返回一个占位值真正的打印结果由打印窗口回调再上报 return { status: sent } }) // 封装一个等待加载完成的 Promise function didFinishLoad(win) { return new Promise((resolve) { if (win.webContents.isLoading()) { win.webContents.once(did-finish-load, resolve) } else { resolve() } }) }渲染进程这边打印路由的 Vue 组件里监听print:data拿到数据后填进模板再触发打印。这里我会用一个watch来监听数据到达避免时序问题。// 打印窗口内的 Vue 组件/src/views/Print.vue 节选 export default { data() { return { receipt: null, items: [] } }, created() { // 监听主进程推来的数据 window.printAPI.onPrintData((payload) { this.receipt payload.receipt this.items payload.items this.$nextTick(() { this.doPrint() }) }) }, methods: { doPrint() { // 调主进程开始打印这里传入模板已经渲染好的 webContents window.printAPI.startPrint() } } }preload.js里要补上onPrintData的桥接方法。注意ipcRenderer.on的监听器要返回一个取消函数否则组件销毁后监听器还在内存会泄漏。// preload.js 补充 onPrintData: (callback) { const listener (_event, data) callback(data) ipcRenderer.on(print:data, listener) return () ipcRenderer.removeListener(print:data, listener) }3.3 真正执行打印webContents.print 的参数逐个说清楚数据注入完成后最后一个动作由主进程执行webContents.print()。这里参数基本就决定了小票的成败// 主进程打印执行函数 ipcMain.on(print:start, (event) { const win getPrintWindow() win.webContents.print({ silent: true, // 静默打印不弹确认框 printBackground: true, // 打印背景色小票底色场景需要 deviceName: printerName, // 目标打印机名称可从系统获取 margins: { marginType: none }, // 零边距小票纸不能有页边空白 pageSize: { width: 58000, height: 200000 } // 纸张尺寸单位微米 }, (success, failureReason) { if (!success) { console.error(打印失败:, failureReason) } }) })参数里有几个坑要解释。silent: true是必须的收银场景不能让店员每次点确认margins.marginType: none配合 CSS 里的page { margin: 0 }这是去掉白边的双保险pageSize的单位是微米以 58mm 热敏纸为例宽度 58mm 58000 微米高度不能写死因为小票长度是动态的。对于长度我的做法是在渲染进程里测量内容高度后通过 IPC 回传主进程再动态设置。// 渲染进程在 nextTick 之后测量内容高度单位px const body document.getElementById(receipt-body) const heightPx body.offsetHeight // 已知打印分辨率58mm 纸宽对应约 384px按 96dpi 计算换算比例 58000 / 384 // 高度微米 heightPx * (58000 / 384) const widthMicrons 58000 const heightPxPerMicron widthMicrons / 384 const heightMicrons Math.ceil(heightPx * heightPxPerMicron) window.printAPI.sendHeight({ heightMicrons })这套「先量后打」的流程是一个血泪经验换来的不设高度Electron 默认按 A4 高度打印热敏纸出票时要么一大段空白要么内容被截断用户来反馈的时候根本说不清楚是代码问题还是打印机问题。4. 排版细节58mm / 80mm 纸宽的像素换算与 HTML 模板写法4.1 从毫米到像素小票纸上一个字符的数学小票排版为什么难核心冲突在于设计师用 1920x1080 的屏幕画图但实际输出只有 58mm 或 80mm 宽宽度是物理固定值高度随内容增长。我们需要把毫米换算成 CSS 像素才能在打印窗口里精确控制。换算公式1 英寸 25.4mm在 96dpi 的浏览器渲染下1 英寸 96px。所以 58mm 宽度对应的 CSS 像素是58 / 25.4 * 96 ≈ 219px。但是实际打印时打印机的物理分辨率往往是 203dpi 或 300dpi这中间存在一个缩放比。我的经验是不在 CSS 里强行换算物理像素而是把打印窗口设置为 219px 宽字体用相对单位让 Chromium 自动处理缩放。# 创建一张 58mm 小票时的基础样式在打印组件里写死 # 宽度58mm 纸实际可用宽度约 56mm左右各留 1mm 安全边距 # 可用宽度像素 56 / 25.4 * 96 ≈ 212px取整设 210px 比较稳妥4.2 小票模板的 HTML 结构表格布局和字体控制的实践经验小票内容无论千变万化结构就是三块头部店名、单号、时间、明细商品、数量、金额、尾部合计、支付方式、备注。我推荐用table而不是flex布局。原因很现实Electron 内置的 Chromium 对 flex 支持没问题但打印引擎在分页、缩放时对 table 的处理更稳定而且很多老式驱动对 flex 生成的绝对定位元素识别有偏差。!-- 打印模板核心结构58mm 小票 -- div idreceipt-body stylewidth: 210px; margin: 0 auto; font-family: SimSun, Microsoft YaHei, monospace; div styletext-align: center; font-size: 14px; font-weight: bold; p{{ shopName }}/p /div div styletext-align: center; font-size: 10px; margin-bottom: 4px; span{{ orderNo }}/span span{{ createdAt }}/span /div table stylewidth: 100%; font-size: 11px; border-collapse: collapse; thead tr th styletext-align: left;品名/th th styletext-align: center; width: 40px;数量/th th styletext-align: right; width: 55px;金额/th /tr /thead tbody !-- 商品明细 -- /tbody /table /div字体的坑比想象中多。默认的微软雅黑在热敏纸上笔画太细打印出来偏淡宋体虽然笔画清晰但数字对不齐。我踩坑之后的方案是数字用等宽字体Consolas或monospace中文用宋体不做抗锯齿处理。热敏纸本身是 203dpi抗锯齿反而让字边缘发虚。还有一点不要用 CSS 的transform: scale()做整体缩放打印引擎对缩放内容的处理是黑匣子结果经常是某几行被切断。4.3 小票末尾的切纸和钱箱信号处理很多小票打印需求不只是打一张纸还要自动切纸、打开钱箱。这些能力webContents.print是不直接暴露的——它只能把内容送到打印机。真正控制切纸和钱箱需要走 ESC/POS 指令也就是通过 Node 的net模块或serialport直接和打印机通信。// 通过 TCP 发送 ESC/POS 切纸指令示例58mm 热敏打印机 const net require(net) const client new net.Socket() // 打印机 IP网口打印机或本地端口 client.connect(9100, 192.168.1.100, () { // ESC 2: 出纸并切纸半切 const cutPaper Buffer.from([0x1B, 0x6D]) // 打开钱箱ESC p 脉冲指令 const openCashDrawer Buffer.from([0x1B, 0x70, 0x00, 0x19, 0xFA]) client.write(cutPaper) client.write(openCashDrawer) client.end() })这里要注意你的打印机如果接的是 USB走 TCP 9100 是不通的需要用驱动命令或者node-usb去做底层通信。所以在项目选型时建议优先选购网口RJ45版热敏打印机能省掉后面集成指令控制的很多麻烦。如果只能用 USB那就只能在打印完成回调里做一个交互提示让店员手动按切纸键体验稍差但稳定。5. 避坑小票打印最常见的 5 个翻车现场与排查方法5.1 对话框没出现打印结果却是空白纸现象打印机出纸了但小票上什么内容都没有或者只有一点点墨迹。原因九成是打印窗口的页面没有正确加载小票内容webContents.print在一个空白页面上执行了。模板里的数据是通过异步 IPC 传入的如果渲染进程还没处理完数据就直接触发打印打出去就是白纸。解决在渲染进程把doPrint()的触发时机从created挪到nextTick之后并且加一个数据存在性检查。更稳妥的方案是给主进程的打印监听加一个「数据注入完成」的确认信号// 渲染进程——数据渲染完成后才通知主进程 this.$nextTick(() { // 确保表格都渲染完了 window.printAPI.confirmRendered(() { window.printAPI.startPrint() }) })5.2 中文乱码成方框或问号现象英文正常中文全变成「口口口」或空方格。这不是 Electron 的问题也不是打印机的问题而是字体映射问题。热敏打印机内置的字库通常只支持 GBK 编码和对应中文字库如果系统里选中的字体打印机不认识驱动就退化成占位符。解决第一CSS 里指定打印机内置字体常见的是宋体SimSun或黑体SimHei第二在驱动设置里确认打印机的文字编码是 GBK 而不是 UTF-8很多驱动默认 UTF-8但热敏打印机不认。如果你在连接的是 USB 打印机还可以尝试把打印机的厂商驱动语言切换为中文。5.3 小票内容被截断最后一行永远打不出来现象小票长度一超过 15cm尾部就被切掉或者刚好是最后一行商品明细缺失。根因就是我在 3.3 节提到的pageSize高度没设或设小了。Electron 打印时默认纸张高度是 A4约 297mm一旦内容超过这个值就会被裁掉如果你把高度设定成了打印窗口的 CSS 高度而不是内容高度同样会截断。解决不要用固定高度每次打印前在渲染进程里测量内容高度换算成微米动态传入pageSize。我的实践里会额外加 5mm 的安全余量防止个别打印机走纸误差导致最后一行被切掉。5.4 连续打两张小票第二张数据错乱或串单现象收银高峰连续打单第一张正常第二张可能是上一单的内容或者干脆白纸。原因复用打印窗口时上一单的数据还在 Vue 组件的data里新的打印数据到达后模板还没来得及重新渲染旧的打印任务就启动了。解决设计一个简单的队列。在渲染进程里维护一个pendingData数组每接到一批数据先放入队列打印完成回调后再取下一批。这个方案比「等 rendering 完成再打」更可靠因为收银场景里经常一分钟内连续打 3~5 张票。// 渲染进程——队列控制核心逻辑 const queue [] let isPrinting false function enqueue(items) { queue.push(items) if (!isPrinting) processNext() } async function processNext() { if (queue.length 0) return isPrinting true const data queue.shift() this.receipt data await this.$nextTick() window.printAPI.startPrint(() { isPrinting false processNext() }) }5.5 打包成 exe 后打印失败了开发环境却正常现象electron:serve里打得好好的打包安装后第一张票打不出来或者报「找不到打印机」。原因通常有两个一是打包时渲染进程的加载路径错了打印窗口加载到了错误页面二是权限问题安装后的程序没被系统授予访问打印机的权限。解决先在主进程里把加载路径的日志打出来确认生产环境loadFile的路径解析正确。权限问题常见于从网络共享盘安装或受限用户登录的情况这时需要在程序清单里声明打印机权限或者给启动程序加「管理员身份运行」兼容属性。如果门店用的是域环境建议 IT 统一部署并预授权。6. 进阶硬化静默打印的「失败重打」与打印记录持久化走到静默打印不是终点真正的挑战是打印机缺纸、卡纸、离线时静默模式把失败信息吞掉了你根本不知道这单没打出来。我的进阶做法是给打印任务加一个「确认回执」。Electron 的webContents.print回调只告诉你打印作业是否成功提交给系统并不代表打印机已经出纸。要确认出纸需要在打印机上做配置部分品牌支持状态回传或者在业务层做超时检测发起打印后 8 秒内没有收到下一次打印请求就在界面上弹一个「请确认小票是否正常打出」的提醒。虽然粗糙但在门店场景里比完全无感知强得多。另外我会在本地维护一个打印记录表存 SQLite 或一个 JSON 文件记录每一单的订单号、打印时间、打印结果标志。这样一旦发生丢单纠纷可以直接定位到那一单的打印作业状态而不是靠收银员回忆。// 打印记录持久化示例主进程 const fs require(fs) const logPath path.join(app.getPath(userData), print.log) function appendPrintLog(orderNo, status) { const entry ${new Date().toISOString()} | ${orderNo} | ${status}\n fs.appendFile(logPath, entry, (err) { if (err) console.error(写入打印日志失败:, err) }) }这个习惯源于我经历过的一次真实教训月底对账时发现某天的 3 张小票金额对不上因为没有记录花了整整一天翻监控核对。从那之后凡是涉及钱的操作我都坚持留痕。小票打印看似边缘功能但它和收款、退款、会员储值强相关记录每一笔打印既是对顾客负责也是给自己留一条后路。最后提一个容易被忽略的细节开发时保持silent: false方便你看打印预览调试排版上线前再把silent切回true。我把这个参数放在环境变量里控制避免发布时手抖改错。希望这篇踩坑笔记能帮你把 Electron 小票打印这条路走顺真正实现「打印功能从立项到上线一周完成」的目标。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站