简介electron-vue-print-demo 是一个基于 Electron 与 Vue CLI3 的桌面端小票打印示例项目主要面向需要实现本地打印机设置、静默打印和打印流程控制的前端开发者。项目以 JavaScript 编写围绕 electron-store 保存打印机名称、判断是否已配置、未配置时弹出设置窗口、确认后执行打印等环节给出可参考的业务闭环适合作为收银、订单、票据类桌面应用的起步模板。压缩包共 25 个文件约 117KB以 14 个 JavaScript 文件、3 个 Vue 组件、2 个 JSON 配置和 2 个 HTML 页面为主另含说明文档与图标资源源码目录覆盖主进程、渲染组件、打印页面及构建配置结构紧凑便于按模块阅读和二次调整。目前已有 3665 人学习。读者可以从中了解 Electron 主进程与 Vue 渲染层的协作方式、electron-store 的本地持久化用法以及打印参数和静默打印的调用思路项目提供打印页与设置弹窗的对应实现可作为验证打印环境和拆解桌面端打印方案的参考资料。1. 收银台前的那声「咔」electron vue 做打印小票到底卡在哪收银台前排着队收银员按下结账热敏打印机「咔」一声吐出一张 58mm 宽的小票。这个动作背后是一个 electron vue-cli3 工程在几百毫秒里完成订单渲染、隐藏窗口加载和静默出纸。这个 electron-vue-print-demo 把「打印小票」拆成了一个能独立跑起来的最小工程主进程持有打印通道渲染进程负责收银界面小票模板独立成一个路由页面订单元数据走 IPC 送进去点一下直接出纸不弹系统打印对话框。真正难的地方是版面和设备一张 58mm 的纸塞不下几个字列对不齐就全乱打印机名字在开发机和收银机上可能根本不一样静默打印的机会只有一次打歪了就是一张废纸。如果你在做收银、餐饮、取号、仓储这类需要现场出纸的桌面端或者手上有个 electron 项目正被打印拖着这份 demo 的骨架和坑位基本可以照抄。2. 工程骨架拆解vue-cli3 插件把主进程和渲染进程摆在哪这个项目的工程结构由vue-cli-plugin-electron-builder这个插件决定它把 vue-cli3 的构建能力直接复用到 Electron 上一条命令同时起 dev server 和主进程打包时又用 electron-builder 出安装包。理解它约定的目录比背 API 重要得多因为后面所有打印相关的代码都落在主进程里。2.1 目录结构与依赖清单一个能跑的最小版本目录大致是下面这个样子。注意Receipt.vue是独立页面它不参与收银界面的交互只被隐藏窗口加载一次。electron-vue-print-demo ├── public/ │ └── index.html ├── src/ │ ├── background.js # 主进程入口打印通道全部在这里 │ ├── preload.js # 预加载脚本只暴露白名单方法 │ ├── main.js # 渲染进程入口 │ ├── App.vue │ ├── router/ │ │ └── index.js # 普通页面 /receipt 隐藏模板页 │ ├── views/ │ │ ├── Cashier.vue # 收银台主界面 │ │ └── Receipt.vue # 小票模板隐藏窗口加载的就是它 │ ├── utils/ │ │ └── receipt.js # 订单对象 → 打印数据的映射 │ └── assets/ │ └── receipt.css # 打印专用样式只在这个页面引入 ├── vue.config.js └── package.json依赖上不需要花哨的东西package.json里这几行是核心{ scripts: { serve: vue-cli-service serve, electron:serve: vue-cli-service electron:serve, electron:build: vue-cli-service electron:build, postinstall: electron-builder install-app-deps }, dependencies: { dayjs: ^1.11.0 }, devDependencies: { electron: ^13.0.0, electron-builder: ^22.0.0, vue-cli-plugin-electron-builder: ~2.1.0 } }electron:serve做的事是先起 webpack dev server再把地址注入process.env.WEBPACK_DEV_SERVER_URL最后拉起主进程。所以主进程里判断开发态和生产态一律看这个环境变量在不在而不是NODE_ENV——打包之后它会被替换成file://路径这个分支写错就会出现「开发能打印、装了包打不开窗口」。2.2 主进程里的窗口与 IPC 打印通道主进程是整个打印链路的中枢。它要维护两类窗口一个是用户看的收银窗口一个是只用来渲染小票的隐藏窗口。隐藏窗口必须是单例并且长期持有引用否则会被 GC 回收上一次打印的任务跑到一半窗口没了回调永远不会回来。// src/background.js import { app, BrowserWindow, ipcMain } from electron import path from path let win null let printWin null // 隐藏窗口单例别用局部变量 function createWindow () { win new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, // 默认 true别为了图方便关掉 nodeIntegration: false } }) if (process.env.WEBPACK_DEV_SERVER_URL) { win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) } else { win.loadFile(path.join(__dirname, ../dist_electron/index.html)) } } app.whenReady().then(createWindow) app.on(window-all-closed, () { if (process.platform ! darwin) app.quit() })窗口创建好之后注册两条 IPC一条查打印机列表一条执行打印。ipcMain.handle走的是 Promise 语义渲染进程用invoke拿结果比老式的sendsendSync干净。// 查询可用打印机供设置页做下拉选择 ipcMain.handle(printer:list, async () { const printers await win.webContents.getPrintersAsync() return printers.map(p ({ name: p.name, // 打印时必须原样传回的名字 displayName: p.displayName, // 给人看的名字可能是中文 isDefault: p.isDefault, // 布尔值 status: p.status // 0 空闲1 暂停2 异常 })) }) // 真正执行打印 ipcMain.handle(print:receipt, async (_e, payload) { return await printReceipt(payload) })这里有个容易踩的点getPrintersAsync()是挂在webContents上的不是挂在app上也不是随便哪个窗口都能调。如果你在隐藏窗口里调拿到的列表可能因为窗口还在加载而返回空数组所以统一用主窗口的webContents查。老版本 Electron9 以前只有同步的getPrinters()现在虽然还能用但已经废弃新工程直接上异步版本。2.3 预加载脚本把打印能力收进白名单contextIsolation打开之后渲染进程拿不到require所有能力必须从预加载脚本里显式暴露。这一步看着繁琐实际是给打印接口做了一层收口——将来要加「打印预览」「重打上一单」都在这一个文件里加方法收银页面不用关心底层。// src/preload.js const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(printApi, { // 查打印机 list: () ipcRenderer.invoke(printer:list), // 提交一单打印任务 print: (payload) ipcRenderer.invoke(print:receipt, payload), // 订阅打印状态用于界面提示「打印中 / 打印失败」 onState: (handler) { const listener (_e, state) handler(state) ipcRenderer.on(print:state, listener) return () ipcRenderer.removeListener(print:state, listener) } })渲染进程里就变成了window.printApi.print({ ... })这么一句。注意onState返回了一个取消订阅的函数Vue 组件的beforeDestroy里要调一下否则热更新几次之后同一个状态会被处理多次界面上出现三条重复的「打印失败」。3. 小票模板把一张订单塞进 58mm 宽的纸里小票排版是这类项目里最耗时间的部分也是最容易被低估的部分API 调通只要半小时版面调对可能要两天。58mm 的热敏纸实际可打印宽度大概 48mm80mm 的机器大概 72mm剩下的都被边距吃掉了。版面一旦超出右侧会被硬裁金额那列直接消失。3.1 打印样式表的三条硬规则样式表里做对三件事后面的调试成本能砍掉一大半固定宽度、关掉页边距、用等宽字体保证列对齐。/* src/assets/receipt.css —— 只在隐藏窗口里生效 */ html, body { margin: 0; padding: 0; width: 58mm; /* 版面宽度和 pageSize 的宽度保持一致 */ background: #fff; color: #000; font-family: monospace; /* 等宽金额列才对得齐 */ font-size: 12px; line-height: 1.35; } page { size: 58mm auto; /* 贴边输出别留浏览器默认页边距 */ margin: 0; } .receipt { padding: 2mm 2mm 6mm; box-sizing: border-box; } /* 三列布局名称自适应数量金额固定宽 */ .row { display: flex; justify-content: space-between; } .row .name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .row .qty { width: 12mm; text-align: right; } .row .amount { width: 18mm; text-align: right; } .divider { border-top: 1px dashed #000; margin: 2mm 0; } .total { font-size: 15px; font-weight: 700; }font-size: 12px是经验值热敏机大多是 203dpi一个 12px 的汉字落在纸上大约 3mm 高一行能放 16 个汉字左右正好够「商品名 数量 小计」三列。想要更紧凑就调到 10px但低于 10px 打印头容易糊成一片尤其是小票纸质量一般的时候。字体这里用monospace让数字等宽中文会回退到系统默认字体如果发现中英文混排时行高不一致把line-height固定成像素值而不是倍数能稳住。page里的size: 58mm auto在部分 Chromium 版本里会被忽略真正决定纸张尺寸的是后面 JS 传的pageSize。这两处要一起写是为了让「开发时用浏览器打印预览看效果」这个调试手段也能大致对得上。3.2 隐藏窗口渲染不走预览直接出纸小票模板不能直接复用收银主窗口因为主窗口的 DOM 里塞满了按钮和弹层打印时会把它们一起算进高度。常见做法是开一个隐藏窗口加载同一个前端工程里的#/receipt路由把订单数据用executeJavaScript注进去。// src/background.js async function ensurePrintWindow () { if (printWin !printWin.isDestroyed()) return printWin printWin new BrowserWindow({ show: false, // 不显示但仍然是完整的渲染进程 width: 400, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false // 后台窗口别降频否则渲染慢半拍 } }) const url process.env.WEBPACK_DEV_SERVER_URL ? ${process.env.WEBPACK_DEV_SERVER_URL}#/receipt : file://${path.join(__dirname, ../dist_electron/index.html)}#/receipt await printWin.loadURL(url) return printWin } async function renderReceipt (data) { const wc await ensurePrintWindow() // 注入数据并等模板渲染完成返回值必须是可序列化的 await wc.webContents.executeJavaScript( new Promise(resolve { window.__setReceipt(${JSON.stringify(data)}) // 等一帧让 Vue 把 DOM 更新完 requestAnimationFrame(() setTimeout(resolve, 0)) }) ) }这里有两个细节值得单独说。第一show: false的窗口在某些 Windows 显卡驱动下渲染结果为空表现就是打出一张白纸稳妥的做法是把它挪到屏幕外x: -2000, y: -2000, show: true视觉上看不见但渲染管线是完整的。第二JSON.stringify的数据里不能有Date对象、函数、undefined订单里的时间要提前格式化成字符串否则注入到页面里会变成Invalid Date。backgroundThrottling: false这一项也建议加上。隐藏窗口在后台会被 Chromium 降频到 1fps渲染时间从 30ms 变成 1s 以上收银员会明显感觉到「点了没反应」。3.3 高度自适应先量后排小票的长度是不固定的买一件和买二十件差好几倍。pageSize的height又不能传 0传小了后面的内容被截断传大了尾巴拖出一长条空白纸。常见做法是渲染完先量一次真实高度再换算成微米传给打印接口。// 量出模板的实际高度单位CSS 像素 async function measureHeight (wc) { return await wc.webContents.executeJavaScript( new Promise(resolve { const read () requestAnimationFrame(() resolve(Math.ceil(document.body.getBoundingClientRect().height)) ) // 字体没加载完就量中文会按 fallback 字体算高度偏小 if (document.fonts document.fonts.ready) { document.fonts.ready.then(() setTimeout(read, 30)) } else { setTimeout(read, 100) } }) ) } // CSS 像素 → 微米1px 1/96 英寸1 英寸 25400 微米 const MICRON_PER_PX 25400 / 96 const heightPx await measureHeight(wc) const heightMicron Math.ceil(heightPx * MICRON_PER_PX) 8000 // 底部多留 8mm 走纸那个 8000是留白让打印机的切刀有空间切在两张票之间不然最后一行字可能被切掉一半。换算系数25400 / 96 ≈ 264.58值得记一下所有从像素到打印尺寸的转换都靠它写成魔法数字 264 会有累积误差长票据末尾会偏移几毫米。4. 静默打印链路从 IPC 到出纸的完整调用链路走到这里已经齐了收银页面点结账 →window.printApi.print(order)→ 主进程注入数据到隐藏窗口 → 量高度 → 调用webContents.print。这一章把最后一步的参数、回调判定和排队机制讲透这三块决定了实际用起来会不会出事故。4.1 参数表webContents.print 的每一项都在干什么打印参数不多但每一项错了都有具体后果。先看对照表参数取值示例作用与常见错误silenttrue关掉系统打印对话框。设成false每次都要人工点确认收银场景不可用deviceNamePOS-58目标打印机名必须和getPrintersAsync()返回的name完全一致留空则走默认打印机printBackgroundtrue不打背景时虚线和浅灰底色会整段消失margins{ marginType: none }小票必须关页边距否则左侧凭空多出 10mm 白边pageSize{ width: 58000, height: 80000 }单位是微米不是毫米写58等于 0.058mmscaleFactor100缩放百分比。调大能放大字但也会放大版面宽度容易超宽被裁copies1联数后厨单常用的「一菜一单」靠它colorfalse热敏机只有黑白设true只是多一步转换落到代码上封装成一个返回 Promise 的函数调用处才好串行排队// src/background.js function doPrint (wc, { deviceName, heightPx, copies 1 }) { return new Promise((resolve) { wc.print( { silent: true, printBackground: true, deviceName, margins: { marginType: none }, pageSize: { width: Math.round(58 * 1000), // 58mm → 微米 height: Math.round(heightPx * (25400 / 96)) 8000 // 内容高 走纸余量 }, scaleFactor: 100, copies, color: false }, (success, failureReason) { // failureReason 只有 cancelled 和 failed 两种取值 resolve({ success, failureReason }) } ) }) }failureReason只有两个值这个信息量其实够用cancelled是用户或系统取消了任务重试也没意义failed才值得重试一次通常是打印队列卡住或者驱动超时。注意回调是异步的但它不代表纸已经出来了——只代表任务被系统打印队列接收。真要确认出纸得去查打印机的状态接口代价太高实际项目里一般用「回调成功 状态提示」就够了。4.2 回调判定与串行队列别让第二单叠在第一单上打印机是串行设备。上一单的数据还在往缓冲区里灌下一单紧接着发过去结果就是两张票的内容交叉在一起或者其中一张整段丢失。收银高峰期连打五六单是常态没有队列一定会翻车。// src/background.js const jobQueue [] let running false function enqueue (job) { jobQueue.push(job) if (!running) drain() } async function drain () { running true while (jobQueue.length) { const job jobQueue.shift() let result { success: false, failureReason: failed } try { const wc await ensurePrintWindow() await renderReceipt(job.data) const heightPx await measureHeight(wc) result await doPrint(wc, { deviceName: job.deviceName, heightPx, copies: job.copies || 1 }) } catch (e) { result { success: false, failureReason: e.message } } // failed 重试一次cancelled 不重试 if (!result.success result.failureReason failed !job.retried) { job.retried true jobQueue.unshift(job) continue } win.webContents.send(print:state, { id: job.id, ...result }) } running false }running这个标志位是关键它保证同一时刻只有一个任务在跑。把重试逻辑放在队列里而不是递归调用是为了避免「重试的任务插到新任务前面」这种顺序错乱。job.id由渲染进程生成比如订单号加时间戳界面拿到状态就能精确地把某一行标成「打印失败点击重打」而不是笼统弹一个 toast。还有一个现实问题切纸指令和打印任务是两个动作如果打印机支持自动切纸通常靠驱动在任务结束时下发。如果发现两张票粘在一起可以在模板底部加一段空白div高度约 10mm比调pageSize的余量更可控。4.3 打印机枚举与 ESC/POS 原生指令兜底设备绑定这一步设置页里必须做成可选不能写死。用户换机器、装第二台打印机、把默认打印机改成 PDF 虚拟打印机都可能发生。// 渲染进程里的设置页 async function loadPrinters () { const list await window.printApi.list() // Windows 上同一个物理打印机可能因为驱动不同出现多条记录 return list.filter(p !/pdf|fax|onenote/i.test(p.name) p.status ! 2) }过滤掉 PDF / 传真这类虚拟设备很有必要它们会出现在列表里但不是真正的小票机用户手一抖选了它打印任务就变成在桌面上生成一个文件收银员会以为打印机坏了。再往下走一层如果业务要求打印条形码、二维码或者需要控制蜂鸣器、钱箱webContents.print这条路就不够了得直接发 ESC/POS 指令。常见做法是不经过打印驱动把指令拼成二进制直接丢给打印机队列在 Windows 上可以用copy /b把裸字节送进共享队列// src/background.js —— ESC/POS 兜底通道 import iconv from iconv-lite import fs from fs import os from os import path from path import { execFile } from child_process // 拼一条最小可用指令初始化 → 文本(GBK) → 走纸 → 切纸 function buildEscPos (text) { const init Buffer.from([0x1b, 0x40]) // ESC 复位 const feed Buffer.from([0x1b, 0x64, 0x05]) // ESC d 5 走纸 5 行 const cut Buffer.from([0x1d, 0x56, 0x42, 0x00]) // GS V B 0 半切 return Buffer.concat([init, iconv.encode(text, gbk), feed, cut]) } function rawPrint (printerName, text) { const tmp path.join(os.tmpdir(), r-${Date.now()}.bin) fs.writeFileSync(tmp, buildEscPos(text)) return new Promise((resolve, reject) { execFile(cmd, [/c, copy, /b, tmp, \\\\localhost\\${printerName}], (err) { try { fs.unlinkSync(tmp) } catch (_) {} err ? reject(err) : resolve() }) }) }中文必须走iconv-lite转成gbk再发直接发 UTF-8 字节打出来就是乱码方块——这是国内热敏机最常见的一个坑因为打印机固件的字库是 GBK 码表它不知道 UTF-8 是什么。这条路的好处是快几十毫秒就能出纸坏处是版面全靠自己算字符宽度中英文混排时对齐很痛苦。我的建议是纯文本票据用webContents.print走 HTML 排版条形码、钱箱、蜂鸣这类硬件动作走 ESC/POS两条路各管一段。5. 常见问题排查白纸、错位、打包后不出纸下面这几条是我在实际项目里反复遇到的按现象、原因、解决三段写遇到问题可以直接对着排查。5.1 排版与渲染类现象一调用成功回调返回success: true但出来的是一张纯白纸。原因多半在隐藏窗口没有真正完成渲染show: false的窗口在部分显卡驱动下不参与合成printBackground又是true结果输出空白。另一种情况是loadURL的 Promise resolve 了但 Vue 的mounted还没跑完__setReceipt注入时 DOM 里根本没内容。 解决是把窗口改成屏幕外显示x: -2000, y: -2000, show: true再在注入数据之后等一次requestAnimationFrame加setTimeout(0)。如果还是白纸就在打印前用wc.capturePage()截一张图存到临时目录看一眼就能确定是渲染问题还是打印参数问题这招比反复打真纸省事得多。现象二小票右侧内容被切掉金额那一列完全看不见。原因有两个方向。一是pageSize.width写成了毫米比如{ width: 58 }实际纸张只有 0.058mm 宽所有内容挤成一列像素二是模板body的宽度设成了 80mm 却打了 58mm 的纸多出来的部分被裁。 解决是先统一单位宽度一律58 * 1000再确认 CSS 里html, body的宽度和纸宽一致并且.receipt的padding算在box-sizing: border-box之内。80mm 的机器要把 58 换成 72 左右去掉两边不可打印区别直接换 80。现象三分隔虚线、表头灰色底纹打不出来。原因是printBackground没开Chromium 默认不打印背景色和背景图border-top: 1px dashed这种边框有时候也会被优化掉。 解决是显式传printBackground: true并且把虚线改成实线的border-bottom: 1px solid #000热敏机的分辨率下 1px 虚线本来就容易断成点。5.2 设备、编码与打包类现象四开发机能打打包安装到收银机之后列表里找不到打印机或者选了也打不出来。原因是deviceName必须和系统里的打印机名完全一致。Windows 上同一个驱动装两次第二个实例的名字会自动加后缀变成POS-58 (副本 1)这种而配置里存的是开发机上的名字两边对不上静默打印就直接失败回调给failed。 解决是把打印机名字存进本地配置文件app.getPath(userData)下面首次启动强制让用户在设置页选一次选完做一次「打印测试页」验证同时在打印前做一次名称校验找不到就退回默认打印机并给界面一个明确提示而不是静默失败。现象五中文全部变成乱码或者问号。走 HTML 打印这条路时乱码通常是因为隐藏窗口用的字体在目标机器上不存在中文回退到了一个不含中文字形的字体走 ESC/POS 那条路时乱码是因为编码没转UTF-8 字节被打印机按 GBK 解释。 解决分两条HTML 路径把font-family落到系统一定有的中文字体上别用网络字体ESC/POS 路径统一用iconv-lite转gbk并且确认打印机支持的代码页必要时在指令开头加一条ESC t n切代码页。6. 二次开发换成自己的单据模板拿到这份 demo 之后绝大多数人的第一件事是把它改成自己业务的单据。改造可以按三层来推从浅到深每层都能独立验证。第一层只改数据映射不动版面。src/utils/receipt.js里做的是「订单对象 → 模板数据」的转换把字段名、金额格式化、时间格式都收在这一个文件里。金额千万别在模板里做除法浮点数出来的0.30000000000000004打在小票上很难看// src/utils/receipt.js import dayjs from dayjs export function toReceiptData (order, shopName) { const items order.items.map(it ({ name: it.title, qty: it.count, // 分转元并保留两位避免浮点误差 amount: (it.price * it.count / 100).toFixed(2) })) const total (order.payAmount / 100).toFixed(2) return { shopName, orderNo: order.orderNo, time: dayjs(order.createdAt).format(YYYY-MM-DD HH:mm:ss), items, total, payType: order.payType wx ? 扫码支付 : 现金, qrText: https://example.invalid/o/${order.orderNo} // 二维码内容 } }第二层改版面。真正的技巧是「多模板共存」而不是改一个模板后厨单不需要金额、退款单要标红负数、标签单只要商品名和规格。做法是给Receipt.vue加一个type参数通过路由 query 传进去receipt.css里用类名区分。这样一套渲染逻辑能覆盖四五种单据比复制五个页面好维护得多。第三层是加验证手段。我现在的习惯是开发阶段不碰真纸先在设置页放一个「导出 PDF」按钮内部调webContents.printToPDF把同一份模板输出成文件肉眼确认版面和高度确认没问题再打真机。这个小按钮至少能省掉一半的纸也方便把效果图发给业务方确认。再往下可以做「重打上一单」和「打印日志」每次打印往本地 SQLite 或 JSON 文件里记一条包含订单号、打印机名、结果、耗时出问题时能翻出到底是哪一单、哪台机器、什么原因失败而不是靠收银员的记忆。这套日志上线之后打印相关的客诉排查时间从半天缩短到十分钟。从那以后我每次接打印需求第一步都是在设置页塞一个「打印测试页」按钮和一份导出 PDF 的开关先把模板和高度验完再去碰设备和队列——顺序颠倒过来排查成本会翻好几倍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?