简介:这份示例工程演示如何在 Electron 与 Vue CLI3 搭建的桌面应用中完成小票打印,面向需要为收银、点餐、门店管理等场景加入打印能力的前端开发者,重点解决打印机选择、打印参数配置以及静默出票的落地问题。压缩包共 25 个文件,约 117KB,以 14 个 JavaScript 文件承载主进程与业务逻辑,3 个 Vue 单文件组件组织界面,2 个 JSON 维护依赖与工程配置,2 个 HTML 分别对应应用入口与打印模板,另有图标和说明文档,体量轻便,便于直接运行调试。目前已有 3665 人学习。工程按主进程与渲染进程分层组织,background.js 承担窗口创建与打印调用,config 与 plugins 目录存放配置及打印相关封装,并借助本地存储记录用户选定的打印机名称,形成已配置则直接出票、未配置则弹出设置框的完整链路,print.html 可作为小票排版样式的改造起点,适合作为二次开发脚手架参考。
1. 58mm 热敏纸上的第一行:electron-vue-print-demo 到底打印什么
electron-vue-print-demo 是一个用 electron+vuecli3 搭起来的小票打印示例,核心语言是 JavaScript,目标很直接:把 Vue 页面里的订单数据变成一张 58mm 或 80mm 热敏小票。它适合已经有一个桌面端管理页、又不想把打印逻辑塞进浏览器弹窗的开发者。小票打印和普通 A4 打印不是一回事,纸宽固定、边距窄、字体小、长单会跨页,设备名还经常在开发机和收银机上不一致。这个示例的价值在于把“预览页面、主进程调用、打印参数、模板样式”放在同一条链路上,让你先看到一张能出纸的票,再去替换成自己的订单字段。JavaScript 在这里既负责 Vue 模板渲染,也负责 Electron 主进程的打印调度,边界清楚后,后面调设备和纸张才有抓手。
2. 拆开 electron+vuecli3 工程:主进程、渲染进程与打印链路怎么接
打印小票最容易翻车的地方,不是 CSS 写得多漂亮,而是链路没有拆开:Vue 页面负责拼数据,Electron 主进程负责找打印机、开隐藏窗口、调用系统打印接口。开发时用electron:serve起窗口,打包后主进程和渲染进程的路径会变,设备名也会变。先把工程骨架和 IPC 约定理顺,后面调纸张参数才不会一边改模板一边猜驱动。
2.1 工程骨架与依赖边界
electron+vuecli3 常见骨架是 Vue CLI 负责渲染进程,Electron 插件负责主进程入口,主进程入口一般放在src/background.js,预加载脚本放在src/preload.js,Vue 页面还是src/main.js、App.vue和业务组件。打印链路要明确:Vue 只准备 payload,主进程负责找打印机、开隐藏窗口、调webContents.print。不要在渲染进程里直接require('electron'),contextIsolation打开后拿不到,而且把 Node 能力暴露给页面也不安全。
常见脚本配置如下,脚本名要和项目实际配置对齐,不要照抄后直接运行:
{ "scripts": { "serve": "vue-cli-service serve", "electron:serve": "vue-cli-service electron:serve", "electron:build": "vue-cli-service electron:build" } }这里的electron:serve用来启动带主进程的开发窗口,electron:build用来出安装包,serve只起浏览器页面,不能验证打印机列表。参数上要注意两点:开发模式下主进程通常读取WEBPACK_DEV_SERVER_URL,打包后要回退到本地index.html;预加载脚本路径要用path.join(__dirname, 'preload.js'),不要写相对路径。调试打印时优先用electron:serve,因为浏览器页面里没有window.printApi,调用会直接报未定义。
2.2 主进程打印服务与 IPC 约定
主进程负责三件事:创建主窗口、列出系统打印机、把一段 HTML 交给隐藏窗口打印。下面这段代码是一个够用的骨架,重点看getPrintersAsync、pageSize和silent三个参数:
// src/background.js const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') let mainWindow = null function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }) if (process.env.WEBPACK_DEV_SERVER_URL) { mainWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL) } else { mainWindow.loadFile(path.join(__dirname, 'index.html')) } } async function listPrinters(webContents) { if (typeof webContents.getPrintersAsync === 'function') { return webContents.getPrintersAsync() } return webContents.getPrinters() } async function printTicket(payload, sourceWebContents) { const { deviceName, html, widthMm = 58, heightMm = 120 } = payload const receipt = new BrowserWindow({ show: false, width: 400, height: 800, webPreferences: { contextIsolation: true, nodeIntegration: false } }) await receipt.loadURL('data:text/html;charset=UTF-8,' + encodeURIComponent(html)) await new Promise((resolve) => setTimeout(resolve, 300)) return new Promise((resolve, reject) => { receipt.webContents.print({ silent: Boolean(deviceName), printBackground: true, deviceName, margins: { marginType: 'none' }, pageSize: { width: widthMm * 1000, height: heightMm * 1000 } }, (success, failureReason) => { receipt.destroy() if (success) { resolve({ success: true }) } else { reject(new Error(failureReason || 'print failed')) } }) }) } app.whenReady().then(() => { createWindow() ipcMain.handle('print:list', (event) => listPrinters(event.sender)) ipcMain.handle('print:ticket', (event, payload) => printTicket(payload, event.sender)) })getPrintersAsync返回系统打印队列里的设备信息,name才是后面传给deviceName的值。silent: Boolean(deviceName)的意思是:选了设备就静默打印,不选设备就弹系统打印对话框。margins用none是为了避免热敏纸左右被二次裁边。pageSize的width和height单位是微米,58mm 对应 58000,80mm 对应 80000,heightMm不是纸宽,而是这一张票的页面高度,按商品行数估一个值,长单宁可多留 20mm,别让合计被切掉。隐藏窗口加载完 HTML 后等 300ms,是给字体和图片留加载时间,这个延时不是玄学,二维码和 logo 没加载完就打印,出纸经常是空白。
预加载脚本只暴露两个方法,别把整个ipcRenderer丢给页面:
// src/preload.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('printApi', { list: () => ipcRenderer.invoke('print:list'), ticket: (payload) => ipcRenderer.invoke('print:ticket', payload) })contextBridge把主进程能力挂到window.printApi上,渲染进程只能调用list和ticket,不能任意发 IPC。payload里放deviceName、html、widthMm、heightMm四个字段就够了,订单号、金额、商品行都在 Vue 里拼成 HTML,主进程不碰业务字段。这样后面换模板、换打印机、换纸张,只改参数和模板,不用重写主进程。
2.3 渲染端小票模板与打印样式
渲染端不要直接把整个应用页面丢去打印,只截取小票区域。下面这个 Vue 组件把打印机选择、预览和打印调用放在一起,#receipt是实际打印的 DOM:
<template> <div class="page"> <div class="toolbar no-print"> <select v-model="deviceName"> <option value="">系统打印对话框</option> <option v-for="p in printers" :key="p.name" :value="p.name"> {{ p.name }} </option> </select> <button @click="printReceipt">打印小票</button> </div> <section id="receipt" class="receipt"> <h3>{{ order.shopName }}</h3> <p>单号:{{ order.no }}</p> <table> <tr v-for="item in order.items" :key="item.name"> <td>{{ item.name }}</td> <td>{{ item.qty }}</td> <td>{{ item.price }}</td> </tr> </table> <p class="total">合计:{{ order.total }}</p> </section> </div> </template> <script> export default { data() { return { printers: [], deviceName: '', order: { shopName: '模拟门店', no: 'A0001', items: [{ name: '模拟商品', qty: 1, price: '9.90' }], total: '9.90' } } }, async mounted() { this.printers = await window.printApi.list() }, methods: { async printReceipt() { const html = document.getElementById('receipt').outerHTML const heightMm = Math.max(80, this.order.items.length * 8 + 60) await window.printApi.ticket({ deviceName: this.deviceName, html, widthMm: 58, heightMm }) } } } </script> <style> .receipt { width: 58mm; margin: 0 auto; font-size: 12px; line-height: 1.4; color: #000; } .receipt table { width: 100%; border-collapse: collapse; } .receipt td { padding: 2px 0; } .receipt .total { font-weight: bold; text-align: right; } @media print { .no-print { display: none; } } </style>document.getElementById('receipt').outerHTML只取小票区域,工具栏不会被打进去。heightMm按商品行数估算,items.length * 8 + 60是经验值,商品行多就加高,否则合计行容易跨页。widthMm固定 58,是因为模板 CSS 也按 58mm 写,纸张、CSS、打印参数三处要一致。deviceName为空时走系统对话框,适合第一次接新机器时确认驱动是否正常;设备固定后再把选中的name存起来,改成静默打印。
提示:先让系统打印对话框出纸,再切静默打印。对话框能出纸,说明驱动和纸张基本没大问题,剩下才是设备名和参数的事。
3. 用 WebContents.print 打出第一张票:参数、设备名与静默打印
webContents.print是 Electron 里最稳的小票打印入口,它走系统打印驱动,热敏打印机只要装好驱动,就能像普通打印机一样出票。难点不在调用,而在参数组合:silent决定弹不弹窗,deviceName决定去哪台设备,pageSize决定纸张尺寸,margins决定版心。参数错一个,表现可能是空白、错位、打到虚拟打印机,或者干脆没有回调。
3.1 打印参数表与 pageSize 选择
先把常用参数列清楚,调的时候按表排查,比乱改代码快:
| 参数 | 常见值 | 作用与坑点 |
|---|---|---|
silent | true/false | 静默打印依赖deviceName,设备名不对会失败或落到默认打印机 |
deviceName | 系统打印队列里的name | 必须完全匹配,显示名和队列名可能不是一个 |
printBackground | true | 小票线条、底色、二维码背景需要它 |
margins | { marginType: 'none' } | 热敏纸边距窄,默认边距容易把右侧金额裁掉 |
pageSize | { width, height }微米 | 58mm 用 58000,80mm 用 80000,高度按票长估 |
scaleFactor | 默认 1 | 不要用缩放硬塞内容,容易让金额换行 |
copies | 1 | 多联打印要看驱动支持,不要在小票模板里复制多份 |
pageSize是第一个要固定下来的参数。58mm 打印机不是 CSS 里写width: 58mm就自动认纸,系统打印任务还要告诉驱动页面多大。自定义尺寸用对象,单位是微米;如果传A4这类字符串,热敏打印机会按 A4 分页,出纸会很长。高度不要写死 297mm,小票是卷纸,按这一单的内容估高度,短单 80mm 到 120mm,长单按行数往上加。
3.2 静默打印的调用链和代码
静默打印前先拿打印机列表,再做一次匹配。不要存数组下标,机器重启后队列顺序会变。下面这段代码先精确匹配用户选过的名字,再按关键词兜底:
async function pickPrinter(preferredName) { const printers = await window.printApi.list() const exact = printers.find(p => p.name === preferredName) const byKeyword = printers.find(p => /58|80|POS|Receipt/i.test(p.name)) const target = exact || byKeyword || printers[0] if (!target) { throw new Error('没有找到可用打印设备') } return target.name } async function printSilent(html, savedName) { const name = await pickPrinter(savedName || '') await window.printApi.ticket({ deviceName: name, html, widthMm: 58, heightMm: 120 }) }preferredName是上一次用户选中的设备名,存在localStorage或配置文件里。exact做完全匹配,byKeyword用58、80、POS、Receipt这类关键词兜底,最后的printers[0]只是保底,不建议长期依赖。printSilent把 HTML 和纸张参数传给主进程,主进程看到deviceName非空就静默打印。失败时先看failureReason,常见是cancelled、failed,前者多半是驱动弹了不可见对话框,后者要看设备名和纸张尺寸。
注意:静默打印不是“绕过驱动”,它仍然依赖系统打印队列。驱动没装好、设备离线、纸张尺寸不匹配,
silent: true只会让错误更安静。
3.3 打印预览/系统对话框的回退
第一次接新机器,或者换纸型后,留一个回退入口很必要。把deviceName传空,主进程里的silent就是false,系统打印对话框会弹出来:
async function printWithDialog(html) { await window.printApi.ticket({ deviceName: '', html, widthMm: 58, heightMm: 120 }) }这个入口适合做“打印设置”按钮,让收银员自己选打印机、选纸张、看预览。回退路径不要觉得丢人,现场机器型号杂,驱动版本杂,保留对话框能快速判断是代码问题还是驱动问题。对话框里能正常出票,再把设备名抄回配置,切静默;对话框里也错位,就先查驱动纸张和边距,别继续改 Vue 模板。
4. 小票模板落地:Vue 组件、CSS 分页与二维码/条码
小票模板不是把网页缩小,而是按热敏纸的物理宽度重新排版。58mm 纸的版心通常只有 48mm 左右,80mm 纸的版心 72mm 左右,字号 12px 到 13px,行高 1.35 到 1.4。商品名一长就换行,金额一挤就掉到下一行,二维码一大就超出纸宽。模板要围绕“窄、密、可读”做取舍,别把管理后台的表格直接搬过来。
4.1 58mm/80mm 版心与字号
先把纸张和版心定成两套样式,别用一套 CSS 硬撑两种纸:
| 纸张 | 版心宽度 | 正文字号 | 行高 | 适用场景 |
|---|---|---|---|---|
| 58mm | 48mm | 12px | 1.35 | 外卖、收银、排队取号 |
| 80mm | 72mm | 13px | 1.4 | 商品行多、需要备注和条码 |
对应 CSS 可以这样写,注意@page和body的默认边距都要清掉:
@page { margin: 0; } html, body { margin: 0; padding: 0; } .receipt-58 { width: 48mm; font-size: 12px; line-height: 1.35; } .receipt-80 { width: 72mm; font-size: 13px; line-height: 1.4; } .receipt { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }@page的margin: 0和主进程的margins: none要一起用,只改一处可能仍然有白边。width用 48mm 而不是 58mm,是给左右留一点安全区,热敏打印机有机械误差,写满 58mm 容易切字。print-color-adjust: exact让背景色和线条在打印时保留,不然细线可能被浏览器优化掉。字体优先用系统自带中文字体,别在打印时加载网络字体,隐藏窗口等不到字体加载完就出纸,中文会变成方框。
4.2 表格、分页和长单处理
商品表格用固定布局,列宽写死,长商品名用word-break换行,金额列右对齐:
<table class="items"> <thead> <tr> <th>品名</th> <th>数量</th> <th>金额</th> </tr> </thead> <tbody> <tr v-for="item in order.items" :key="item.id"> <td class="name">{{ item.name }}</td> <td class="qty">{{ item.qty }}</td> <td class="price">{{ item.price }}</td> </tr> </tbody> </table>.items { width: 100%; table-layout: fixed; } .items .name { width: 58%; word-break: break-all; } .items .qty { width: 14%; text-align: center; } .items .price { width: 28%; text-align: right; } .page-break { break-after: page; page-break-after: always; }table-layout: fixed让小票表格按列宽稳定排,不会因为某个商品名太长把金额列挤走。word-break: break-all适合中文和长英文混排,代价是英文单词可能从中间断开,小票场景通常能接受。分页用break-after: page,但热敏纸最好一单一张票,长单优先加高heightMm,而不是真分页;真要分页,表头和合计要重复,否则第二页看不懂。测试时造一个 30 行商品的长单,看合计是否被切、二维码是否还在第一页。
4.3 二维码/条码与图片打印
二维码不要用 CSS 画,生成 data URL 图片最稳。下面这段代码在 Vue 里生成二维码,margin: 0去掉白边,width控制物理尺寸:
import QRCode from 'qrcode' export default { data() { return { qrDataUrl: '' } }, async mounted() { this.qrDataUrl = await QRCode.toDataURL('https://example.invalid/order/A0001', { margin: 0, width: 160, errorCorrectionLevel: 'M' }) } }width: 160是像素尺寸,落到 58mm 纸上大约 20mm 到 25mm,太大挤占商品行,太小扫不出来。margin: 0让二维码边缘贴紧容器,但打印时最好再留 1mm 到 2mm 的 quiet zone,否则热敏纸黑边可能影响识别。errorCorrectionLevel: 'M'是纠错级别,小票有轻微污损时够用,级别越高二维码越密,别盲目拉到 H。图片打印前要等img.complete,或者主进程里统一等 300ms,二维码没加载完就出纸,票上会留一个空框。
条码也是同理,能生成图片就别依赖打印机内置条码指令。系统打印驱动不认 ESC/POS 的条码命令,只有直发指令时才有意义。模板里所有图片都建议转成 data URL,打包后本地路径、相对路径、路由路径都可能变,data URL 不依赖文件系统,少一个黑匣子。
5. 避坑与排查:设备名、中文乱码、打包路径和边距
打印小票的故障很少是单一原因,设备名、驱动、纸张、编码、打包路径会叠在一起。现场排查要按“先对话框、再静默、先系统驱动、再直
本文还有配套的精品资源,点击获取