ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Electron静默打印实战:从核心API到Vue3工程落地全流程

Electron静默打印实战:从核心API到Vue3工程落地全流程 做桌面端开发这几年被问得比较多的问题里“打印能不能不弹窗点一下按钮就直接出纸”绝对排前三。尤其门店收银、诊所药房、仓库打包台这类场景操作员根本没空去碰系统打印对话框里的确定和取消他们要的是扫码枪一扫、按钮一按小票或者标签自己就出来了。这个需求在 Electron 桌面应用里有个固定的说法静默打印。这篇文章我把 Electron 静默打印从方案选型到落地实现、再到打包部署踩坑按完整链路整理一遍。适合正在做收银系统、医院导诊系统、后台票据打印、快递面单打印这类项目并且技术栈已经选了 Electron、Vue3 或者原生 JS 的开发者。看完你应该能直接抄一套可用的打印模块回去至少不用在“打印机名称传错导致没反应”这种问题上再浪费一下午。1. 静默打印是什么以及为什么第一步是定方案1.1 先分清三种打印方式很多人一上来就找代码但我建议先花十分钟想清楚业务到底属于哪种打印方式。实际开发中桌面端的打印基本分三类手动打印调用系统打印弹窗用户自己选打印机、调份数、点确定。这是浏览器里window.print()的行为。静默打印程序预先指定打印内容和打印机不弹任何对话框触发后直接往打印机队列里提交任务。Electron 里主要靠webContents.print({ silent: true })这类 API 实现。指令直打不走系统打印驱动直接通过串口、网口或 USB 向打印机写入控制指令比如 ESC/POS、TSPL、ZPL。速度快、可控性强常见于 80mm 热敏小票机、标签打印机。Electron 的静默打印天然落在第二种。因为 Electron 是一套基于 Chromium 的桌面运行环境页面渲染输出由 Chromium 完成打印服务对接、队列提交这些活最终要交给操作系统的打印子系统。所以思路不能停留在“死磕代码”要先想清楚是走系统打印还是要走设备直连这两条路的技术方案和坑完全不同。1.2 Electron 提供了哪些打印能力Electron 在 Chromium 基础上暴露了几组和打印相关的 API是所有方案的底座webContents.print(options, callback)核心打印方法打印当前加载的页面支持silent参数静默打印也支持指定打印设备。webContents.getPrintersAsync()获取系统打印机列表返回设备名称、状态、是否默认打印机等信息。webContents.printToPDF(options)把当前页面渲染成 PDF 文件返回PromiseBuffer。可以先把内容转成 PDF再用外部命令打印这是很多生产项目的稳路子。BrowserWindow隐藏窗口打印内容不必出现在用户当前看到的窗口里可以起一个不可见的窗口加载模板再打印。这几样组合起来已经覆盖了“前台页面不跳打印样式”“后台自动出纸”“打印结果存档”这些常见需求。还有一个容易忽略的点渲染进程自己是没有权限直接调系统打印的所有打印动作最终都要回到主进程执行所以项目里必然绕不开 IPC 通信。后面第 3 节我给的模块就是按这个链路设计的。1.3 三条技术路线怎么选我在不同的项目里分别用过下面三条路线各有适用场景。给在一张表里方便对比路线优点缺点适用场景webContents.print({ silent: true })代码量最少直接打印当前页面样式跨平台不完全一致对复杂套打支持一般普通 A4/单据打印无需预览printToPDF 系统打印命令输出稳定可预览可存档打印结果与 PDF 一致多一步转 PDF耗时略长有审核要求的报表、需要留底的凭证串口/网口指令直打serialport速度快可控性最高不依赖驱动只能针对特定协议打印机需自己拼指令热敏小票、条码标签、称重标签我的建议是一般项目默认先用webContents.print先把业务跑通如果遇到样式兼容问题、需要打印预览或者打印机是带驱动的高速票据机再切到printToPDF 外部命令的链路。串口直打属于另一套体系适合专门的小票机场景我在第 6 节单独展开。2. 核心 API 详解webContents.print 的参数与它背后的坑2.1 打印参数逐项拆解webContents.print使用频率最高但很多人就记得silent一个参数导致出了问题不知道往哪儿查。这里把常用参数完整列一遍都是 Electron 官方支持的参数类型默认值作用silentbooleanfalse是否静默打印为true时不弹打印对话框printBackgroundbooleanfalse是否打印背景色和背景图。经常有人打出来白底才发现忘了设deviceNamestring打印机的设备名称不是控制面板里的显示名colorbooleantrue是否彩色打印marginsObject{ marginType: default }边距。指定自定义边距用{ marginType: custom, top, bottom, left, right }landscapebooleanfalse是否横向打印scaleFactornumber100缩放百分比copiesnumber1打印份数pageRangesArray[]页码范围例如[{ from: 1, to: 1 }]只打第一页dpiObject{}分辨率例如{ horizontal: 300, vertical: 300 }header/footerstring页眉和页脚 HTML 模板这里有两个参数需要多讲几句。第一个是silent。它只在deviceName有值或者系统存在默认打印机时才能真正“静默”。如果打印机没配对、驱动异常、deviceName传空Electron 在某些平台会退化为弹窗或者直接失败表现很迷。所以生产代码里别只写{ silent: true }一定要同时指定明确的打印机。第二个是printBackground。浏览器打印默认不打印背景色和背景图但业务单据里经常需要底色块、水印、表格线这些在页面上看着好好的打出来全是白的。我见过很多次同事排查半天最后就是少了这个参数。现阶段建议不管什么单据都把它设成true减少惊吓。2.2 deviceName 是设备名不是显示名这个是我见过最高频的翻车点必须单独拿出来说。Electron 的getPrintersAsync()返回的打印机对象里有好几个名字字段name、displayName、deviceName、description。在 Windows 上displayName通常是你控制面板里看到的友好名比如“HP LaserJet M1005”而传给webContents.print({ deviceName })的往往是name这个字段它的值可能长得像\\.\POS58或者HP LaserJet M1005 (副本 1)。如果直接把displayName塞给deviceName很多时候打印任务会在队列里卡住或者直接没有任何反应。我的做法是先打一份完整的打印机信息看看结构// 在主进程里拿到当前窗口的 webContents const printers await win.webContents.getPrintersAsync() console.log(printers.map((p) ({ name: p.name, displayName: p.displayName, deviceName: p.deviceName, status: p.status, isDefault: p.isDefault, options: p.options })))打印出来后在 Windows 上要确认name字段是不是你真实要用的那台机。不确定时可以用 PowerShell 对照Get-Printer | Select-Object Name, DriverName, PortNamemacOS 和 Linux 下也可以用lpstat -p -d查看打印机名称列表。总之不要把“显示名”和“设备名”混为一谈这是静默打印能不能触发的基本前提。2.3 打印链路为什么必须走 IPCElectron 的安全模型里渲染进程跑的是页面代码权限受限不能直接操作系统打印服务。所以静默打印的正确姿势一定是渲染进程发起请求通过 preload 脚本暴露的白名单 API 走 IPC 到主进程由主进程调用webContents.print或系统命令。这个设计既是权限需要也是稳定性需要。主进程持有窗口实例能拿到真正的webContents主进程出错时进程不会崩到页面主进程还能统一做打印队列、日志记录、异常上报。封装的时候建议把 IPC channel 命名规范一点比如统一用print:xxx前缀。下面是一个最小可用的主进程实现先注册print:get-printers和print:html两个 IPC Handler// main/printHandler.js import { BrowserWindow, ipcMain } from electron export function registerPrintHandlers() { // 获取打印机列表 ipcMain.handle(print:get-printers, async (event) { const win BrowserWindow.fromWebContents(event.sender) if (!win) return [] return win.webContents.getPrintersAsync() }) // 静默打印 HTML ipcMain.handle(print:html, async (event, options) { const win BrowserWindow.fromWebContents(event.sender) if (!win) return { success: false, reason: window not found } const printOptions { silent: options.silent ?? true, printBackground: options.printBackground ?? true, deviceName: options.deviceName || , color: options.color ?? true, margins: options.margins || { marginType: default }, landscape: options.landscape ?? false, scaleFactor: options.scaleFactor ?? 100, copies: options.copies ?? 1, pageRanges: options.pageRanges || [] } return new Promise((resolve) { win.webContents.print(printOptions, (success, failureReason) { resolve({ success, failureReason }) }) }) }) }preload 里面用contextBridge暴露 API保证页面代码只能拿到你允许的接口// preload/index.js import { contextBridge, ipcRenderer } from electron contextBridge.exposeInMainWorld(printer, { getPrinters: () ipcRenderer.invoke(print:get-printers), printHTML: (options) ipcRenderer.invoke(print:html, options) })这样渲染进程里就可以直接调window.printer.printHTML(...)不用知道底层细节也不会被恶意页面拿去乱打印。3. 实操一套可以直接用的 Electron 静默打印模块3.1 项目初始化与目录规划新项目我习惯用 electron-vite 脚手架它对主进程、preload、渲染进程的分层很清晰也不用自己折腾一堆构建配置。用 pnpm 初始化pnpm create quick-start/electron my-print-app cd my-print-app pnpm install创建时选 Vue 模板它会帮我们把src/main、src/preload、src/renderer三个目录都建好。实际项目里我会在src/main下单独拆一个printHandler.js避免主进程入口文件越写越臃肿。目录结构大致这样my-print-app/ ├── src/ │ ├── main/ │ │ ├── index.js │ │ └── printHandler.js │ ├── preload/ │ │ └── index.js │ └── renderer/ │ ├── index.html │ └── src/ │ └── App.vue ├── resources/ # 放外部打印工具或资源 ├── electron-builder.yml └── package.json3.2 主进程注册打印模块在主进程入口文件里引入registerPrintHandlers放在app.whenReady()之后调一次即可// main/index.js import { app, BrowserWindow } from electron import { registerPrintHandlers } from ./printHandler app.whenReady().then(() { registerPrintHandlers() createWindow() })这里我建议把打印回调里的success状态原样返回给渲染进程。很多业务逻辑需要知道这单到底打没打成功比如收银系统要决定是否继续下一单。webContents.print的回调会在打印任务提交或失败时返回(success, failureReason)但注意不同平台触发时机不完全一样有的驱动会等任务真正完成才回调有的提交队列就回调了。所以后端如果要做“打印结果强校验”最好定时去查打印任务状态而不能只依赖这个回调。3.3 渲染进程打印机选择与触发打印渲染进程要做的事很简单进入页面时拉一次打印机列表让用户选默认打印机实际打印时把deviceName传过去。script setup import { ref, onMounted } from vue const printers ref([]) const selectedPrinter ref() const printing ref(false) onMounted(async () { printers.value await window.printer.getPrinters() const defaultPrinter printers.value.find((p) p.isDefault) if (defaultPrinter) { selectedPrinter.value defaultPrinter.name } else if (printers.value.length 0) { selectedPrinter.value printers.value[0].name } }) async function handlePrint() { if (!selectedPrinter.value) { alert(未找到可用打印机) return } printing.value true const result await window.printer.printHTML({ deviceName: selectedPrinter.value, silent: true, printBackground: true, copies: 1 }) printing.value false if (!result.success) { console.error(打印失败:, result.failureReason) } } /script有一个细节这里选打印机用的是name字段不是displayName。如果你把用户在下拉框里看到的展示名和实际传给后端的设备名分开存储既能保证界面友好又能保证底层参数正确。我在项目里通常会让展示名显示displayName但值始终存name。3.4 更稳的方案printToPDF 加系统命令打印如果一个页面需要打印多页、还要带页眉页脚、或者客户对打印效果要求极高直接webContents.print的结果有时不稳定。这时候我会换成“先转 PDF再调系统命令打印”的链路。主进程里加一个print:pdf的 IPC Handler负责转换 PDF 文件然后按平台调用不同命令import fs from fs import path from path import os from os import { exec } from child_process import { BrowserWindow, ipcMain } from electron ipcMain.handle(print:pdf, async (event, { deviceName }) { const win BrowserWindow.fromWebContents(event.sender) if (!win) return { success: false, reason: window not found } // 1. 打印页面为 PDF const pdfBuffer await win.webContents.printToPDF({ printBackground: true, pageSize: A4, margins: { marginType: default } }) // 2. 存到临时文件 const tmpPath path.join(os.tmpdir(), print-${Date.now()}.pdf) fs.writeFileSync(tmpPath, pdfBuffer) // 3. 调系统打印命令 return new Promise((resolve) { if (process.platform win32) { // Windows 用 pdf-to-printer 这类工具或者 SumatraPDF 命令行 const tool path.join(process.resourcesPath, pdf-to-printer.exe) const printerArg deviceName ? -printer ${deviceName} : exec(${tool} ${tmpPath} ${printerArg} -silent, (err) { resolve({ success: !err, failureReason: err?.message }) }) } else { // macOS / Linux 直接用 lp const printerArg deviceName ? -d ${deviceName} : exec(lp ${printerArg} ${tmpPath}, (err) { resolve({ success: !err, failureReason: err?.message }) }) } }) })这个方案的优点很明显打印结果是 PDF 渲染后的固定效果和用户预览看到的完全一致不会因为打印机驱动对 HTML 解释不同而产生偏差。而且自然获得“打印留档”能力——很多政务、财务场景要求每一单都有 PDF 存底这个方案直接把 PDF 文件留下来就行。缺点是比直接打印多一步转换和文件写入但对绝大多数场景来说多出的几百毫秒感知不明显。4. 结合 Vue3 Electron 的完整工程实践4.1 用 electron-vite 搭 Vue3 工程与 pnpm 打包配置如果你是从头开始搭 Vue3 Electron 的工程electron-vite 是目前我体验下来最顺手的一套方案。它把main、preload、renderer三个构建目标分开配置开发时支持热更新生产的构建也快。一个容易踩的坑是 pnpm 和 electron-builder 的兼容问题。pnpm 默认用符号链接管理依赖但 electron-builder 在打包时按普通 node_modules 结构去找依赖容易漏包。我的处理办法是在项目根目录加一个.npmrcnode-linkerhoisted这样 pnpm 就会把依赖平铺到根node_moduleselectron-builder 打包时就不会出现“开发环境跑得好好的一打包就报 module not found”。另外Electron 本身和 serialport 这类原生模块需要编译pnpm 高版本默认会拦截包的 install 脚本。如果发现 electron 装完没有二进制或者 native 模块报 ABI 错误先执行pnpm approve-builds或者在package.json里配置onlyBuiltDependencies放行 electron、serialport、electron-winstaller 这些包。4.2 打印模板页面与 CSS 适配用 Vue3 做打印页通常有两种做法直接在当前页面里划一个打印区域写media print样式把页面其他部分隐藏。单独建一个隐藏的 BrowserWindow 加载一个纯打印模板打印完直接关掉。第一种代码少适合简单单据第二种适合打印模板和业务页面分离结构清晰也方便以后把模板放到后端动态下发。我用得比较多的是第二种。打印模板页面要注意几个细节。首先是media print必须隐藏所有操作按钮和无关元素media print { .no-print { display: none !important; } body { margin: 0; padding: 0; } .print-page { width: 100%; } }热敏小票的场景页面宽度和设备宽度要匹配。比如常见 80mm 热敏纸打印区域实际宽度一般是 72mm 左右页面 CSS 可以设置.print-page { width: 72mm; margin: 0 auto; font-size: 12px; font-family: 宋体, SimSun, monospace; }套打定位比如快递面单、发票更讲究一般做法是按实际纸张尺寸铺一个背景图然后把需要填写的字段用绝对定位放在对应位置。打印时设置printBackground: true否则背景的定位参考线会消失。字体也是个隐蔽的坑。页面里用了思源黑体这类字体但打印机所在机器没装打印时浏览器会自动 fallback布局就全乱了。稳妥的办法是打印模板只用系统自带字体比如宋体、黑体、Arial如果必须用特殊字体考虑用font-face内嵌字体文件但也要注意字体会让 PDF 变大、转 PDF 变慢。4.3 electron-builder 打包配置与发布要点项目进入打包发布阶段electron-builder 是主流选择。建议用 YAML 文件管理配置比塞在package.json里更清晰。一份比较常见的配置appId: com.example.printapp productName: 静默打印示例 directories: output: dist files: - out/** - resources/** extraResources: - from: resources/pdf-to-printer.exe to: pdf-to-printer.exe win: target: - nsis nsis: oneClick: false allowToChangeInstallationDirectory: true这里有两个容易出问题的点第一个是外部打印工具比如 pdf-to-printer.exe要通过extraResources放到安装目录的resources下程序里用process.resourcesPath去定位而不是用__dirname拼接。打包之后__dirname在 asar 里路径和开发环境完全不同。第二个是 asar 打包。如果打印模板是外部 HTML 文件、或者你需要动态读取模板目录可能要从files里排除或者放到extraResources。否则打包后文件被塞进 asar 归档里读不到。菜单和托盘也可以顺手做进去。门店场景下操作员未必懂计算机我一般会在系统托盘加一个“打印测试页”的入口方便现场排查打印机配置。用 Electron 的Menu.buildFromTemplate或者Tray都能实现核心逻辑还是那句菜单点击后通过 IPC 发到主进程调用打印模块。5. 常见问题速查表与排查思路我把实际项目里遇到过的问题整理成一张速查表后面再单独展开三个最高频的现象可能原因解决思路silent: true仍然弹窗deviceName未指定或打印机不可用显式传打印机name字段打印任务没反应deviceName传了显示名改用getPrintersAsync()里的name打印出来是空白页漏了printBackground: true打开背景打印页面上有背景图但打印没有未启用背景打印同上打印内容和页面显示不一致用了media print样式但没适配单独做打印样式中文变成方框或乱码打印机驱动缺字体换系统字体或内嵌字体打包后找不到打印机主进程代码路径问题检查process.resourcesPath和 asar 配置Linux 下打印乱码CUPS 驱动和字体配置问题检查/etc/cups确认字体已安装5.1 silent 但总是弹打印框这个问题的共性原因就是deviceName没传或者传错。Electron 在 Windows 下如果检测不到可用的打印设备即使silent: true也会回退到系统弹窗。排查步骤很简单先调getPrintersAsync()把name打出来确认你要打印的机器在列然后确认printer.status是 0空闲而不是错误状态最后把name原样传进去。5.2 打印出来是一张白纸白纸问题 90% 是背景没打印。比如单据上有浅灰色底纹、表格有背景色如果不设置printBackground: trueChromium 默认不渲染这些。另一个原因可能是页面宽度超出了纸张范围内容全部跑到页面外部去了设一下scaleFactor: 100或者检查media print里的body { width: auto }。5.3 打印队列有任务但打印机不动Windows 上最常见的是设备名用错。有一点值得强调在getPrintersAsync()返回的对象里deviceName和name有时长得一样有时差很远真正传给print的是name。另外有些共享打印机或者网络打印机驱动里配置的端口是别的主机名Electron 提交任务时走的是系统打印服务理论上和端口无关但如果驱动本身有问题任务会一直在队列里状态是“正在打印”。这种情况属于环境问题需要先从系统控制面板手动打印测试页排查。6. 扩展场景串口直连热敏打印机的另一种“静默”6.1 什么时候应该考虑串口直连系统打印 Solution 虽好但在一些特定设备上有天然短板。最常见的就是收银小票机尤其是连 80mm/58mm 热敏纸的老机器它们很多时候根本没装 Windows 驱动只提供一个串口或者网口。这种机器你走webContents.print根本找不到它。串口直连的做法是通过serialport这样的库直接打开打印机的串口往里面写 ESC/POS 指令让打印机自己出纸。整个过程不经过系统打印服务也就无所谓“静默”设备收到什么就打什么响应速度极快。6.2 Electron serialport 的最小示例先安装pnpm add serialport pnpm add -D electron/rebuild注意serialport是原生模块它的编译版本必须匹配 Electron 的 Node ABI。装完依赖后执行npx electron/rebuild -f -w serialport不开小票机的场景下最小代码是这样import { SerialPort } from serialport // 列出可用串口 const ports await SerialPort.list() console.log(ports.map((p) ({ path: p.path, manufacturer: p.manufacturer, serialNumber: p.serialNumber }))) // 打开串口并打印一行文本 const port new SerialPort({ path: COM3, baudRate: 9100, autoOpen: true }) port.write(Buffer.from([0x1b, 0x40])) // ESC 初始化打印机 port.write(Hello, Silent Print\n)打印中文小票时需要把内容转成 GBK 编码否则 GP 系列的热敏机会打出乱码。一般配合iconv-lite做转码import iconv from iconv-lite const text 订单号20240001\n商品可乐 x2\n合计6.00元\n port.write(iconv.encode(text, gbk))还要注意小票机的波特率常见的有 9100、9600、115200具体要看你那台机器的拨码开关或者配置工具。另外很多免驱小票机直接用 USB 转串口芯片在SerialPort.list()里会显示成USB-Enhanced-SERIAL这类名字别一看不是 COM 开头的就以为没识别到。6.3 什么时候别用串口直连串口直连虽好但不能一概而论。办公场景的激光打印机、喷墨打印机、一体机老老实实走系统驱动webContents.print是正路需要套打的快递面单虽然也有 TSPL 直打方案但模板调试复杂度远高于 HTML 排版除非量特别大、性能要求特别高否则不建议为了“静默”而把整个打印系统拖进指令拼接的深渊。我的经验是小票机优先考虑串口直连普通办公打印机就老实走系统打印。静默打印的核心不是一招吃遍天下而是搞清楚你的打印机和业务需求到底匹配哪条路线然后每条链路里再把设备名、模板样式、打包路径这些细节磨到位。Electron 的打印 API 本身不算复杂真正考验人的是它连接的那一整个桌面环境。把这套链路理清楚以后不管换什么业务场景你都能很快定位问题出在渲染、驱动还是设备上。
返回列表