简介面向用 Electron 与 Vue CLI 3 开发桌面打印功能的前端工程师这份 demo 以完整小票打印为例演示如何在渲染进程中触发打印、读取本地 electron-store 中的打印机配置并在未设置时弹出配置框、设置后静默输出覆盖了常见的多步骤交互与主进程/渲染进程协作场景。资源共 25 个文件以 JS 逻辑文件为主另有 Vue 组件、JSON 配置和 HTML 页面压缩包仅 117KB结构精简便于直接对照改造。整个项目目录很精简示例页面、Vue 组件与 Electron 主进程代码分离便于二次开发。目前已有 3660 人学习适合有一定 Vue 和 Electron 基础、希望快速落地小票打印或批量静默打印需求的开发者。源码把打印机设置、保存与调用拆成清晰流程并给出主进程与渲染进程的通信写法通过查看打印流程、设置面板和 store 存取逻辑能节省自行踩坑时间快速迁移到自己的项目中。1. electron-vue-print-demo 解决的是 Vue 技术栈下桌面小票打印的落地问题electron-vue-print-demo 解决的是 Vue 技术栈下桌面小票打印的落地问题。浏览器里直接window.print()不是不能用而是页面缩放、页边距、打印机驱动里的默认纸张设置只要有一项不对打出来的小票就对不齐换一台电脑就要重新调一遍这对门店场景来说太玄学了。基于 Vue CLI 3 搭一个 Electron 壳把打印环境锁在应用内主进程控制打印机渲染进程只负责把订单数据填进模板。这套思路适合门店收银、外卖贴单、后勤票据这类需要固定小票格式和固定纸宽的桌面应用。读这份笔记的人最好已经会用 Vue 写组件但不一定碰过 Electron 主进程我会把打印链路从 IPC 到纸宽参数都拆开讲。2. 环境与选型为什么小票打印要交给 Electron 而不是浏览器2.1 三种打印方案的取舍先说结论这个 demo 选的链路是「Vue 组件渲染小票模板 → 新开一个小窗口加载模板页 → 主进程拿到打印指令后把 webContents 直接交给系统打印队列」。为什么不在浏览器里直接打印因为浏览器对纸张的控制颗粒度不够。window.print()走的是页面默认布局用户可以随便缩放、改边距、多打一页空白生成的 PDF 又要人工再点一次打印不适合收银台这种一单接一单的场景。三种方案对比下来各自边界很清楚打印方案实现方式适合场景主要短板window.print()浏览器内置打印临时打印、报表预览样式受页面缩放影响纸张控制弱容易被用户改乱生成 PDF 再打印前端渲染 PDF对账单据、需要留档的场景多一步人工操作无法静默连打ElectronwebContents.print()主进程直接调用系统打印队列小票、贴单、后厨单要自己搭 IPC 和模板页上手有门槛Electron 方案最核心的价值是把 Chromium 和 Node 打包成桌面应用打印参数在代码里写死操作员在界面里只管点打印按钮不需要也不会接触到打印机设置。这个 demo 正是为了把这条链路跑通才存在的模板页和打印机参数全部由应用侧控制和浏览器环境彻底隔离。2.2 创建项目与第一版目录结构用 Vue CLI 3 创建项目并接入 Electron命令不多但每一步都要确认版本关系。Vue CLI 3 对应的 electron-builder 插件会自动生成主进程入口不用自己手动去改复杂的 webpack 配置。# 用 vue cli 3 创建项目预设选默认即可后续手动补需要的依赖 vue create electron-vue-print-demo # 进入项目目录接入 electron-builder 插件 cd electron-vue-print-demo vue add electron-buildervue add electron-builder会注册两个脚本electron:serve用于开发调试electron:build用于打包分发。生成后的目录结构大致是这样src/ ├── background.js # Electron 主进程入口窗口和打印逻辑都在这 ├── main.js # Vue 渲染层入口负责挂载应用 ├── App.vue ├── router/ # 路由配置打印页单独做一个路由 │ └── index.js ├── components/ │ └── TicketLine.vue # 小票明细行组件 └── views/ ├── OrderMain.vue # 下单主界面 └── PrintTicket.vue # 小票打印模板页background.js和main.js是两个互相独立的进程入口。background.js跑在 Node 环境里拥有系统打印队列的访问权限main.js跑在 Chromium 渲染环境里只负责界面交互。开发模式下主进程会通过process.env.WEBPACK_DEV_SERVER_URL加载 Vue 开发服务器所以你在OrderMain.vue里改动样式主进程窗口里的内容也会跟着热更新。2.3 主进程窗口配置里最容易忽略的三个 webPreferences 参数打印窗口和普通业务窗口要分开配置打印页不需要用户看到所以窗口默认隐藏只在需要时短暂显示或干脆一直隐藏。下面这段是 background.js 里创建打印窗口的最小配置// src/background.js const { BrowserWindow } require(electron); const path require(path); function createPrintWindow() { const win new BrowserWindow({ width: 240, height: 340, show: false, // 打印窗口不需要展示给用户 webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, sandbox: false } }); return win; }这里三个参数踩过不少坑。contextIsolation: true是现代 Electron 推荐的隔离方式渲染进程拿不到 Node 能力只能通过 preload 暴露的接口通信nodeIntegration: false是为了防止 Vue 组件里有人顺手写了require(child_process)sandbox: false则是给 preload 脚本留后门因为打包后如果沙箱开启preload 里的require(path)会直接失效。提示把小票模板做成独立窗口而不是在主界面上直接打印好处是样式完全隔离主界面布局改了不会影响到小票输出。打印窗口和业务窗口各用各的 BrowserWindow 实例互不干扰。3. 从渲染进程到打印机把 IPC 打印链路搭起来3.1 主进程里用 ipcMain.handle 收打印指令主进程是唯一能访问系统打印队列的角色。渲染进程点击打印按钮后把订单 ID 通过ipcMain.handle发给主进程主进程负责加载模板页、执行打印、关窗口。注意这里传的是订单 ID 而不是整个订单对象避免 url 参数超长和脏数据注入。// src/background.js const { ipcMain, BrowserWindow } require(electron); const path require(path); ipcMain.handle(print-ticket, async (event, payload) { const { orderId, printerName } payload; const printWindow new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false } }); // 开发环境加载开发服务器路由生产环境加载打包后的 index.html const url process.env.WEBPACK_DEV_SERVER_URL ? process.env.WEBPACK_DEV_SERVER_URL /#/print-ticket?orderId orderId : file:// path.join(__dirname, dist/index.html) /#/print-ticket?orderId orderId; await printWindow.loadURL(url); printWindow.webContents.print({ silent: true, printBackground: true, deviceName: printerName || undefined }, (success, failureReason) { if (!success) console.error(打印提交失败, failureReason); printWindow.close(); }); });silent: true表示不弹系统打印预览框直接把内容送往打印机printBackground: true是必须的小票如果有底色或条码默认会被浏览器丢掉deviceName不传时会走系统默认打印机所以如果门店只装了一台小票机这段代码其实可以不用传设备名。回调里的success只代表任务进入系统打印队列不代表纸真的出来了这一点第 5 章会详细讲。3.2 preload 只用 contextBridge 暴露一个方法contextIsolation开启后渲染进程的window对象是纯净的需要通过 preload 脚本用contextBridge把能力注入进去。这个 demo 里只暴露两个方法一个是打印一个是获取打印机列表。// src/preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(printAPI, { runPrint: (payload) ipcRenderer.invoke(print-ticket, payload), getPrinters: () ipcRenderer.invoke(get-printers) });runPrint对应主进程里的print-ticket事件getPrinters对应另一个负责枚举设备的处理器。这里的白名单思想比直接暴露ipcRenderer要安全得多——Vue 组件只能调到这两个方法就算某一处被注入恶意代码也无法向主进程发送任意事件。3.3 渲染进程的调用封装与失败兜底渲染进程里不要在组件里直接写死window.printAPI.runPrint()最好包成一个可复用的函数统一处理错误和参数来源。下面这段是模拟项目里实际使用的封装// src/utils/print.js export async function printReceipt(order) { try { const result await window.printAPI.runPrint({ orderId: order.id, printerName: window.printerConfig?.defaultDeviceName }); if (!result) { // 主进程里 abort 时会返回 undefined console.warn(打印任务未正常返回); } } catch (err) { // renderer 无法访问打印队列错误只能提示给操作员 this.$message.error(打印任务提交失败 err.message); } }渲染进程不创建窗口、不访问文件系统、不读设备列表只把订单 ID 和打印机名透传给主进程。window.printerConfig是启动时从主进程同步过来的配置对象里面存的是当前门店的默认打印机名不一定每台都用默认设备所以也可以留空让主进程决定。3.4 调试 IPC 时看什么IPC 链路不通的典型表现是点打印按钮没有任何反馈。第一步先看 background.js 的终端输出主进程代码里的console.log会直接打印在 electron 的控制台第二步在 preload 脚本里加一段console.log(preload loaded)确认 preload 有没有被执行——如果连这个都没有说明 preload 路径配置错了。第三步检查渲染层调用是否真的发起打开 devtools 在printReceipt函数里打断点看ipcRenderer.invoke返回的 Promise 是否 reject。4. 小票模板与 58mm 纸宽样式对齐才是打印的命门4.1 模板页面的 HTML 骨架小票页面结构不需要花哨一个纵向列表足够了。店名、订单号、明细、金额合计、备注从上往下排。这个 demo 里模板页是独立路由PrintTicket.vue通过 query 参数里的orderId去拉取订单详情数据。!-- src/views/PrintTicket.vue -- template div classticket div classshop-name示例超市/div div classmeta-row单号{{ order.no }}/div div classmeta-row{{ order.createdAt }}/div div classseparator/div div v-for(item, idx) in order.items classgoods-line :keyidx span classgoods-name{{ item.name }}/span span classgoods-qtyx {{ item.qty }}/span span classgoods-amount{{ (item.price * item.qty).toFixed(2) }}/span /div div classseparator/div div classtotal-row span合计/span span{{ order.total.toFixed(2) }}/span /div div classtotal-row span实收/span span{{ order.paid.toFixed(2) }}/span /div div classtotal-row span找零/span span{{ (order.paid - order.total).toFixed(2) }}/span /div div classfooter-note{{ order.footerNote }}/div /div /template模板里刻意没有放图片格式的 logo也没有二维码位。原因是热敏打印机的图像渲染非常依赖驱动图片会让整张票的打印速度变慢而且容易出现深浅不一的条纹。如果非要放二维码能用 SVG 就不要用 canvascanvas 在打印场景里被截断的概率远高于普通图片。4.2 纸张宽度和页面级样式58mm 卷纸指的是纸张总宽实际可打印区通常是 56mm 左右这个差值来自纸边和打印机压纸轮的物理遮挡。如果按 58mm 去排版打出来右边必然被切掉一点。下面是 demo 里实测可用的页面级样式/* 58mm 卷纸实际可打印区按 56mm 算 */ page { size: 58mm auto; margin: 0; } html, body { margin: 0; padding: 0; background: #fff; } .ticket { width: 56mm; padding: 3mm 2mm; box-sizing: border-box; font-family: Microsoft YaHei, PingFang SC, sans-serif; font-size: 12px; color: #000; } /* 底色和边框在小票输出时不要被 Chromium 默认省略 */ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }page size: 58mm auto里的auto表示高度自动撑开内容多高纸就打多高避免固定高度导致多出一张空白页。margin: 0是必须的Chromium 默认会给页面加页边距不关掉的话小票两侧会出现白边。width: 56mm配合padding: 2mm是为了让内容不贴边同时又留出了被驱动裁切的安全余量。4.3 明细行的对齐与换行策略明细行是打印中最容易翻车的地方商品名长短不一数量、金额列的宽度稍有偏差视觉上就歪了。这个 demo 里的做法是flex布局但三个子元素的宽度策略完全不同。.goods-line { display: flex; width: 100%; line-height: 1.5; } .goods-name { flex: 1 1 auto; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .goods-qty { flex: 0 0 42px; text-align: right; } .goods-amount { flex: 0 0 60px; text-align: right; font-variant-numeric: tabular-nums; }商品名区域用flex: 1 1 auto占据剩余空间但设置了max-width: 90px超过部分直接省略号截断。为什么不换行因为热敏纸的行高是物理固定的换行后行距会被撑大同一张票上的行与行之间会忽松忽紧。数量列和金额列都写成固定宽度并右对齐这样所有行的小数点垂直方向对齐。font-variant-numeric: tabular-nums让数字等宽避免金额列因为数字位数不同而整体左右摆动。4.4 连续打印时用队列保证顺序打印多张小票时最容易出现顺序错乱。如果每张票都开一个窗口去加载路由三个窗口会并行渲染先后完成顺序和创建顺序完全无关最终打印出来的单子可能 A 订单后跟着 C 订单。demo 里做法是把打印任务串成全量队列// src/main/printQueue.js let printQueue Promise.resolve(); export function queuePrint(printTask) { printQueue printQueue .then(() printTask()) .catch((err) { console.error(单张打印失败队列继续执行后续任务, err); }); return printQueue; }使用方式是在主进程的 IPC 处理器里包一层queuePrint(() doPrint(payload))。doPrint内部执行loadURL和webContents.print前一张的回调触发后Promise 链才会走到下一张。这样做还有一个好处打印机的数据缓冲区不会堆积不然连续三单之后第四单莫名变成乱码的情况经常发生。5. 打印小票的避坑记录尺寸、缓存与设备映射5.1 预览正常但打出来内容被裁掉现象在 devtools 里查看模板页面整体布局完全正常但真实打印出来的小票右侧被切掉约 1-2mm金额列部分数字看不清。原因打印机驱动对可打印区的定义比纸张物理宽度略窄。即便是标注 58mm 的热敏纸实际可打印宽度也只有 56mm 或 56.5mm驱动按这个真实值渲染而你排版按 58mm 铺满两边一对比自然被裁。解决把page size从58mm auto改成56mm auto外层容器宽度同步改为56mm同时给容器加padding: 0 2mm。这一步看起来是微调实际是让版式完全落在驱动认可的宽度内不再挑战打印机物理边缘。5.2 打印任务提交成功但出来是白纸现象webContents.print的回调返回success: true系统打印队列里也能看到任务但实际打出来一张空白纸而且有时是前几张空白后一张有字。原因silent: true模式下打印时机太早。模板页路由加载了 DOM但 Vue 组件内部还在异步拉取订单数据loadURL的 Promise resolve 只代表页面 HTML 就绪不代表v-for渲染的数据已经填充进去了。此时打印拿到的还是空的明细列表。解决在渲染进程的PrintTicket.vue里等数据渲染完成后主动通知主进程而不是让主进程盲目等待。mounted里拿到订单数据后调用window.printAPI.notifyReady()主进程收到事件后再执行webContents.print。如果不想引入第二个 IPC 事件退一步的做法是setTimeout(300)但数据量大时 300ms 不够稳。5.3 开发环境正常打包后 window.printAPI 报 undefined现象npm run electron:serve调试时打印功能一切正常electron:build打包安装后点击打印按钮直接报window.printAPI is not a function。原因打包后 preload 脚本被打进 asar 压缩包path.join(__dirname, preload.js)在开发环境下能定位到文件在 asar 环境下指向了错误路径。最隐蔽的是contextIsolation开启时 preload 加载失败不会抛异常所有 Vue 组件里的window.printAPI静默变成 undefined。解决主进程里不要用__dirname拼接 preload 路径改用app.getAppPath()来定位根目录。同时确认构建配置里的files白名单包含 preload 文件否则它根本不会进入安装包。5.4 中文和数字混排时金额列对不齐现象商品名较短时数量列和金额列垂直对齐没问题商品名一长金额列整体向右挤出去或数量列位置偏移。原因中文全角字符的宽度是英文字符的两倍热敏打印机对字体渲染又不像屏幕那样可控。flex: 1的弹性区面对不同长度的中文名挤压效果不一致固定宽度的列反而被迫跟着移位。解决数量列和金额列一律用固定宽度text-align: right不参与 flex 弹性分配。商品名限制最大宽度并省略号截断防止长文本把整行撑破。另外把整页line-height设为1.5避免贴纸打印时字迹从第一行开始逐行往上顶。5.5 打印机缺纸时不会返回错误现象纸卷已经空了但点击打印按钮后台任务照常提交界面不报错实际机器也没有任何输出。原因webContents.print的回调成功只代表任务已经提交给系统打印队列设备是否真正出纸、纸是否缺、是否卡纸这些状态 Electron 完全感知不到驱动也不会主动回传。解决业务层要兜底在打印前增加一个确认弹窗让操作员检查纸卷在打印完成后提供重试按钮。不要相信success回调就能代表物理完成设计流程时把它当作「已提交」而不是「已打印」。6. 进阶把打印机名做成配置顺便定一套连打验收流程6.1 设备名映射配置化真实门店里的打印机名字比 demo 里复杂得多。同一台热敏打印机驱动版本不同在系统里可能显示为58mm Receipt Printer也可能显示成POS-58-Series或者带一串厂商型号后缀。如果代码里写死设备名换台电脑换套驱动就要重新改代码。把这个名字抽到配置文件里让主进程读配置而不是读常量。// config/device-map.json { defaultDevice: front-58, devices: { front-58: 58mm Receipt Printer, kitchen-80: 80mm Kitchen Printer } }主进程启动时读取这个 JSON把设备名列表注册成全局配置。渲染进程通过getPrinters()枚举系统当前可用打印机再把配置里的逻辑名映射到真实设备名。如果枚举不到指定设备直接提示「未找到打印机」而不是让 Electron 静默提交到虚拟 PDF 设备里。6.2 连打验收流程换打印机或换电脑后我一般强制走三遍这套测试流程每遍 20 张连续打印全部通过才正式交给门店用。测试项目如下先打一张宽纸测试页检查金额列是否在 56mm 宽度内完整显示没有裁切。同一订单连续打印 20 张逐张核对单号、明细、金额不允许出现串单或空白页。把系统显示缩放从 100% 调到 125%重复打印 3 张确认样式不变形。这一步能提前暴露驱动与缩放兼容性问题。每次接一台新的热敏打印机我都会强制走一遍上面的验收流程其中第一项测试页尤其绕不开——大部分排版玄学问题在换机后都会现原形。希望这份小票打印 demo 能帮你少踩几个已经踩平的坑希望帮到你。本文还有配套的精品资源点击获取