简介:这是一份面向 JavaScript 开发者的静默打印中间件源码包,定位是帮助需要实现后台自动打印、无弹窗打印场景的 Web/Electron 项目。资源基于 Electron-Vue 工程组织,核心围绕 SilentPrint 中间件展开,覆盖打印流程控制、参数配置、打印状态监听及兼容性处理。压缩包共 44 个文件,大小约 154KB,以 JS、Vue、HTML 源码和 ESLint/Babel/package.json 等工程化配置为主,另有演示页面与 README 说明,便于直接查阅和二次开发。已有 5280 人学习/下载。对于正在解决发票/收据打印、报表自动输出、自助终端打印等需求的开发者,这份资料提供了可运行的参考实现,也能帮助理解如何绕过默认打印预览、结合 HTML2Canvas 或底层打印 API 实现静默输出;同时其清晰的目录结构和 electron-vue 配置方式,适合作为中间件集成与前端打印方案设计的学习样本。若需在浏览器端或 Electron 桌面端快速落地静默打印,可直接基于其中的 demo 与配置信息进行裁剪改造。
1. 静默打印中间件:让 ERP 系统告别打印对话框的本地代理
某仓储系统的 web 端改造卡了两个月,核心痛点就一个:仓库操作员每天要打几百张入库单,浏览器自带的打印预览对话框要点三次鼠标,赶上订单高峰手都点酸。试过 ActiveX 插件,Chrome 更新几个版本后直接失效;试过网页套 PDF 再调系统打印,PDF 预览器又弹出来拦一道。最后落地的方案,就是这个 SilentPrint 类型的本地中间件思路:浏览器不直接碰打印机,而是把打印任务丢给跑在本机的中间件进程,由它完成后续的渲染、入队和静默输出。这套方案绕开了浏览器的沙箱限制,用 JavaScript 既能驱动,又能做到用户完全无感出纸,适合订单打印、面单打印、报表批量输出这类高频场景。
2. 架构设计:网页到打印机之间多出来的那一层
2.1 为什么非要加中间件:浏览器的三条路都不好走
浏览器本身没有能力直接访问系统打印机。过去常用的三条路,各有各的死穴:第一条路是 ActiveX / NPAPI 插件,只能在 Windows 老 IE 里用,现代浏览器包括 Edge 都已经不再支持;第二条路是 Chrome 的 USB / 串口 API,能连设备但需要用户手动授权,而且打印机大多走并口或网络共享,根本映射不到 Web API 上;第三条路是把文档转成 PDF 再让用户手动打印,这又绕回了打印预览对话框。
所以真正能扛住生产环境的路径,就是加一个本地中间件。它跑在操作系统层,拥有调用系统打印命令的完整权限。浏览器端只负责把「要打印什么内容、用哪个模板、往哪个打印机发」打包成一条 JSON 消息,通过 HTTP 协议发给 localhost 上的中间件服务。中间件解析消息、渲染文档、调用打印命令,然后返回一个任务 ID,前端轮询拿结果。用户看到的只是点了按钮、打印机自己出纸,全程没有任何系统弹窗。
提示:这套架构里最容易被低估的是中间件进程的常驻方式。生产环境一般把它注册成 Windows 服务或用 PM2 守护,确保开机自启、崩溃自动拉起,不能依赖用户手动开命令行窗口。
2.2 整体架构:前端 SDK、本地代理与打印核心三层
整个 SilentPrint 体系在逻辑上是三层的:
第一层是前端 SDK,本质是一段 JavaScript 工具库,封装了请求发送、任务轮询、错误标准化。业务页面不关心底层协议,只需要调用silentPrint.send(templateId, data, printerName)这样一个方法。第二层是本地代理服务,用 Node.js 写的轻量 HTTP 服务,开一个端口监听本地请求,负责权限校验、模板拉取、渲染任务调度、打印机发现与状态探测。第三层是平台打印核心,负责把业务数据渲染成最终可打印的文档格式(常见的是 HTML 转 PDF 或者直接拼接 EPL / TSPL 指令),再交给系统打印命令,同时对打印任务做队列管理,避免高并发时打印机驱动崩掉。
这三层之间的边界很干脆:SDK 不知道打印机的牌子,代理服务不知道业务的字段含义,打印核心只关心数据进来了怎么排版、指令怎么拼。业务字段哪怕天天变,改模板就行,不需要动中间件本体。这种分离带来的好处,在后面讲二次开发时会体现得更明显。
2.3 通信协议:HTTP + JSON 轮询,够用就别上 WebSocket
有一些实现会把浏览器和中间件的通道设计成 WebSocket 长连接,但就我拆过的项目来看,静默打印这个场景用普通的 HTTP 接口加轮询更稳。原因很简单:打印任务不是高频实时消息,一个任务从提交到完成通常需要 0.5 到 3 秒,HTTP 请求天然的请求-响应模型刚好契合「发任务、查状态」这个节奏。WebSocket 长连接最大的问题是中间件进程一旦重启,浏览器端需要感知断线并重连,这对一个本地服务来说反而引入更多故障点。
通信协议直接走 HTTP + JSON,两端都容易调试。浏览器发 POST 请求到http://localhost:18321/api/print,请求体包含模板编号、业务数据 JSON 和打印机名称。中间件返回{ "taskId": "xxx" },前端拿到 taskId 后每隔 800 毫秒轮询/api/task/{taskId}直到状态变为成功或失败。把接口设计成无状态的,还有个额外的好处:可以不经过前端 SDK,直接用 curl 或 Postman 调试中间件,排查问题的时候特别顺手。
3. 核心模块拆解:一次打印请求从进入到返回的完整链路
3.1 前端 SDK:封装调用与轮询逻辑
前端 SDK 的核心方法就一个 send。它负责三件事:组装请求参数、带超时地提交给中间件、提供回调通知业务方。下面这一段是 SDK 最核心的调用代码:
/** * 发送静默打印任务 * @param {string} templateId - 模板标识,对应中间件 templates 目录下的模板子目录名 * @param {object} data - 业务数据,会被渲染进模板的占位符 * @param {string} printerName - 打印机名称,缺省则使用中间件默认打印机 * @param {object} options - 额外选项,如份数、是否排队 * @returns {Promise<string>} taskId */ async function sendPrintTask(templateId, data, printerName = '', options = {}) { const payload = { templateId, data, printerName, copies: options.copies || 1, priority: options.priority || 0 }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const resp = await fetch('http://localhost:18321/api/print', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal }); const result = await resp.json(); if (result.code !== 0) { throw new Error(`打印任务提交失败: ${result.msg}`); } return result.taskId; } finally { clearTimeout(timer); } } /** * 查询任务状态 * @param {string} taskId - 提交任务时返回的 ID * @returns {Promise<{status: string, message?: string}>} status: pending/success/failed */ async function getTaskStatus(taskId) { const resp = await fetch(`http://localhost:18321/api/task/${taskId}`); const result = await resp.json(); return result; }这段代码里需要留意的是超时控制。如果中间件没有启动,fetch 会因为连接被拒绝而立即抛错,这是好事;但如果中间件启动了却因为打印队列堵塞迟迟不回状态,5 秒的 AbortController 超时就会让前端提前感知到异常,而不是干等。实际项目里我会把超时做成可配置项,因为不同模板的渲染耗时差别很大,最长可以放到 15 秒。业务方在调用时只需要关心 taskId 和最终状态,至于中间件内部怎么渲染、怎么入队,SDK 刻意不暴露。
3.2 中间件入口:Express 框架下的请求解析与鉴权
中间件主服务用 Express 实现,入口文件只干三件事:解析请求、查本地模板目录、把任务丢进队列。这里有一个安全细节需要注意:接口监听在 localhost 上,理论上只有本机能访问,但还是要加一层简单的 token 校验,防止浏览器里被恶意网页探测端口后伪造打印请求。
const express = require('express'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const { renderTemplate } = require('./renderer'); const { printQueue } = require('./queue'); const app = express(); app.use(express.json({ limit: '2mb' })); // 简单鉴权:请求头里必须携带与中间件启动时一致的 token app.post('/api/print', async (req, res) => { const auth = req.headers['x-print-token']; if (auth !== global.__PRINT_TOKEN) { return res.status(401).json({ code: 401, msg: 'invalid token' }); } const { templateId, data, printerName, copies } = req.body; const templateDir = path.join(__dirname, 'templates', templateId); const fs = require('fs'); if (!fs.existsSync(templateDir)) { return res.status(404).json({ code: 404, msg: `template not found: ${templateId}` }); } const taskId = uuidv4().replace(/-/g, ''); printQueue.push({ taskId, templateId, data, printerName, copies: copies || 1, createdAt: Date.now() }, result => { if (result.success) { res.status(200).json({ code: 0, taskId }); } else { res.status(200).json({ code: 1, taskId, msg: result.error }); } }); });这个接口之所以不直接执行打印而是在回调里返回结果,是因为打印是慢操作,开多个异步任务同时写打印机驱动容易冲突。队列 push 进去后立即返回 taskId,前端再去轮询状态,整个交互就变成异步的了。还有一个细节是express.json({ limit: '2mb' }),这个限制值是从生产实践里踩出来的——业务数据如果带了图片 base64,很容易超过默认的 100kb 限制,但超过 2mb 又说明这数据不太适合走 JSON 模板渲染,应该先落附件再传路径。
3.3 渲染与打印核心:从模板变量替换到系统打印命令
渲染层是这个中间件技术含量最高的模块,它有两个职责:把 JSON 数据灌进 HTML 模板生成最终文档;再把文档转成打印机能理解的内容。前者靠简单的模板字符串替换函数就能实现,后者则需要根据打印机类型分别处理。对于普通办公打印机,方案是 HTML 转 PDF 再调系统静默打印;对于条码标签打印机,直接用 EPL / TSPL 指令拼接效率更高。
下面贴一段模板渲染核心函数,它处理的是不带条码的普通票据模板:
/** * 渲染模板:把 data 里的键值对替换进模板占位符 * @param {string} templateHtml - 模板内容 * @param {object} data - 业务数据 * @returns {string} 渲染后的 HTML */ function renderTemplate(templateHtml, data) { // 支持 {{key}} 和 {{#each list}} 两种基本语法 let html = templateHtml.replace(/\{\{(\w+)\}\}/g, (match, key) => { if (data[key] === undefined || data[key] === null) { return ''; } return escapeHtml(String(data[key])); }); // 简单的列表渲染:找到表格里的循环块并重复生成 html = html.replace(/\{\{#each (\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, block) => { const list = data[key] || []; return list.map(item => { return block.replace(/\{\{(\w+)\}\}/g, (m, k) => item[k] || ''); }).join(''); }); return html; } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }这里值得注意的一点是escapeHtml。最开始写这个中间件时,我图省事直接替换没做转义,结果业务数据里出现了一个「&&」符号,渲染出的模板结构直接错乱。静默打印最怕的不是报错,而是打出来一张排版错乱的废纸。所以凡是往 HTML 里塞的文本字段,必须走转义,只有从模板侧写死的标签才能当作可执行标记。列表循环语法虽然简陋,但足够覆盖大多数一单多品的打印场景,比如一份出库单包含多个品名、数量、单价行。
3.4 任务队列与状态管理:避免打印机驱动被并发打崩
队列模块负责两件事:控制打印请求的并发数量、维护任务状态供前端轮询。Windows 下的打印机驱动对并发任务非常敏感,实测下来同时提交 3 个以上的打印任务,驱动就存在假死风险。所以这个队列的本质是「串行执行 + 内存状态表」。下面贴队列模块的核心实现:
const { execFile } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); class PrintQueue { constructor() { this.tasks = []; this.running = false; this.statusMap = new Map(); // taskId -> { status, error } } push(task, done) { const taskId = task.taskId; this.statusMap.set(taskId, { status: 'pending', error: null }); this.tasks.push({ ...task, done }); this._drain(); } getStatus(taskId) { return this.statusMap.get(taskId) || { status: 'not_found', error: null }; } async _drain() { if (this.running || this.tasks.length === 0) return; this.running = true; while (this.tasks.length > 0) { const task = this.tasks.shift(); try { this.statusMap.set(task.taskId, { status: 'rendering', error: null }); const printData = await this._preparePrintData(task); this.statusMap.set(task.taskId, { status: 'printing', error: null }); await this._sendToPrinter(printData, task.printerName, task.copies); this.statusMap.set(task.taskId, { status: 'success', error: null }); task.done({ success: true }); } catch (err) { this.statusMap.set(task.taskId, { status: 'failed', error: err.message }); task.done({ success: false, error: err.message }); } } this.running = false; } async _preparePrintData(task) { const templatePath = path.join(__dirname, 'templates', task.templateId, 'index.html'); const templateHtml = fs.readFileSync(templatePath, 'utf-8'); const { renderTemplate } = require('./renderer'); const html = renderTemplate(templateHtml, task.data); // 临时文件写到系统临时目录,打印完成后清理 const tmpFile = path.join(os.tmpdir(), `silentprint_${task.taskId}.html`); fs.writeFileSync(tmpFile, html, 'utf-8'); return tmpFile; } async _sendToPrinter(htmlFile, printerName, copies) { return new Promise((resolve, reject) => { // Windows 下调用 powershell 调用打印命令,静默不弹窗 const psScript = ` $file = '${htmlFile.replace(/'/g, "''")}'; $printer = '${(printerName || 'Microsoft Print to PDF').replace(/'/g, "''")}'; $p = Start-Process -FilePath $file -NoNewWindow -PassThru; Start-Sleep -Seconds 1; if (!$p.HasExited) { $p.Kill(); } resolve(); `; execFile('powershell', ['-Command', psScript], (err) => { if (err) reject(new Error('调用打印命令失败: ' + err.message)); else resolve(); }); }); } } module.exports = { printQueue: new PrintQueue() };队列串行执行这一步是关键。刚开始实现时图快,用Promise.all并发打印,结果仓库那台一体机在打了第 3 张的时候驱动直接失去了响应。后来改成串行,虽然单张耗时不变,但整体稳定性提升了一个档次。串行的代价是吞吐量下降,但订单打印场景下每秒最多也就 2~3 个任务,串行完全够用。如果你的业务量大到需要真并发,那也是按打印机分组并发——同一个打印机串行、不同打印机并行,而不是无脑全并发。
注意:
_sendToPrinter里调用 PowerShell 的方式在不同 Windows 版本上表现不一,Win10 和 Win11 的Start-Process行为基本一致,但 Windows Server 上可能需要调整执行策略。后面避坑章节会展开讲。
4. 部署与联调:把中间件在当前机器上跑起来
4.1 环境准备与依赖安装
这个中间件对运行环境的要求很克制:Windows 10 或更高版本(因为打印命令基于 PowerShell)、Node.js 14 或更高版本(用到AbortController和可选链语法)、以及一个已经装好的打印机驱动。开发阶段调试时,强烈建议先装一个「Microsoft Print to PDF」虚拟打印机,这样不用浪费纸就能验证整条链路通不通。
项目拉下来后,先执行依赖安装:
# 进入项目根目录 cd silent-print # 安装依赖 npm install express uuid # 启动服务 node src/server.js启动成功后,控制台会打印监听端口号和临时 token。这里有一个容易被新手忽略的步骤:首次启动后中间件会在配置目录生成一个config.json文件,里面包含端口号、默认打印机名、token 值。改完配置必须重启中间件进程才生效,不能热加载。
4.2 配置参数说明
配置文件的结构很简单,但每个字段都有讲究:
{ "port": 18321, "defaultPrinter": "Microsoft Print to PDF", "token": "silent-print-2024-secret", "templatesDir": "./templates", "taskTimeoutSec": 30, "pollingIntervalMs": 800 }端口 18321 是我随手选的一个高位端口,基本不会和常用开发服务冲突。token 是浏览器与中间件之间的共享密钥,前端 SDK 每次请求都要在请求头带x-print-token。如果嫌每次启动都要手动同步 token 麻烦,可以直接在配置里写死——但这意味着任何本地网页都能打印,风险自担。taskTimeoutSec是任务超时保护,超过 30 秒还没打印完成的任务会被标记为失败,防止打印机卡纸后任务永远挂在队列里。
4.3 启动与验证:从 curl 到真机出纸
服务启动后,第一步先用 curl 验证中间件是否存活:
curl -X POST http://localhost:18321/api/print \ -H "Content-Type: application/json" \ -H "x-print-token: silent-print-2024-secret" \ -d '{"templateId":"invoice","data":{"orderNo":"SO001","customer":"某公司","items":[{"name":"USB线","qty":2}]},"printerName":"Microsoft Print to PDF"}'如果返回里带taskId,说明中间件已经把任务接进队列了。这时候再轮询状态:
curl http://localhost:18321/api/task/{taskId}直到返回"status":"success"。由于配的是虚拟打印机,系统会在「打印」目录下生成一个 PDF 文件,打开它检查模板渲染结果。这一步跑通了,再切换到真实打印机真机打一张,重点看排版和纸张尺寸是否匹配。整个联调过程建议按这个顺序走:先虚拟打印验渲染,再真机打印验出纸,最后在浏览器页面里点按钮验全链路。
5. 避坑指南:静默打印最常见的五个翻车现场
5.1 端口被占用导致中间件静默启动失败
现象:双击启动脚本后没有任何报错,但浏览器端请求直接超时,查进程发现 Node 进程确实在,但netstat显示端口没被监听。
原因:server.js里app.listen的EADDRINUSE错误被未捕获异常处理器吞掉了,导致进程挂起但服务不可用。18321 端口被某些代理类软件占用的概率不高但真实存在。
解决:启动脚本里增加端口检测,监听失败时打印明确错误并退出:
const server = app.listen(port, () => { console.log(`SilentPrint listening on ${port}`); }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`端口 ${port} 已被占用,请修改 config.json 里的 port 或释放该端口`); process.exit(1); } });从那以后我写任何本地服务,都会强制加上 listen 错误监听,血泪经验。
5.2 PowerShell 执行策略拦截打印脚本
现象:中间件日志显示调用 PowerShell 成功,但打印机没有任何反应;或者日志直接抛错「因为在此系统上禁止运行脚本」。
原因:部分 Windows 环境默认的 PowerShell 执行策略是Restricted,任何脚本都跑不了。execFile调 PowerShell 时会继承系统策略,触发拦截。
解决:在启动中间件时手动放开当前进程的执行策略,或者在_sendToPrinter的调用命令前加上绕过策略参数:
execFile('powershell', ['-ExecutionPolicy', 'Bypass', '-Command', psScript], (err) => { ... });注意-ExecutionPolicy Bypass只作用于本次进程,不会改系统全局设置,可以放心用。
5.3 打印内容中文乱码,数字和英文正常
现象:模板里的中文显示成乱码方块,但英文和数字完全正常。
原因:模板文件读进来后,Node 默认按 UTF-8 解析写入 HTML,但 PowerShell 的Start-Process打开 HTML 时若遇到没有声明字符集的文档,会按系统 ANSI 编码(GBK)解析,中文自然乱掉。
解决:渲染后的 HTML 模板中始终在<head>里显式声明<meta charset="utf-8">。另外,写临时文件时也要用fs.writeFileSync(tmpFile, html, 'utf-8'),这行代码看起来不起眼,漏掉就是乱码现场。
5.4 打印队列积压,后提交的任务先出纸
现象:同时提交了三个打印任务,结果第二个先打出来,用户以为系统乱序了。
原因:之前提到过的串行队列实现里,如果_drain内部while循环没有把task.done回调放到setImmediate里,会出现两个任务几乎同时在跑的情况;另外如果曾经有并行版本的代码残留,也会导致顺序错乱。
解决:当前队列实现里把遍历和回调边界理清楚——push之后立即返回 taskId,_drain里循环体内逐个 await 完成后才进入下一个任务,确保 FIFO 顺序。验证方法也简单:连续提交五个任务,看轮询状态变成 success 的顺序是否与提交顺序一致。
5.5 打印机显示离线,但直接打印文件是正常的
现象:中间件体检时返回所有打印机在线,但实际打印任务一直处于 pending 后失败。
原因:中间件探测打印机状态用的是powershell Get-Printer,它只能反映打印队列服务的状态,探测不到物理连接(比如 USB 线松脱、打印机休眠)。打印机可能在系统里显示「就绪」但物理上已经不响应。
解决:把「打印机探测」和「打印任务结果」分开看。前者只作为参考,后者以_sendToPrinter调用是否成功返回为准。另外在任务失败时,把spoolsv.exe的打印队列日志路径也一并记录到错误信息里,方便分辨是驱动问题还是物理链路问题。
6. 二次开发:把中间件改造成适配自己业务的样子
6.1 自定义模板的扩展思路
当前模板系统只支持{{key}}和{{#each list}}两种语法,面对复杂业务(比如需要判断字段为空时整行隐藏、根据金额大小变色)就不够用了。一个简单的升级方案是引入更完整的模板引擎。常见的做法是使用 Handlebars 替换自己写的renderTemplate函数,语法向后兼容,还能获得条件判断、自定义 helper 等能力。
松绑模板的技术栈之后,中间件的模板目录就成了一个完全开放的资源文件夹:新业务上线时,前端加一个按钮、中间件 templates 下加一个目录,不用改任何服务代码。这个改动看起来简单,但对业务接入方的价值极大——他们可以自己维护模板而不需要等待中间件发版。
6.2 队列策略:从全局串行到按打印机分组
之前说过全局串行的稳定性优势,但如果接入方把这个中间件用在多打印机门店场景(前台小票打印、后厨出单打印、快递面单打印各用一台),全局串行就会让不同打印机互相等待。改造思路是把队列从「单队列」改成「按 printerName 分队列」:
class MultiQueue { constructor() { this.queues = new Map(); // printerName -> PrintQueue this.statusMap = new Map(); } push(task, done) { const key = task.printerName || 'default'; if (!this.queues.has(key)) { this.queues.set(key, new PrintQueue()); } this.queues.get(key).push(task, done); } }不同打印机各自维护串行队列,互不阻塞;同一台打印机内部仍然保持顺序。这个改造只涉及队列模块,不影响前端 SDK 和模板渲染。改完后实测三台打印机同时出单,吞吐量从原来的 2 张/秒提升到 6 张/秒。
6.3 验证方法:用状态码辅助调试而不是只靠看纸
二次开发最怕的是凭感觉调参。给中间件加一个node src/debug.js诊断模式,启动后依次做五件事:检查端口是否被监听、读取模板文件并验证语法、尝试连接配置的默认打印机、用虚拟打印机发一个测试任务、轮询到完成并报告总耗时。每次改完代码先跑一遍诊断,再上真机,能省掉大半的联调时间。项目走到这一步,静默打印中间件已经不只是「能让网页调打印机」的玩具,而是一个能跟着业务需求持续演进的基础设施了。希望帮到你。
本文还有配套的精品资源,点击获取