做收银项目的朋友应该都遇到过这种需求:结完账,小票自己从打印机里出来,服务员不需要碰任何界面。我第一次真正被这个需求卡住,是在一个餐饮项目上——客户用的是触摸一体机,浏览器里window.print()一弹系统对话框,服务员就傻眼,不是选错打印机就是点了取消,甚至有人直接把对话框当成故障报修。后来切换到Electron,很多人最初接触它是为了“把HTML网页打包成exe”,但真正让它变成生产力工具的,其实是这类系统级能力的解锁:静默打印。
所谓静默打印,就是程序自己决定用哪台打印机、按什么纸张尺寸、要不要背景色,用户全程不感知打印流程的存在。这篇内容适合正在做Electron收银、仓储、医疗、自助终端项目的开发者,我把从打印机设备匹配、隐藏窗口加载内容、印刷参数调优,到批量队列和钱箱联动的完整方案都整理出来,踩过的坑也一并交代清楚。
1. 先拆解“静默”到底静在哪:Web端绕不过去的三个硬伤
1.1 对话框只是表象,最麻烦的是无法感知打印结果
浏览器里做打印,表面上的问题是window.print()会弹系统打印对话框,需要人工点击确认。但如果你顺着这个思路往下想,会发现更麻烦的事还在后面:就算你告诉用户“别管对话框,直接点打印”,你的程序也拿不到打印结果。用户到底点的是确定还是取消?打印机到底有没有开始工作?打印队列是不是堵住了?浏览器一概不告诉你。这种“发出去就不管了”的体验,在无人值守的收银台、自助查询机上是致命的。
Electron的窗口本质是Chromium的渲染进程,但它多了系统集成能力。它可以在主进程里直接调用底层的打印服务,获取打印机列表、发起打印任务、拿回调结果。换句话说,Electron把“打印”从一个浏览器黑盒变成了一个可控的API。
1.2 silent只是其中一个参数,静默是组合出来的效果
很多人以为静默打印就是把silent: true打开,其实这只是最表层的开关。完整的静默至少包含三个层面:
- 界面静默:不弹任何可见的BrowserWindow,没有打印对话框,没有菜单栏闪一下。
- 操作静默:由代码明确指定目标打印机,而不是依赖系统默认打印机。
- 结果可控:打印结束之后,程序能拿到
success和failureReason,再把状态回传给渲染进程做后续提示。
这三个层面缺一个,都会在实际部署时出问题。最典型的场景是:程序指定了打印机A,但用户机器上驱动的名称跟代码里写的不一样,silent: true一开,打印任务直接发给了系统默认打印机,小票从一个完全错误的地方出来了,你还排查半天不知道问题出在哪儿。
1.3 静默打印的适用边界
不是所有场景都适合静默。如果是办公场景下用户需要选择纸张、份数、双面打印,那么强行静默反而添乱。静默打印真正适合的是业务规则明确、打印机固定、打印内容程序生成的封闭场景:POS小票、后厨联打、排队叫号、体检报告、物流面单。先确认自己在什么场景里,才好决定后面怎么做。
2. 打印机设备匹配:type字段、displayName和name之间的隐秘差异
2.1 读懂getPrintersAsync返回的三个名称
Electron提供webContents.getPrintersAsync()获取打印机列表,返回的PrinterInfo里有几个字段经常被忽略,却在线上出问题时扮演重要角色:
| 字段 | 含义 | 常见坑 |
|---|---|---|
name | 设备名,传给deviceName时用的值 | Windows网络打印机可能是\\server\printer格式,Linux可能是CUPS队列名 |
displayName | 展示给用户看的友好名称 | 不同驱动会带后缀,比如“XP-58C (副本 1)” |
description | 驱动信息、端口信息 | 有时是空字符串 |
isDefault | 是否为系统默认打印机 | 不指定设备时会用到它 |
我见过不少开发者直接把displayName当成deviceName传进print(),然后在Windows上碰运气——有些驱动两者恰好一样就正常,换了驱动就开始乱打。实际上应该优先用name字段作为deviceName的值,displayName只用于匹配关键词和展示。
2.2 一个能扛住环境差异的匹配函数
生产环境里,不可能让用户去配置文件里填一长串\\192.168.1.100\EPSON TM-T88VI这种名字。更靠谱的做法是维护一个“打印机别名表”,把门店常见的名称关键词存下来,例如TM-T88、XP-58、58mm、Receipt,然后按优先级匹配。
我常用的匹配逻辑是这样的:
function matchPrinter(printers, keyword = '') { if (!keyword) { return printers.find(p => p.isDefault) || printers[0] } const lower = keyword.toLowerCase() // 先精确匹配 const exact = printers.find(p => p.name.toLowerCase() === lower || (p.displayName && p.displayName.toLowerCase() === lower) ) if (exact) return exact // 再模糊匹配 const partial = printers.find(p => p.name.toLowerCase().includes(lower) || (p.displayName && p.displayName.toLowerCase().includes(lower)) ) if (partial) return partial // 兜底 return printers.find(p => p.isDefault) || printers[0] }这里的核心思路是:精确匹配 > 关键词包含 > 默认打印机兜底。注意displayName可能为undefined,所以取值时要加一层判断,否则在Linux环境上很容易直接抛异常。
2.3 匹配不到打印机时的处理策略
如果getPrintersAsync()返回的是空数组,通常不是Electron的问题,而是系统层面就没有可用的打印队列。我在Linux服务器环境踩过一次:CUPS服务没起来,接口返回空列表,代码还一路往下走,最后print()回调直接返回failed。所以主流程里必须先判断:
if (!printers.length) { return { ok: false, reason: 'no-printer' } }另外建议在设置页做一个“测试打印”按钮,把getPrintersAsync()返回的完整列表以JSON形式展示给实施人员。这一步能省掉大量现场排查时间——很多打印机名称跟业务方口头描述的完全对不上。
3. 核心方案落地:隐藏窗口 + 内容注入 + print三件套
3.1 为什么选择隐藏BrowserWindow,而不是直接打印当前页面
Electron里print()方法挂在webContents上,理论上当前窗口也能打印。但直接打印主窗口会有几个问题:
- 页面里带着按钮、导航栏、滚动条,你要额外写一套复杂的
@media print样式把界面元素隐藏掉; - 如果窗口正在被用户操作,打印期间页面抖动或者样式变化,会影响渲染结果;
- 主窗口HTML往往包含大量业务组件,打印无关的JS报错会直接干扰打印流程。
所以更干净的做法是单独创建一个隐藏的BrowserWindow,只用来承载打印内容。这个窗口的生命周期跟主窗口完全隔离,打印完成、销毁窗口,对主业务没有任何副作用。
3.2 打印内容怎么传进去:三种方式对比
要在隐藏窗口里渲染出打印内容,核心是把HTML字符串交给这个窗口去加载。我试过三种方式,各有适用场景:
- data URL方式:把HTML字符串
encodeURIComponent后拼成data:text/html;charset=utf-8,...,简单直接,适合内容不大、图片用Base64或纯文本的小票。 - 临时HTML文件:把HTML写到
app.getPath('temp')目录,再用loadFile()加载。适合HTML很大、包含大量静态资源引用的场景,也方便事后排查——文件还留在临时目录里,可以打开看。 - 本地HTTP服务:用
http.createServer起一个随机端口的本地服务渲染模板,适合对接Vue3等前端框架,把动态数据渲染好的DOM片段交过来。
我实际项目里,小票场景用data URL就够了,但面单打印因为要嵌入多张图片,临时文件方式更稳。
3.3 一个可运行的主进程打印模块
下面这段代码我尽量写得完整,涵盖了创建隐藏窗口、加载HTML、匹配打印机、发起打印、返回结果的全过程:
const { app, BrowserWindow, ipcMain } = require('electron') const { promisify } = require('util') function createPrintWindow() { return new BrowserWindow({ show: false, autoHideMenuBar: true, webPreferences: { sandbox: true } }) } function loadHtml(win, html) { const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(html) return win.loadURL(dataUrl) } ipcMain.handle('print:html', async (event, payload) => { const { html, keyword } = payload || {} const win = createPrintWindow() try { await loadHtml(win, html || '<html><body>empty</body></html>') const printers = await win.webContents.getPrintersAsync() if (!printers.length) { return { ok: false, reason: 'no-printer' } } const target = matchPrinter(printers, keyword) const result = await new Promise((resolve) => { win.webContents.print( { silent: true, printBackground: true, deviceName: target.name, margins: 'none' }, (success, failureReason) => { resolve({ ok: success, reason: failureReason }) } ) }) return result } catch (err) { return { ok: false, reason: err.message } } finally { win.destroy() } })这段代码有几个细节值得说明。第一,loadURL本身返回Promise,加载失败会走catch;第二,print()是回调风格,需要用Promise包装一下,否则在ipcMain.handle里没法直接await;第三,win.destroy()放在finally里,保证无论成功失败,隐藏窗口都不会泄漏。
3.4 资源加载时序:did-finish-load不等于渲染完成
很多人遇到过一个现象:打印出来是白纸或者半截内容。原因往往是HTML里有图片或异步渲染的内容,窗口触发did-finish-load时图片其实还没加载完,print()已经把当前DOM状态送去打印了。
我现在的处理方式是:把关键图片都转成Base64内联,保证HTML字符串本身是自包含的。如果HTML是通过Vue渲染后拿到的DOM片段,要求前端先把图片完全加载完成再交给主进程。实在有外部图片的需求,可以往HTML注入一个标记对象,然后轮询执行JS判断就绪状态:
async function waitForPrintReady(win, timeoutMs = 5000) { const start = Date.now() while (Date.now() - start < timeoutMs) { const ready = await win.webContents.executeJavaScript( 'window.__printReady === true' ) if (ready) return true await new Promise(r => setTimeout(r, 100)) } return false }对应的HTML里需要在图片加载完成后设置window.__printReady = true。这种方式比固定setTimeout硬等更靠谱,因为不同机器加载速度差异很大。
4. 打印参数与样式适配:热敏纸、标签纸、A4不是一回事
4.1 print()参数逐项扫盲
webContents.print()的参数里,除了最常见的几个,还有一批直接影响输出效果的字段,列成表格看清楚:
| 参数 | 类型 | 说明与建议 |
|---|---|---|
silent | boolean | 为true时静默打印,非静默调试时设为false |
printBackground | boolean | 打印背景色和背景图片,小票需要,A4文档一般不需要 |
deviceName | string | 目标打印机设备名,优先用name字段 |
margins | string | default/none/printableArea/custom |
landscape | boolean | 是否横向打印,面单/标签常需要 |
scaleFactor | number | 缩放比例,100为不缩放,系统DPI异常可调 |
copies | number | 打印份数,慎用,队列里控制份数更可控 |
pageRanges | object | 页码范围,很少用 |
dpi | object | 指定dpi,例如{ basic: 203 } |
duplexMode | string | simplex/shortEdge/longEdge |
实际用得最多的是前四个。dpi在驱动不听话时有用,比如某些标签打印机默认dpi跟纸张尺寸不匹配,强制指定之后尺寸才对。
4.2 @page与margins的协同关系
控制打印边距有两条路,一条是CSS里的@page规则,一条是Electron的margins参数,它们会叠加生效。如果你在@page里写了margin: 0,又在print()里传了margins: 'default',最终反而会有系统默认边距加进来。
我一般遵循这样的规则:
- 热敏小票:
@page { size: 80mm auto; margin: 0; },同时print()传margins: 'none'。 - A4文档:不在CSS里写
@page,由print()的margins控制。 - 标签纸:根据实际标签尺寸设置
@page size,并调整webPreferences里offscreen关闭状态以避免分辨率干扰。
要注意,Chromium对@page size里的auto高度支持有限,不同Electron版本表现有差异。稳妥的做法是用固定高度,比如size: 80mm 90mm,或者干脆让内容自然撑高,配合margins: 'none'。
4.3 小票模板的CSS调试心得
一个80mm热敏小票的CSS骨架,我通常这样写:
@page { size: 80mm auto; margin: 0; } body { margin: 0; padding: 0; width: 80mm; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 12px; color: #000; background: #fff; } .bold { font-weight: 700; } .center { text-align: center; } .divider { border-top: 1px dashed #000; margin: 4px 0; }打印调试时,最有用的技巧是先打印到PDF再去看实际效果。Electron没有直接暴露“打印到PDF”的静默接口,但你可以用系统里的“Microsoft Print to PDF”或macOS的“存储为PDF”这类虚拟打印机,先把内容跑一遍,检查切边、换行、字体问题。否则每调一次CSS就烧一张纸,效率太低。
字体也是一个隐蔽的坑。Windows下开发时用的“微软雅黑”在Linux部署机器上可能不存在,打印出来变成宋体,宽度就全变了。如果跨平台部署,建议小票字体统一用系统自带的无衬线字体,或者把字体文件和打印内容一起打包分发。
4.4 系统缩放与scaleFactor的坑
Windows系统常见100%、125%、150%三种缩放设置。Chromium在渲染时会自动适配DPI,但打印时,这个适配可能会让内容比预期的大或小。比如同为80mm宽的纸,在150%缩放的机器上打出来字体偏大,右侧内容被裁掉。
遇到这种问题,我的办法是先读取系统缩放比例,然后在print()里动态调整scaleFactor:
const display = screen.getPrimaryDisplay() const scale = display.scaleFactor || 1 const printScale = Math.round(100 / scale)当然这会引入内容整体缩小的副作用,所以最根本的做法还是:给收银机统一系统缩放配置。这个可以在实施清单里作为一条写进去,跟打印机别名表一起交给现场人员。
5. 实战排查:打印没反应、回调false、内容错位
5.1 silent:true没反应的完整排查链路
我在项目群里被问得最多的一句话是:“代码跑起来了,打印没反应。”遇到这种问题,按下面的链路排查,基本能定位90%的故障:
- 先把
silent改成false,调用相同的打印逻辑。如果能正常弹出打印对话框,说明API链路是通的,问题出在设备匹配或打印机状态。 - 打印当前机器上
getPrintersAsync()的结果,看deviceName跟代码里matchPrinter匹配出的设备是否一致。 - 在系统设置里确认该打印机状态不是“脱机”或“暂停”。Windows下用“打印机队列”窗口看是否有卡住的任务。
- 检查打印内容HTML本身。用能显示页面的窗口加载同一份HTML,用
webContents.capturePage()截图,确认渲染结果不是白屏。 - Linux环境优先检查CUPS服务状态:
systemctl status cups,很多“Electron打印不了”的问题其实是CUPS挂了。
5.2 failureReason都在说什么
当print()回调返回success: false时,failureReason往往只有几个笼统的单词,但含义完全不同:
| 失败原因 | 常见场景 |
|---|---|
cancelled | 打印任务被系统取消,常见于打印机脱机或者驱动弹了错误框 |
failed | 底层打印服务拒绝任务,Linux下最常见 |
denied | 权限不足,少见,但macOS访问打印机权限未开启时会遇到 |
注意,cancelled不一定代表用户点了取消,很多打印机驱动在连接异常时也会以“cancelled”收尾。所以收到失败结果后,不要直接提示“用户取消了打印”,而是要引导检查打印机连接状态。
5.3 并发打印:多次print一起调用,后一次永远不执行
收银场景经常一单要打小票、后厨单、发票好几份,如果代码里连发三次print(),第二次和第三次经常“消失”。这不是Electron抽风,而是打印服务通常只允许同一时刻一个打印任务,后面的任务进不了队列。
解决思路是做一个简单的串行队列,保证一次只发一个打印任务:
let printing = false const taskQueue = [] function enqueuePrint(task) { return new Promise((resolve, reject) => { taskQueue.push({ task, resolve, reject }) drainQueue() }) } async function drainQueue() { if (printing) return printing = true while (taskQueue.length) { const { task, resolve, reject } = taskQueue.shift() try { resolve(await task()) } catch (err) { reject(err) } // 给打印服务留一点缓冲,避免连续任务被吞 await new Promise(r => setTimeout(r, 200)) } printing = false }这个队列看起来简单,但很管用。每条任务之间留200毫秒,避免了大多数打印机驱动对瞬时并发任务的敏感反应。
5.4 pnpm打包Electron后打印模块异常
热词里提到“pnpm配置electron打包”,这个我是有切身体会的。pnpm默认用符号链接管理依赖,Electron的二进制包在某些版本下会被链接得七拐八拐,导致打包后打印功能直接失效。解决办法有两类:
- 在项目根目录的
.npmrc里设置node-linker=hoisted,让依赖安装方式退回到扁平结构,兼容性最好,代价是安装目录变大。 - 使用
pnpm approve-builds或配置onlyBuiltDependencies允许Electron执行postinstall脚本,否则node_modules/electron/dist可能压根不存在。
另外,Electron的下载源在国内不配置镜像会非常痛苦,在.npmrc里加上electron_mirror=https://npmmirror.com/mirrors/electron/能省大量时间。如果项目里还引用了serialport这类原生模块,打包时记得放到asarUnpack里,否则原生.node文件在打包后的asar包里调用不到。
6. 从单次打印到业务闭环:批量、钱箱与状态回传
6.1 批量打印的正确姿势
批量打印场景里,最怕的不是慢,而是“打到一半不知道打了哪几张”。我现在的做法是把“生成HTML”和“发送打印”分成两步:先把要打印的内容全部生成好,缓存到数组里,再逐个放进6.3那个队列串行执行。每完成一个任务,就更新数据库状态,这样即使程序中途崩溃,重启后也能根据状态续打。
一个额外的经验:批量打印不要循环里多次创建BrowserWindow,创建一个窗口可以复用多次加载不同HTML。每次loadURL之后等待加载完成、打印、再loadURL下一个内容,性能比频繁创建销毁窗口稳定得多。
6.2 小票打印后自动弹钱箱:serialport的常见联动
收银场景里,小票打完之后顾客要付钱,钱箱需要自动弹开。这已经不是Electron的打印API范围,而是通过串口往打印机发指令。现在餐饮门店常用接串口的钱箱或带钱箱接口的票据打印机,Electron主进程可以借助serialport模块直接发送十六进制指令。
常见的ESC/POS开钱箱指令是这样的:
const { SerialPort } = require('serialport') function openCashDrawer(portPath = 'COM3') { const port = new SerialPort({ path: portPath, baudRate: 9600, autoOpen: false }) port.open(() => { // 常见开钱箱指令,不同厂商有差异,务必以设备手册为准 port.write(Buffer.from([0x1b, 0x70, 0x00, 0x19, 0xfa])) setTimeout(() => port.close(), 200) }) }注意这里不要想当然:不同品牌打印机的钱箱指令可能不同,尤其是波特率,有的是9600,有的是2400,需要调设备手册。另外serialport是原生模块,打包时的asarUnpack配置必须带上,否则打包后打开串口会报错。
6.3 前端如何感知打印结果
静默打印不是“打出去就完了”,业务上需要知道打印到底成没成功。我通常用ipcRenderer.invoke调用主进程的print:html方法,收到返回结果后,在界面上做提示:
const result = await window.api.printHtml({ html: receiptHtml, keyword: 'TM-T88' }) if (!result.ok) { // 这里根据 result.reason 分级处理 // no-printer: 引导进入打印机配置页 // failed: 提示检查打印机连接后重试 }打印失败时,不要直接把技术报错甩给用户。你要在渲染进程里做一层翻译,什么原因给什么提示,同时在日志里保留原始failureReason,方便远程排查。
6.4 设置页的“测试打印”是刚需
最后聊一个看起来和静默打印无关、实际上非常关键的功能:设置页面里一定要有打印机下拉框和“测试打印”按钮。下拉框选项直接来自getPrintersAsync()的displayName,选完之后把name保存到本地配置文件。这样实施人员到现场第一件事就是打开设置页,选打印机、点测试打一张,确认没问题再收工。
我在项目里见过太多这种情况:开发机打印正常,到了客户现场就静默失败,结果发现客户机器上打印机的品牌型号跟开发机完全不一样,程序里写死的deviceName自然匹配不上。把这个配置开放出来,问题就变成了“选一下打印机”这么简单。这一点我认为比任何技术优化都重要。
最后再分享一个调试习惯
用了这么久Electron静默打印,我养成了一个固定习惯:第一次调试永远先不静默。把silent设为false,弹一次系统打印对话框,确认目标打印机、纸张、内容都对,再切回true去测自动流程。这样能把“参数配错”和“代码逻辑错”两类问题快速分开,省得对着一个什么都没有的打印队列瞎猜。另一个小技巧是保留一份“打印内容快照”——每次打印前把HTML存到日志目录,出问题可以直接打开快照看内容,不必跑到现场介入。静默打印的难点从来不在API本身,而在你对自己程序的运行环境到底了解多少。动手之前把这台机器、这台打印机、这卷纸的脾气摸清楚,剩下的其实就是一遍遍测试而已。