简介:这是一份面向 Electron 与 Vue 开发者的桌面端打印示例项目,基于 Vue CLI3 搭建,重点演示打印机选择、打印参数设置以及小票静默打印的完整实现思路,适合需要为收银、外卖、门店管理等桌面应用接入本地打印能力的前端或全栈开发者参考。压缩包共 25 个文件,约 117KB,以 JavaScript 逻辑代码和 Vue 单文件组件为主,并包含 JSON 配置、HTML 打印模板、说明文档及图标等配套素材,体量轻便,便于快速运行和二次改造。项目已有 3665 人学习,核心流程覆盖用户触发打印后读取本地存储中的打印机名称、已配置则直接打印、未配置则弹出设置窗口并确认后继续打印等环节,同时展示了主进程与渲染进程的协作方式。读者可据此理解 electron-store 的本地读写、打印窗口与业务页面之间的通信及静默打印落地方法,并在此基础上扩展纸张规格、打印队列和异常处理等能力。
1. 一张 58mm 小票,把这个 demo 的价值说清楚
门店收银台换打印机是常事,麻烦的是后端系统跟着崩。之前用纯浏览器方案做小票打印,window.print()一弹窗,58mm 热敏机在系统对话框里压根不出现——浏览器只认系统那几台设备,用户还得自己在下拉框里翻。后来换成本地客户端方案,也就是这套 electron + vue-cli3 的打印 demo 结构,把「列打印机、定纸宽、静默出纸」三件事拆到主进程做,渲染层只负责把小票 HTML 排好,问题才算收口。它适合两类人:正在给桌面端加小票打印的 JavaScript 前端,以及手里有热敏机、被silent和pageSize参数反复卡住的开发者。下面按链路拆,能直接照着改。
2. 打印链路拆解:主进程握打印机,渲染进程只管排版
2.1 为什么 window.print 在 Electron 里不够用
window.print()在 Electron 渲染进程里能调,但它的行为跟浏览器完全一致:弹系统打印对话框、只允许用户手选设备、页面尺寸由 CSS 的@page或者用户手动设置决定。放到收银场景里,这三个限制全是硬伤——收银员不该看见打印对话框,打印机应该由程序指定,纸宽必须锁死成 58mm 不能让人改。
webContents.print()是真正能控制这些的入口,签名是print(options, callback),options里能带silent、deviceName、pageSize、margins、printBackground、scaleFactor。问题是webContents属于主进程模块,渲染进程拿不到(remote模块早就被废弃了,别在这上面浪费时间)。
所以链路的天然分界线是:主进程负责「找打印机、发起打印、接回调」,渲染进程负责「把小票画出来」。中间用 IPC 串起来。这条线画对了,后面所有参数都有地方放;画错了,就会出现「渲染层想指定打印机但传不过去」这类返工。我一般会在项目一开始就把三个动作定死:列打印机、打印当前页、导出 PDF。这三个覆盖了 95% 的小票需求,剩下的边角料再往上加。
还要提一句安全配置。Electron 现在默认contextIsolation: true,渲染进程直接require('electron')会报错,必须走 preload +contextBridge。这套 demo 的结构就是按这个来的,不要图省事把contextIsolation关掉,后面打包签名、上架审核都会卡。
2.2 vue-cli3 里主进程入口与 preload 的位置
vue-cli3 本身不认识 Electron,需要vue-cli-plugin-electron-builder这层胶水。它约定的目录结构大致是这样,background.js是主进程入口,preload.js单独放,渲染进程还是正常的src/main.js:
├─ src/ │ ├─ background.js # 主进程入口 │ ├─ preload.js # 预加载脚本 │ ├─ main.js # 渲染进程入口 │ └─ views/Print.vue # 小票页面 ├─ vue.config.js └─ package.jsonvue.config.js里要把 preload 路径告诉插件,否则打包后 preload 会丢:
// vue.config.js module.exports = { pluginOptions: { electronBuilder: { // 关键:显式声明 preload,打包时会一并复制 preload: 'src/preload.js', builderOptions: { appId: 'com.example.printdemo', productName: 'PrintDemo', win: { target: 'nsis' }, mac: { target: 'dmg' } } } } }这里有两个参数值得说。preload必须写相对项目根的路径,写成./src/preload.js也能跑,但打包时路径解析容易出岔子,建议统一不带./。appId用反域名格式,跟安装包的注册表项挂钩,改一次要重装才能生效,别等上线前才想起来改。
background.js的最小骨架是这样的,重点是webPreferences里那三行:
// src/background.js const { app, BrowserWindow } = require('electron') const path = require('path') let mainWin = null function createWindow() { mainWin = new BrowserWindow({ width: 1000, height: 700, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // 渲染进程与 preload 隔离 nodeIntegration: false // 渲染进程不直接拿 Node } }) if (process.env.WEBPACK_DEV_SERVER_URL) { mainWin.loadURL(process.env.WEBPACK_DEV_SERVER_URL) } else { mainWin.loadFile(path.join(__dirname, '../dist/index.html')) } } app.whenReady().then(createWindow) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })process.env.WEBPACK_DEV_SERVER_URL是插件注入的,开发态指向 dev server,打包后为空,走loadFile。这两个分支都要测,很多人只在开发态跑通,打包后白屏就是因为这里写死了 URL。
2.3 三个 IPC 通道:list、print、pdf
主进程侧用ipcMain.handle注册,配合渲染进程的invoke,天然是 Promise,比老式的send/on好维护。三个通道的职责划清楚:
// src/background.js 追加 const { ipcMain, BrowserWindow } = require('electron') // 1) 列出系统里所有打印机 ipcMain.handle('printer:list', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) // 新版 Electron 用 getPrintersAsync,老版本是同步的 getPrinters const printers = await win.webContents.getPrintersAsync() return printers.map(p => ({ name: p.name, // 打印时 deviceName 要用这个 displayName: p.displayName, isDefault: p.isDefault, status: p.status })) }) // 2) 打印一段 HTML 字符串 ipcMain.handle('printer:print', async (event, { html, deviceName, width, height }) => { const worker = new BrowserWindow({ show: false, webPreferences: { offscreen: true, javascript: false } }) await worker.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) // 等一帧,确保布局完成再打印 await new Promise(r => setTimeout(r, 120)) const ok = await printTicket(worker, { deviceName, width, height }) worker.destroy() return ok }) // 3) 导出 PDF 留档 ipcMain.handle('printer:pdf', async (event, { html }) => { const worker = new BrowserWindow({ show: false }) await worker.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) const buf = await worker.webContents.printToPDF({ printBackground: true, pageSize: { width: 58000, height: 80000 }, // 单位微米,见第 3 章 margins: { top: 0, bottom: 0, left: 0, right: 0 } }) worker.destroy() return buf.toString('base64') })data:text/html这个写法省掉了临时文件,但要注意两点:一是 HTML 里的外链资源(图片、字体)必须转成 base64 内联,否则隐藏窗口加载不到;二是内容过长时 URL 会超限,稳妥做法是超过 100KB 就改写成loadFile临时文件。
preload 侧只做一件事——把通道暴露给渲染层,不做任何业务逻辑:
// src/preload.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('ticket', { listPrinters: () => ipcRenderer.invoke('printer:list'), print: (payload) => ipcRenderer.invoke('printer:print', payload), toPdf: (payload) => ipcRenderer.invoke('printer:pdf', payload) })渲染层调用就非常干净了:
// src/views/Print.vue 里的一段 const printers = await window.ticket.listPrinters() const target = printers.find(p => p.name === 'XP-58') || printers.find(p => p.isDefault) if (!target) throw new Error('未找到可用打印机') await window.ticket.print({ html: buildTicketHtml(order), deviceName: target.name, width: 58000, height: 80000 })deviceName一定要传p.name而不是p.displayName,这两个字段在部分 Windows 驱动下不一样,传错的表现是print回调返回success: false,failureReason写个Invalid deviceName,很直白但第一次遇到容易懵。
3. 纸宽换算与版面约束:58mm 与 80mm 的 pageSize 怎么写
3.1 pageSize 单位是微米,不是毫米
这是最容易翻车的一个参数。Electron 的pageSize对象里width和height的单位是微米(micrometer),1mm = 1000 微米。你写{ width: 58, height: 80 },Chromium 会理解成 0.058mm,输出一张比头发丝还窄的纸,打印机直接吐白纸。
常用规格的换算我整理成表,直接抄:
| 纸张标称 | 实际可打印宽度 | pageSize.width | 建议 height 起始值 |
|---|---|---|---|
| 58mm 热敏 | 约 48mm | 58000 | 80000 |
| 76mm 针式 | 约 63mm | 76000 | 120000 |
| 80mm 热敏 | 约 72mm | 80000 | 120000 |
| A4 | 210mm | 210000 | 297000 |
height这一列是最容易设错的。小票是连续纸,打印机自己会走纸,pageSize.height只影响 Chromium 的分页逻辑:内容超过这个高度就分页,没超过就按这个高度输出一页。如果你固定写 200mm,而一张单子只有 60mm 内容,某些驱动会老老实实走完 200mm 的纸,收银员手里就是一张长白条。
稳妥做法是量内容高度再设。Chromium 默认 96dpi,1 像素 = 25400/96 ≈ 264.58 微米,换算如下:
// 在隐藏窗口加载完 HTML 后,量出真实内容高度 async function measureHeight(win) { const px = await win.webContents.executeJavaScript( 'Math.ceil(document.body.getBoundingClientRect().height)' ) // 加 8mm 余量,避免最后一行被切 return Math.ceil(px * 25400 / 96) + 8000 }getBoundingClientRect().height比scrollHeight稳,后者在body有 margin 时会多算。+8000是给撕纸留的余量,58mm 机器出纸口到切刀大概 5~10mm,不给余量最后一行合计金额会被切掉一半,这个血泪经验值得记住。
还有一条边界:不同 Electron 版本对printToPDF的pageSize支持不一致,老版本只认'A4'、'A3'这类字符串,传对象会被忽略并回退到 A4。第一次接项目时先打一张测试页,看输出尺寸对不对,别等到批量出单才发现全是 A4 缩放。
3.2 小票模板的宽度、字号与边距
热敏打印机的物理特性决定了排版规则:只有「打」和「不打」两种状态,没有灰度。所以浅灰色文字、大面积背景色、细体字在纸上要么糊成一团,要么完全看不见。我一般按下面这套约束写模板:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> /* 页面尺寸锁死,margin 归零,边距交给内层容器 */ @page { size: 58mm auto; margin: 0; } html, body { margin: 0; padding: 0; width: 58mm; background: #fff; } .ticket { /* 58mm 机器可打印宽度约 48mm,左右各留 1mm 安全区 */ width: 46mm; margin: 0 auto; padding: 2mm 0; font-family: "SimSun", "Microsoft YaHei", monospace; font-size: 12px; line-height: 1.45; color: #000; } .row { display: flex; justify-content: space-between; word-break: break-all; } .row .name { flex: 1 1 auto; padding-right: 2mm; } .row .qty { flex: 0 0 8mm; text-align: right; } .row .amt { flex: 0 0 14mm; text-align: right; } .divider { border-top: 1px dashed #000; margin: 1.5mm 0; } .total { font-size: 14px; font-weight: bold; } .center { text-align: center; } </style> </head> <body> <div class="ticket"> <div class="center">某某超市</div> <div class="center">2024-01-01 12:00:00</div> <div class="divider"></div> <div class="row"><span class="name">矿泉水 550ml</span><span class="qty">x2</span><span class="amt">4.00</span></div> <div class="row"><span class="name">抽纸 三层</span><span class="qty">x1</span><span class="amt">12.50</span></div> <div class="divider"></div> <div class="row total"><span class="name">合计</span><span class="amt">16.50</span></div> <div class="center">谢谢惠顾</div> </div> </body> </html>几个参数逐个解释。@page { size: 58mm auto }里的auto高度在 Chromium 里支持不完整,实际生效的还是print传的pageSize,这行更多是给 PDF 导出的兜底。.ticket宽度 46mm 是 58mm 减去左右各 1mm 安全区再留一点冗余,实测 48mm 满宽打印时右边缘的字偶尔会被切掉半列。
font-size: 12px是 58mm 纸上的下限。203dpi 的热敏头,12px 大约对应 2.5mm 字高,再小就模糊了。想强调金额用 14px 加粗,别用 10px 的细体去塞更多信息——省下的那点宽度换来的是收银员看不清金额,得不偿失。
display: flex在这里是安全的,Electron 内置的 Chromium 版本足够新。但要注意word-break: break-all必须加,商品名里有长英文或数字串(比如条码)时,不加会撑破容器。
3.3 printToPDF 的适用边界
printToPDF和print是两条不同的路,别混着用。print直接把内容送进打印队列,速度快,没有中间文件,适合「出单即走」的收银主流程。printToPDF返回一个 Buffer,主进程可以写盘留档,适合需要补打、需要对账、需要给顾客发电子小票的场景。
// 主进程里保存 PDF,文件名带时间戳和单号 const fs = require('fs') ipcMain.handle('printer:savePdf', async (event, { html, orderNo }) => { const worker = new BrowserWindow({ show: false }) await worker.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) const buf = await worker.webContents.printToPDF({ printBackground: true, margins: { top: 0, bottom: 0, left: 0, right: 0 }, preferCSSPageSize: false // 让它用我们传的 pageSize,而不是 CSS 里的 }) const file = path.join(app.getPath('userData'), 'receipts', `${orderNo}.pdf`) fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, buf) worker.destroy() return file })preferCSSPageSize这个开关容易忽略。设成true时 Chromium 会优先用 CSS 里@page定义的尺寸,设成false才用代码传的pageSize。小票场景我一般设false,尺寸统一在主进程代码里管,改动只在一个地方。
printToPDF的代价是多一次页面加载和多一次磁盘写入,实测一张小票多花 200~400ms。所以别在主流程里同步做,改成「打印成功后异步补一份 PDF」,用setImmediate或者消息队列丢出去,收银员感知不到。
4. 静默打印的三个开关:deviceName、silent、回调
4.1 先把打印机名字拿准
deviceName必须和系统里注册的设备名完全一致,包括空格、大小写、括号。Windows 上装第二台同型号打印机时,系统会自动加后缀,变成XP-58 (副本 1),你代码里写死XP-58就找不到。
正确做法是启动时拉一次列表,按优先级匹配:用户配置里存的设备名 → 系统默认打印机 → 列表第一台可用设备。
async function pickPrinter(win, preferredName) { const list = await win.webContents.getPrintersAsync() // 1) 优先用配置里存的名字,做精确匹配 let target = list.find(p => p.name === preferredName) // 2) 退到系统默认 if (!target) target = list.find(p => p.isDefault) // 3) 再退到第一台状态正常的 if (!target) target = list.find(p => p.status === 0) || list[0] if (!target) throw new Error('系统内无可用打印机') return target }getPrintersAsync返回的对象里,name是打印时用的标识,displayName是给人看的,status是状态码(0 表示正常,其他值跟平台相关)。有些驱动在离线时status仍然是 0,所以别完全信这个字段,最终还是要靠打印回调判断。
macOS 那边要注意,设备名是 CUPS 队列名,可能是XP-58___1这种带下划线的形式,跟前端界面上显示的名字对不上。跨平台项目里别让用户手输设备名,做成下拉选择,把name存进配置。
4.2 silent 生效的硬条件
silent: true是静默打印的总开关,但它不是单独生效的,得同时满足几个条件:
第一,deviceName必须存在且可写。传了一台不存在的设备,Electron 不会报错,直接回调success: false,failureReason里写Invalid deviceName。
第二,打印队列不能卡死。Windows 的 Print Spooler 服务挂了、或者前一个任务卡在队列里,新的静默任务会一直排队,回调很久才触发甚至不触发。
第三,margins的写法跟版本有关。老版本是marginsType: 0(0 表示无边框),新版本是margins: { marginType: 'none' }。写错了不会报错,但边距会按默认值走,58mm 纸打出来内容只有 40mm 宽。
完整的打印函数长这样:
function printTicket(win, { deviceName, width, height }) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('PRINT_TIMEOUT')), 15000) win.webContents.print({ silent: true, printBackground: false, // 小票不需要背景色,关掉省热敏头 color: false, deviceName, margins: { marginType: 'none' }, pageSize: { width, height }, scaleFactor: 100 }, (success, failureReason) => { clearTimeout(timer) if (success) resolve(true) else reject(new Error(failureReason || 'PRINT_FAILED')) }) }) }printBackground: false在小票场景是必开的。热敏纸打背景色等于全黑,既费纸又可能糊住字。scaleFactor: 100是百分比缩放,不需要缩放就别写,有些版本传了反而触发重排。
4.3 用 Promise 包住回调并加超时
上面那段里最重要的不是参数,是那个setTimeout。Electron 的print回调在部分 Windows 驱动上不触发——任务已经进队列了,驱动也出了纸,但回调就是不回来。如果你用await printTicket()直接等,界面会永远卡在「打印中」。
超时设 15 秒是经验值:一张 58mm 小票正常 2 秒内出纸,超过 15 秒基本可以判定卡住了。超时之后别急着销毁窗口,先查一下打印队列里有没有残留任务,有的话清掉再重试:
async function printWithRetry(win, opts, retries = 2) { for (let i = 0; i <= retries; i++) { try { return await printTicket(win, opts) } catch (e) { if (i === retries) throw e // 换一台设备再试:默认打印机兜底 const list = await win.webContents.getPrintersAsync() const fallback = list.find(p => p.isDefault) if (fallback) opts.deviceName = fallback.name await new Promise(r => setTimeout(r, 800)) } } }重试之间要留 800ms 左右的间隔,让打印后台处理程序把上一次的失败任务清掉,紧接着重试大概率还是失败。
5. 避坑:五张打成白纸的小票
5.1 开发态能打,装机后静默打印返回 false
现象:本地npm run electron:serve一切正常,打包成 exe 装到门店电脑上,点打印没反应,日志里failureReason是Invalid deviceName。
原因:开发机上getPrintersAsync返回的第一个设备就是那台测试机,代码里写死或者取了list[0]。门店电脑上打印机顺序不一样,list[0]变成了一台虚拟 PDF 打印机,deviceName自然对不上。
解决:把设备名做成可配置项,存在app.getPath('userData')下的 json 里,启动时用配置名去精确匹配;匹配不到再退到isDefault;再匹配不到弹一次选择框让用户选。这条链要在打包环境里跑一遍,别只在 dev 模式验证。
5.2 小票只出半截,合计金额被切掉
现象:商品列表正常,最后「合计」那一行只剩上半截字,或者整行消失。
原因:pageSize.height设小了,或者设成固定值(比如 80000 微米 = 80mm),而这次订单有 15 个商品,内容高度超过 80mm,Chromium 把超出部分分到第二页,打印机打完第一页就停了。
解决:先量高度再打印,用 3.1 里那段measureHeight;量出来的值再加 8mm 余量。如果订单商品数不固定,必须动态量,不能写死。
5.3 中文全是方块或者糊成一团
现象:英文数字正常,中文变成一个个黑方块,或者字迹边缘发虚、相邻笔画粘连。
原因:两个独立问题。方块是字体缺失——CSS 里指定的字体在目标机器上没装,Chromium 回退到了不含中文字形的字体。发虚是字号太小,12px 以下在 203dpi 热敏头上点阵不够。
解决:字体链写全"SimSun", "Microsoft YaHei", "PingFang SC", monospace,覆盖 Windows 和 macOS;确定性要求高的项目直接把字体文件 base64 内联进 HTML,体积大一点但不会翻车。字号死守 12px 下限,宁可多分几行也别缩字号。
5.4 打印任务进队列但不出纸
现象:代码返回success: true,系统打印队列里能看到任务,但打印机没动静,队列状态一直是「正在打印」。
原因:打印后台处理程序(Windows 的 Print Spooler)或者 USB 端口异常。热敏机大多是 USB 虚拟串口,拔插之后端口号变了,驱动还指着旧的端口。
解决:先打一张打印机自检页(关机按住走纸键开机),能出说明硬件没问题;再看系统「设备和打印机」里那台设备是不是脱机状态;最后重启 Print Spooler 服务。代码层面做的补救是超时 + 重试,但根因在驱动层,代码救不了。
5.5 连续打印时第二张是空白
现象:单次打印正常,连着点两次,第二张小票是空白的,或者打出来是上一单的内容。
原因:复用了同一个隐藏BrowserWindow,第一次print还没走完,就loadURL加载了第二份 HTML,打印引擎抓到的页面状态是中间态。
解决:一次打印一个窗口,打完destroy(),不复用。窗口创建开销不大(隐藏窗口 100ms 左右),比调试空白小票便宜得多。如果非要复用,必须await上一次打印的 Promise 完成后再loadURL,并且加载后等一帧(setTimeout 120ms)再调print。
6. 装机验收:打包后仍能出纸的六个检查点
开发态跑通只是及格线,真正的小票打印问题大多出在打包和装机环节。我一般按下面这张表逐项过,过了才算交付:
| 检查点 | 验证方式 | 不过的典型表现 |
|---|---|---|
| preload 是否打进包 | 安装后打开 devtools 执行typeof window.ticket | 返回undefined,IPC 全部失效 |
| 打印机列表能否拉到 | 界面上点「刷新设备」 | 列表为空,getPrintersAsync报错 |
| 设备名精确匹配 | 选中设备后重启应用,看是否记住 | 每次启动都回退到默认打印机 |
| 纸宽参数是否正确 | 打测试页量实际出纸宽度 | 内容偏左或右侧被切 |
| 中文是否正常 | 测试页带中英文混排 | 方块字或糊字 |
| PDF 留档是否落盘 | 打完查userData/receipts目录 | 目录为空或文件名重复覆盖 |
第三项特别值得单独说。设备名记住这件事,很多实现是存在localStorage里的,渲染进程一清缓存就丢了。稳妥做法是主进程用fs写到app.getPath('userData')下的printer.json,渲染层每次启动通过 IPC 读回来。这样即使用户重装渲染层资源,配置还在。
第六项的文件名重复也踩过。用Date.now()做文件名,同一毫秒内连打两单会覆盖,后来改成单号_时间戳才彻底解决。
有一个习惯从那以后我一直保持着:每次改完打印相关代码,不管改动多小,都强制走一遍「开发态打一张 → 打包 → 装到一台没装过开发环境的机器 → 打一张 → 拔掉打印机再打一张看报错」这条链路。拔打印机那一步看着多余,但它能暴露 90% 的错误处理缺失——deviceName失效时你的代码是弹提示还是静默失败,只有真拔一次才知道。希望帮到你。
本文还有配套的精品资源,点击获取