☰
Electron+Vue 静默打印58mm热敏小票全链路拆解
2026/10/30 7:23:43 网站建设 项目流程

简介:面向 Electron 桌面端开发者的打印小票功能示例工程,基于 electron 与 Vue CLI 3 搭建,聚焦本地打印机设置与静默打印流程的落地实现。项目以 JavaScript 为主,包内共 25 个文件,含 14 个 js 业务与主进程脚本、3 个 vue 组件、2 个 json 配置、2 个 html 打印页面及图标等静态资源,压缩包约 117KB,体量轻便、便于快速运行与二次改造。已有 3665 人学习下载。工程把打印逻辑拆分为主进程与渲染层:渲染层由 Vue 组件触发打印,主进程负责调用系统打印能力,并通过 electron-store 持久化用户选择的打印机名称;未设置时弹出配置窗口,设置完成后直接静默输出,形成完整的交互闭环。目录按 src/components、main-process、assets、config 与 public 分层,主进程 background.js、打印页 print.html、入口 main.js 等职责明确,适合需要理解 Electron 主渲染通信、打印参数配置与本地存储方案的开发者参考,也可作为收银、票据类桌面应用的起步脚手架。

1. 门店收银台前的 3 秒钟,藏着一整条 Electron 打印链路

收银员扫完最后一件商品,按下“结算”,旁边那台 58mm 热敏小票机“咔”一声开始出纸。整个过程没有弹窗,也没有让用户选择打印机,连强烈的加载动画都没出现。对顾客来说,这只是 3 秒钟的事;对写代码的人来说,这 3 秒钟里发生了一次跨进程通信:从 Vue 渲染进程里组装订单数据,把数据送到 preload 暴露出来的桥接方法,再由主进程接收,接着主进程开一个隐藏窗口、加载小票模板、填充数据、调用webContents.print,最后把结果回传给渲染进程。

这个 electron + vue-cli3 的小票打印 demo,做的就是这条完整链路。它适合两类人:一类是正在做零售、餐饮、门店收银类桌面应用,需要对接 58mm 或 80mm 热敏小票机的开发者;另一类是已经能用 Electron 做点东西,但每次碰静默打印、页面尺寸、打包后资源丢失就出问题的同学。下面按我的拆解顺序,先把工程骨架讲清楚,再逐段看打印代码,最后落到怎么换成自己的小票模板。

2. 工程骨架拆解:主进程、渲染进程与打印页的三方分工

2.1 目录结构:一份典型的 electron + vue-cli3 工程

这个 demo 的基本形态是 vue-cli3 项目加vue-cli-plugin-electron-builder。之所以不是直接手撸 webpack 双入口,是因为 vue-cli3 的插件体系已经把主进程打包、开发环境热更新、构建产物目录这些事处理掉了,你只要遵守它的约定目录,剩下的精力可以全放在业务上。

常见的目录形态大致如下:

electron-vue-print/ ├── public/ │ ├── index.html # 主界面模板,vue-cli 默认入口 │ └── print.html # 小票页模板,打印专用,独立于 SPA ├── src/ │ ├── background.js # 主进程入口(electron-builder 插件约定) │ ├── preload/ │ │ └── index.js # 预加载脚本,向渲染进程暴露受控 API │ └── renderer/ │ ├── main.js # 渲染进程入口 │ ├── App.vue │ ├── router.js │ ├── store.js │ └── views/ │ ├── Cashier.vue # 收银台主界面 │ └── Setting.vue # 打印机选择、测试打印 ├── vue.config.js ├── package.json └── babel.config.js

这里面最关键的一点,是public/print.html这个独立页面。很多新手会把小票模板做成一个 Vue 路由页面,然后在主界面里“跳到那个路由再打印”。这种做法在开发环境里看起来没问题,一旦到了生产环境,路由切换、页面卸载、组件销毁时机全都会干扰打印结果。独立 HTML 页面的好处很直接:它不参与 SPA 路由,不带任何业务逻辑,主进程什么时候需要,就什么时候把它加载到一个隐藏窗口里,打完了直接destroy(),干净利落。

src/background.js是主进程唯一的入口,所有跟操作系统打交道的事情都往这里塞:创建主窗口、注册 IPC 处理器、枚举打印机、执行打印。src/preload/index.js是主进程和渲染进程之间的唯一通道,渲染进程能拿到哪些能力,完全由它决定。

2.2 两种构建产物怎么被 Electron 加载

这一点是理解整个项目运转的基础。vue-cli3 在这里其实产出了两套东西:

  • 主进程代码(src/background.js及其依赖)由 electron-builder 插件用 webpack 单独打一份 CommonJS 产物,输出到dist_electron/目录,package.json的main字段指向它。
  • 渲染进程代码(src/renderer/)走标准的 vue-cli SPA 构建流程,输出到dist/或者插件指定的目录。

开发阶段,npm run electron:serve会先起一个 vue-cli 的开发服务器,再把process.env.WEBPACK_DEV_SERVER_URL注入主进程环境变量。主进程创建窗口时判断这个变量存不存在:存在就loadURL(devServerUrl),不存在就loadFile()加载本地打包好的 HTML。

打印页的加载逻辑用的是同一套判断,这是新手最容易漏掉的地方。开发时你能打开小票窗口、打包后就白屏,八成就是因为打印页的路径只按开发环境写死了:

// 判断当前是开发环境还是打包后环境,决定加载 URL 还是本地文件 const isDev = !!process.env.WEBPACK_DEV_SERVER_URL function resolvePrintPage() { if (isDev) { // 开发环境:走 dev server,改模板能热更新 return `${process.env.WEBPACK_DEV_SERVER_URL}print.html` } // 生产环境:__dirname 指向 dist_electron,print.html 在 public 里被打到了同级目录 return `file://${path.join(__dirname, 'print.html')}` }

这段代码的逻辑很直白,但有几个参数值得停下来看一眼。process.env.WEBPACK_DEV_SERVER_URL是插件在开发模式下自动注入的,形如http://localhost:8080/,注意末尾带斜杠,拼接print.html时不要再补一个。__dirname在主进程打包产物里指向dist_electron,而public/目录下的静态文件会被复制到构建产物的根级,所以print.html和background.js的产物在同一层,path.join(__dirname, 'print.html')才能命中。如果你在vue.config.js里改过outputDir或者publicPath,这个相对位置会变,打包后必白屏。

file://前缀也不能省。win.loadFile()是更稳妥的写法,它会自动处理协议和路径转义,但很多 demo 用的是loadURL加字符串拼接,遇到中文路径或者空格就会翻车,这个坑在后面的排查章节还会再提一次。

2.3 进程分工:谁负责拼数据,谁负责碰打印机

Electron 里最容易写乱的地方,就是职责边界不清。这个 demo 的分工算是比较标准,值得抄:

渲染进程(Cashier.vue这些)只干三件事:拿到订单数据、按业务规则算好小票上要显示的字段、把结构化的 payload 交给桥接 API。它不require('electron'),不碰BrowserWindow,也完全不知道打印机叫什么名字。

preload 脚本只负责“暴露能力”。它用contextBridge把几个方法挂到window上,每个方法对应一个ipcRenderer.invoke通道,参数原样传、结果原样回,不写业务逻辑。这一层的作用是隔离,渲染进程就算被注入恶意脚本,也调不到ipcRenderer.send这种危险接口。

主进程负责所有脏活累活:创建隐藏窗口、加载小票模板、注入数据、等渲染完成、调print、回收窗口、把成败结果返回。它不关心订单里买了什么,只认 payload 里的字段。

这种切法有一个隐性好处:打印逻辑可以单独测试。你在主进程里写一个printTest(deviceName),直接传一份假订单进去,就能验证整条链路,不需要真的走一遍收银流程。后面讲二次开发时,这个能力会频繁用到。

顺带说一句contextIsolation这个配置。Electron 12 之后默认开启,渲染进程拿不到 Node 的原生require,preload 里也不能再用window.xxx = ...的方式直接挂方法了,必须走contextBridge.exposeInMainWorld。如果你拿到的老项目在 preload 里直接写window.printAPI = {...},升级 Electron 后必然报“找不到 printAPI”,这一点在排查章节里会展开。

3. 打印链路核心代码:从 Vue 点击到 webContents.print

3.1 渲染进程:组装 payload 并发出打印请求

收银台上的“结算并打印”按钮,背后做的事情比想象中要多。订单里通常有商品明细、优惠、合计、支付方式、门店信息、流水号、时间戳,小票上不可能全塞,得按模板挑字段。我一般会在Cashier.vue里单独抽一个buildTicketPayload函数,把业务对象转成打印模板能直接渲染的扁平结构,这样以后换模板只改这一个函数。

// src/renderer/views/Cashier.vue import { ticketApi } from '@/api/ticket' async function handleCheckout() { // 1. 业务侧先落实订单,拿到订单号和流水号 const order = await ticketApi.submit(currentCart) // 2. 转成小票模板能直接吃的结构,字段名和模板一一对应 const payload = buildTicketPayload(order) // 3. 走 preload 暴露的桥接方法,触发主进程打印 const res = await window.printAPI.printTicket(payload) if (res.ok) { // 打印成功才清空购物车,避免打失败丢单 clearCart() } else { // 失败时保留现场,让收银员可以重打 showPrintError(res.message) } } function buildTicketPayload(order) { return { shopName: order.shopName, ticketNo: order.serialNo, // 小票流水号,重打时用得上 createdAt: formatTime(order.paidAt), items: order.items.map(i => ({ name: i.title, qty: i.count, price: (i.unitPrice / 100).toFixed(2), amount: (i.totalPrice / 100).toFixed(2) })), total: (order.payAmount / 100).toFixed(2), payType: order.payChannel, // 打印机名字从设置页读,用户选过一次就存本地 deviceName: window.printAPI.getSavedPrinterName() } }

这段代码里有几个点值得说。第一,金额在数据库里一般存整数分,小票上要显示成两位小数的元,转换必须放在渲染进程做,不要让主进程去猜单位。第二,deviceName从本地配置读出来一起传过去,主进程不需要知道配置存在哪。第三,也是最重要的一点——打印成功才清空购物车。我见过不止一个项目,点了结算先清空购物车再调打印,结果打印机缺纸、驱动报错,收银员当场傻眼,只能靠回忆重录一遍订单。

window.printAPI这个对象在渲染进程里是只读的,它由 preload 通过contextBridge注入,页面里的 JS 改不了它的方法指向。这也是为什么渲染层可以放心大胆地把打印入口暴露给业务代码。

3.2 preload + IPC:把数据安全地送进主进程

preload 这一层代码量很少,但设计得好不好直接影响后面好不好维护。我的习惯是按能力分组暴露,而不是一个大杂烩对象:

// src/preload/index.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('printAPI', { // 打印一张小票,返回 { ok, message } printTicket: (payload) => ipcRenderer.invoke('print:ticket', payload), // 枚举系统里的打印机,设置页用 listPrinters: () => ipcRenderer.invoke('print:list'), // 打一张自检页,验证驱动和纸张是否正常 printTest: (deviceName) => ipcRenderer.invoke('print:test', deviceName), // 本地保存打印机名字,纯本地读取快 getSavedPrinterName: () => localStorage.getItem('printer.deviceName') || '', savePrinterName: (name) => localStorage.setItem('printer.deviceName', name) })

ipcRenderer.invoke和ipcMain.handle是配对使用的,走的是 Promise 语义,天然支持 async/await,比老的send+on加回调事件要清爽得多。参数通过结构化克隆传递,普通对象、数组、字符串都没问题,但函数、类实例、undefined之外的特殊对象传不过去,payload 一定要保证是纯数据。

localStorage在 preload 里能用,因为 preload 有渲染进程的上下文。打印机名字这种一两条的配置,放localStorage比走 IPC 存到主进程再读一次要快得多,设置页打开就能立刻显示。如果你的配置项变多了,比如纸张宽度、打印份数、页脚文案,我一般会换成一个 JSON 串一次存下来,读的时候JSON.parse一次搞定。

这里有个细节:contextBridge暴露的对象在渲染进程里是深冻结的,你没法在页面上给window.printAPI加新方法,也没法改已有方法的实现。这既是安全边界,也提醒你——要在 preload 里把需要的 API 一次想全,别等到业务写到一半再回来加。

3.3 主进程:隐藏窗口 + 数据注入 + 调用 print

主进程是整个链路里代码量最大的部分。核心思路是:每次打印都新建一个不可见的BrowserWindow,加载print.html,等页面渲染完成后注入数据,再调webContents.print,最后销毁窗口。这套流程的好处是每次打印都是一个干净的上下文,不存在上一次的数据残留。

// src/background.js const { app, BrowserWindow, ipcMain } = require('electron') const path = require('node:path') // 打印任务串行队列,避免并发建窗口导致订单串号 let printChain = Promise.resolve() ipcMain.handle('print:ticket', (event, payload) => { // 把任务挂到链尾,失败也继续往下走,不能因为一单失败卡死整个队列 printChain = printChain.then(() => doPrint(payload), () => doPrint(payload)) return printChain }) async function doPrint(payload) { const win = new BrowserWindow({ show: false, // 绝对不可见 width: 400, height: 900, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: false } }) try { // 1. 加载小票模板页 await win.loadURL(resolvePrintPage()) // 2. 把数据塞进页面,模板里的 render 函数负责生成 DOM await win.webContents.executeJavaScript( `window.renderTicket(${JSON.stringify(payload)})` ) // 3. 给二维码、图片留一点绘制时间,这个延时是血泪经验 await sleep(150) // 4. 调系统打印,silent 为 true 时不弹系统对话框 return await new Promise((resolve) => { win.webContents.print({ silent: true, printBackground: true, // 虚线、底色必须开,否则打不出来 deviceName: payload.deviceName, // 为空则用系统默认打印机 margins: { marginType: 'none' }, // 小票纸没有边距概念 pageSize: { width: 58000, height: 297000 } // 单位是微米 }, (success, failureReason) => { resolve({ ok: success, message: failureReason || '' }) }) }) } catch (err) { return { ok: false, message: err.message } } finally { // 5. 无论成败都销毁窗口,否则隐藏窗口会越积越多 if (!win.isDestroyed()) win.destroy() } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

这段代码值得逐块拆开看。show: false是静默打印的前提,窗口一旦可见,用户就会看到闪烁的空白页。webPreferences里nodeIntegration: false加contextIsolation: true是安全基线,打印页里不需要 Node 能力,就别开。

executeJavaScript传的是一个字符串,payload 用JSON.stringify序列化后拼进去。这里要小心 payload 里如果含有反引号、</script>之类的字符会破坏字符串结构,稳妥的做法是在模板页里把renderTicket挂到window上,然后用win.webContents.send配合ipcRenderer.once传数据。demo 里为了代码短用了字符串拼接,生产环境我一般会换成 IPC 事件。

sleep(150)这个延时看起来土,但很有必要。如果小票模板里有 canvas 画的二维码、或者异步加载的 logo 图,executeJavaScript返回时 DOM 已经有了,但位图可能还没画完,这时候直接 print,打出来的二维码就是空白或者缺角。150ms 是个经验值,机器慢的话可以拉到 300ms,更靠谱的做法是在模板页里用Promise等图片onload之后再resolve。

pageSize的单位是微米,不是毫米也不是像素。58mm 宽的纸写58000,80mm 写80000。高度给一个大值(297000对应 297mm),让内容自己撑开,这样一张小票内容再长也不会被截断。这个参数写错的后果很直观:打出来是 A4 大小,内容缩在纸的左上角一小块。

3.4 print 参数逐项拆解与失败排查顺序

webContents.print的参数不多,但每一个都能单独制造一个线上问题。我整理成下面这张表,改代码时对着看:

参数常用值作用写错的后果
silenttrue跳过系统打印对话框每次打印弹窗,收银流程被打断
printBackgroundtrue打印背景色和虚线边框分隔线、底纹全部消失
deviceName打印机全名指定目标打印机为空走默认打印机,名字错了直接失败
margins{ marginType: 'none' }去掉页边距内容整体偏移,右侧被裁掉
pageSize{ width: 58000, height: 297000 }纸张尺寸,单位微米按 A4 排版,内容缩成一角
copies1或业务值打印份数多联单场景会少打

排查顺序我是这么走的:先看print回调里的failureReason字符串,它一般会告诉你“printer not found”还是“no default printer”。如果回调说成功但纸没出来,先确认deviceName拿到的是不是系统里的全名——Windows 上打印机名字经常带“(副本 1)”这种后缀,getPrintersAsync()返回什么就用什么,别自己拼。再确认纸张尺寸在驱动里是不是也设成了 58mm,有的驱动会把应用的pageSize覆盖掉,这时候要在系统打印机首选项里改。

4. 小票模板与打印样式:58mm 热敏纸的排版规则

4.1 模板结构:一个独立 HTML 对应一张小票

public/print.html是整个打印链路里唯一跟“长什么样”有关的地方。它不引入 Vue、不引入任何 UI 库,就是一段纯 HTML 加一段内联脚本。原因很简单:打印页加载得越快越好,每多一个外部依赖,就多一次网络请求或文件读取,打印延迟直接上去。

<!-- public/print.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <style> /* 关键:告诉打印引擎这张纸的物理尺寸 */ @page { size: 58mm auto; margin: 0; } html, body { margin: 0; padding: 0; width: 58mm; /* 热敏机字库有限,优先用系统里一定有的字体 */ font-family: "Microsoft YaHei", "PingFang SC", sans-serif; color: #000; } .ticket { padding: 2mm 3mm; font-size: 12px; line-height: 1.5; } .ticket__shop { text-align: center; font-size: 15px; font-weight: 700; } .ticket__meta { text-align: center; font-size: 11px; margin: 1mm 0 2mm; } .row { display: flex; justify-content: space-between; } .row--item { margin-top: 1mm; } .divider { border-top: 1px dashed #000; margin: 2mm 0; } .total { font-size: 14px; font-weight: 700; } .footer { text-align: center; font-size: 11px; margin-top: 3mm; } /* 打印时强制保留背景和虚线,否则会被浏览器优化掉 */ @media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } </style> </head> <body> <div class="ticket" id="ticket"></div> <script> // 主进程通过 executeJavaScript 调用这个函数注入数据 window.renderTicket = function (data) { const el = document.getElementById('ticket') el.innerHTML = ` <div class="ticket__shop">${data.shopName}</div> <div class="ticket__meta">单号:${data.ticketNo}<br>${data.createdAt}</div> <div class="divider"></div> ${data.items.map(it => ` <div class="row row--item"> <span>${it.name}</span> <span>${it.qty} x ${it.price}</span> </div> <div class="row"><span></span><span>${it.amount}</span></div> `).join('')} <div class="divider"></div> <div class="row total"><span>合计</span><span>¥${data.total}</span></div> <div class="row"><span>支付方式</span><span>${data.payType}</span></div> <div class="divider"></div> <div class="footer">谢谢惠顾,欢迎再次光临</div> ` } </script> </body> </html>

这段模板里有两个点决定成败。@page { size: 58mm auto; margin: 0; }是跟打印引擎沟通纸张尺寸的声明,它必须和主进程里pageSize的微米值对得上,一边写 58mm 一边写 80000 就会打架。auto让高度随内容增长,小票打多少行都不会被硬截断。

-webkit-print-color-adjust: exact是另一个容易被忽略的声明。Chromium 默认在打印时会“优化”掉背景色和粗边框,虚线分隔线经常直接消失。加上这一行并且主进程里printBackground: true,虚线才能稳定打出来。

模板里用innerHTML拼字符串是为了让 demo 代码短。真实项目里如果商品名可能带<或者&,就必须做转义,否则一段商品名就能破坏整个小票结构。我一般的做法是写一个escapeHtml函数,把& < > " '五个字符替换掉,调用成本很低,但能省掉一次线上事故。

4.2 字号、宽度与分页:热敏纸上的排版约束

58mm 热敏纸的有效打印宽度大约 48mm,两边各留 5mm 左右的非打印区。这个约束决定了几件事:一行最多放 32 个半角字符或 16 个全角汉字,商品名超过这个宽度必须截断或者换行,价格列要右对齐,字号不能太小否则热敏头打出来糊成一片。

我的经验值是这样:正文字号 12px 到 13px,店铺名 15px 到 16px,合计行 14px 加粗,页脚 11px。小于 11px 的字在 203dpi 的热敏头上会明显发虚,尤其是笔画多的汉字。行高给 1.5 倍左右,行与行之间留一点呼吸,不然打出来像一坨墨。

80mm 的机器宽度翻倍,排版逻辑一样,把@page改成size: 80mm auto、width: 80mm,主进程pageSize.width改成80000,其余样式按比例放大一到两档就行。别想着用媒体查询在同一个模板里同时适配两种宽度,主进程是知道当前用哪台打印机的,直接把宽度作为参数注入模板更省事。

分页这件事在小票场景里基本不用管,因为height给的是auto,内容多长纸就出多长。但有一个例外:如果你的小票内容特别长,比如一单几十件商品,超过驱动单页最大长度限制时会被强制切断,切口可能落在某一行商品中间。稳妥的做法是当商品行数超过阈值时,主动在小票里插入一个分页标记,分成两张打。这个阈值因驱动而异,得在自己目标机器上实测。

4.3 二维码和条形码的清晰度控制

小票上经常要打订单二维码或者付款码。两种做法:一种是引入qrcode这类库,在模板页里用 canvas 画出来,然后toDataURL()塞进<img>;另一种是后端直接返回图片 URL,模板页加载。前者不依赖网络,更稳,我一般选前者。

清晰度的关键在于绘制尺寸。canvas 的 CSS 尺寸和实际像素尺寸是两回事,如果只设 CSS 宽高,canvas 默认 300x150,放大后就是一片糊。正确做法是绘制时把 canvas 的实际宽高设成目标尺寸的 2 到 3 倍,再用 CSS 缩回来:

// 在小票模板页里生成二维码,2 倍尺寸保证打印清晰 function drawQr(text, targetMm) { const dpr = 3 // 58mm 纸按 8 dot/mm 粗略估算,再乘缩放系数 const px = Math.round(targetMm * 8 * dpr) const canvas = document.createElement('canvas') canvas.width = px canvas.height = px canvas.style.width = targetMm + 'mm' canvas.style.height = targetMm + 'mm' QRCode.toCanvas(canvas, text, { margin: 1, width: px }) return canvas.toDataURL('image/png') }

参数里targetMm是小票上二维码的物理边长,一般 20mm 到 25mm 之间,太小扫不出来,太大浪费纸。dpr = 3是我试出来的经验值,2 倍有时边缘会毛,3 倍肉眼看着就干净了,文件体积也没大到影响打印速度。margin: 1是二维码的静区,别设成 0,热敏纸上墨点会往外洇,静区不够就扫不出来。

生成完之后把 dataURL 放进<img>的src,还要给图片留出加载时间。这就是前面主进程里sleep(150)存在的原因——executeJavaScript返回时<img>的src已经设好了,但位图可能还没解码完成,这时候 print 打出来就是空白框。

5. 避坑与排查:静默打印、页面尺寸与打包后资源

5.1 现象:打出来是 A4 大小,内容缩在左上角

这是出现频率最高的一个问题。打印机能正常工作,纸也出来了,但内容排版完全不对,本该铺满 58mm 宽的小票,实际只占了纸张左上角一小块,剩下的全是空白。

原因基本锁定在pageSize上。Chromium 的打印接口如果不显式指定纸张尺寸,就会用系统的默认纸张,桌面系统默认就是 A4。而小票机的驱动虽然物理上只认 58mm,但接收到的排版指令是按 A4 算的,于是内容按 A4 的宽度排版,实际打到 58mm 的纸上就缩成一角。

解决办法是两处必须同时改:主进程print参数里pageSize给{ width: 58000, height: 297000 },单位微米;模板页<style>里加@page { size: 58mm auto; margin: 0; }。只改一处有时也能生效,但另一处会在某些驱动上把结果覆盖回去,两台一起改最稳。

5.2 现象:silent 设了 true,还是弹系统打印框

silent: true写了,代码也确实是这么传的,但点打印还是弹出系统的打印对话框,收银员得手动点一下“打印”。

这种情况先检查deviceName。silent模式要求必须能确定目标打印机,如果deviceName是空字符串,Electron 会退回到默认打印机;如果系统里压根没设默认打印机,或者deviceName跟系统里的名字对不上(比如少了“(副本 1)”后缀),打印就会失败并且可能弹框。排查方法是在设置页里调一次listPrinters,把getPrintersAsync()返回的name原样打出来,跟你在配置里存的对比一下。

还有一种情况是驱动本身不支持静默。少数老驱动在收到打印任务时会强制弹窗确认,这个 Electron 层解决不了,只能换驱动或者换打印方案。

// 枚举系统打印机,把 name 原样显示给用户选 ipcMain.handle('print:list', async () => { const win = BrowserWindow.getAllWindows()[0] const printers = await win.webContents.getPrintersAsync() return printers.map(p => ({ name: p.name, // 这个名字就是 print 的 deviceName displayName: p.displayName, isDefault: p.isDefault })) })

这段代码返回的name是系统内部标识,displayName是给人看的。存配置要存name,因为print的deviceName认的是它。

5.3 现象:开发环境能打,打包后白屏或找不到模板

npm run electron:serve一切正常,打完包安装上,点打印要么卡住不动,要么直接报错说页面加载失败。打开开发者工具一看,打印窗口里是空白页。

这就是 2.2 节提过的路径问题。开发环境下打印页通过 dev server 的 HTTP 地址加载,打包后WEBPACK_DEV_SERVER_URL不存在,代码走file://分支,如果路径拼错了就加载不到。常见错误有三种:用__dirname拼的时候多套了一层../;publicPath配成了相对路径导致资源 404;print.html没被复制到构建产物目录。

排查顺序:先在打包产物的dist_electron目录里ls一下,确认print.html真的在里面;然后把主进程里最终拼出来的 URLconsole.log出来,在浏览器地址栏里粘贴试试能不能打开;最后检查electron-builder的files配置,确认public下的静态文件没有被排除规则滤掉。

loadFile比手拼file://字符串更省心,它会自动处理路径分隔符和转义,中文路径也不会出问题。如果非要手拼,至少用pathToFileURL转一下。

5.4 现象:中文变宋体、字变细,或者干脆乱码

开发机上一切正常,换到门店那台机器上,中文全变成了宋体,笔画细得几乎看不见,有的字还变成了方块。

这是字体回退的问题。模板里font-family写了一个目标机器上没有的字体,Chromium 就回退到默认字体,Windows 上通常是宋体,热敏打印下宋体的细横线基本打不出来。还有一种更糟的情况是meta charset没写或者是错的,中文字节被按 Latin-1 解码,直接显示成乱码。

解决起来分两步。第一步,<head>里必须有<meta charset="UTF-8">,并且 HTML 文件本身也确实以 UTF-8 保存,不能是 GBK。第二步,font-family写成字体栈,把系统常驻字体放前面:"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif,这样 Windows 上命中雅黑,macOS 上命中苹方,Linux 上命中 Noto,不会掉到宋体。

如果目标机器上什么中文字体都没有,那就得把字体文件随应用一起打包,用@font-face引进来。代价是打印页加载要多读一个文件,字体文件动辄几 MB,会影响首张票的打印速度,能靠系统字体解决就别走这条路。

5.5 现象:连续打多单时串号、丢单或者程序卡死

单个订单打印完全正常,一到高峰期连续打十几单,就出现小票内容错乱——第二张票上印着第一单的商品,或者中间某单干脆没打出来,严重时整个应用无响应。

原因是并发创建隐藏窗口。收银员手快,两次点击间隔可能只有几百毫秒,第一张票的窗口还没销毁,第二个打印任务就开了一个新窗口,两个窗口同时调print,系统打印队列会乱序,数据注入也可能串到错误的窗口上。窗口越开越多,内存和句柄耗尽,应用就卡死了。

解决办法就是 3.3 节里那个printChain。所有打印任务挂到一条 Promise 链上串行执行,前一个打完再开下一个窗口。链上任务失败也要继续往下走,用.then(onFulfilled, onRejected)两个分支都指向同一个任务函数,避免一单失败把后面全卡住。

另外别忘了finally里销毁窗口。我见过只在成功回调里destroy()的写法,一旦打印失败,隐藏窗口就永远留在那儿,打十次失败就积十个窗口。

6. 二次开发:换成自己的小票模板,外加两个提效改造

拿到这份工程之后,最常做的改造就是换成自家的小票样式。步骤其实就三步,但每一步都有容易漏的地方。

第一步改模板。public/print.html里的renderTicket函数整体替换成你的结构,字段名跟着 payload 一起改。改完之后别急着跑完整收银流程,先在设置页挂一个“打印测试页”按钮,传一份写死的假数据进去,验证模板渲染和打印参数。这个按钮在调试期能省掉大量来回操作。

第二步改主进程参数。纸张宽度、pageSize、margins三处要同步改,58mm 和 80mm 切换时尤其容易只改一处。我的建议是把宽度抽成一个常量文件,主进程和模板页各自读一份,改的时候一眼能看到所有关联点。

第三步改 payload。buildTicketPayload是业务对象到打印数据的唯一转换点,加字段、改格式、算优惠明细都在这一个函数里做。别在主进程里做业务计算,主进程只负责搬运。

除了换模板,还有两个改造点性价比很高,我自己每个项目都会加:

改造点改动位置解决什么问题
打印失败自动进重打队列doPrint返回值 + 渲染进程 store缺纸、卡纸后不用手抄订单
打印前静默预览截图webContents.capturePage上线前批量核对模板效果
打印机自检页设置页新增按钮 +print:test通道换机器时快速验证驱动和纸张
小票流水号落库payload 增加ticketNo并回写重打时不会打出重复单号

重打队列这个改造值得多说一句。做法是在渲染进程维护一个failedTickets数组,printTicket返回ok: false时把 payload 压进去,收银界面顶部挂一个红色角标显示待重打数量,点开可以逐条重试或者一键全打。这个功能上线之后,门店因为打印机抖动导致的补录工作量基本归零。

预览截图则是用capturePage在打印前抓一张图存到本地,跑一遍回归测试用例,把几十张小票截图摊开看,比一张一张真打出来核对要快得多,也省纸。

有个教训我印象很深。早期做这类项目时,我把打印逻辑直接写在Cashier.vue的事件处理里,也没做串行队列。上线第一周就遇到高峰期连续打单串号,门店只能先把订单记在纸上,事后人工补录。从那以后我每次接打印需求,都强制先把队列、失败重试、自检页这三件事做掉,再写业务界面。打印这东西平时不显眼,真出问题就是收银台前排长队,后悔药没地方买。

希望这些拆解能帮你少绕几个弯,把这份 demo 跑起来之后,先从模板和参数改起,剩下的慢慢加。

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

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

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

立即咨询