隔一阵子就会有人去搜"js 静默打印 不弹对话框",翻到一条 2013 年的高赞回答,然后花两天时间确认:那些方法现在一个都不好使了。
需求本身完全合理:收银员点一下"结账",小票就出来;仓库系统渲染出面单,面单就打出来。没人愿意一小时点四十次打印对话框、再手动选一次打印机。
问题在于,这件事不该向浏览器要。想明白为什么,能省下绕一大圈偏方的时间——那些偏方最后都栽在同一个地方。
window.print() 为什么做不到
window.print() 打开的是浏览器的打印对话框。这个对话框不是可以用某个开关关掉的界面装饰,它是一条安全边界。如果网页能悄悄把字节推给硬件,任何一个广告 iframe 都能把你的纸打光,任何页面都能通过枚举打印机来做设备指纹。所以浏览器坚持要有人来拍板。
这条边界带来的后果不只是"多点一下":
- 选不了打印机。 页面无法枚举打印机,也无法指定其中一台。小票机如果不是系统默认打印机,就得用户每次手动选。
- 控制不了输出。 页边距、页眉页脚、缩放、分页来自对话框设置和浏览器自己的打印 CSS 实现。同一个页面在 Chrome 和 Safari 打出来不一样。
- 拿不到结果。
window.print()返回undefined。没有"打印成功",没有"打印机离线",没有任务 ID。afterprint只在对话框关闭时触发,纸有没有出来它不管。 - 它的生命周期就是那个标签页。 中途关掉标签页,状态就没了。
如果打印的是用户自己的文档——他正在看的报表、想存成纸的页面——那对话框是正确的,配一份像样的 @media print 样式表,window.print() 就是标准答案。这种情况看到这里就够了。
下面讨论的是另一种:固定工位上、作为业务流程一环的机器打印。
那些偏方,以及它们为什么反复失效
Chrome 的 --kiosk-printing。 用 --kiosk-printing 启动 Chrome,window.print() 就会直接打到默认打印机,不弹框。它确实有效——在那一台机器上、以那一种方式启动的时候。然后有人从任务栏而不是你的快捷方式打开了 Chrome,对话框就回来了;或者某次 Windows 更新把默认打印机切成了"Microsoft Print to PDF",小票开始悄无声息地变成文件。你依然拿不到任何状态,而且每台新终端都多了一道手工配置。
已经死掉的插件时代。 NPAPI 插件、Java Applet、ActiveX 控件、IE 里的 document.execCommand('print')——全部被移除。任何依赖它们的答案都是博物馆藏品。
自动打印的 PDF。 在 PDF 里塞 /OpenAction << /S /JavaScript /JS (this.print\(true\);) >>,Acrobat 打开即打印。但 Chrome 内置的 PDF 阅读器根本不执行 PDF 里的 JavaScript,其他阅读器的行为也各不相同。这条路靠不住。
打印 iframe。 iframe.contentWindow.print() 弹的是同一个对话框,只是范围限定在 iframe 里。它解决的是排版隔离,不是静默。
页面调本机的小助手。 机器上跑一个小程序监听 localhost,页面把任务 POST 给它,由它驱动打印机。这个形态才是真正走得通的——QZ Tray、JSPM,以及下文说的本地 Agent,都是它的不同版本。但要如实说清代价:每台机器都得装一个东西并保证它一直在跑;HTTPS 页面访问 http://localhost 会撞上跨域和私有网络访问的规则;而且你从此要负责一个桌面二进制的签名、更新和售后。
浏览器直连打印机 socket。 浏览器没有 TCP。fetch() 打 9100 端口这件事不存在,再聪明也变不出来。
可行的做法:把决策搬到服务端
回看上面每一次失败,规律是一样的:浏览器不知道该用哪台打印机、不能被信任去碰硬件、活不过那个标签页、也没法告诉你结果。当浏览器只表达意图、后端负责打印时,这四条同时消失。
浏览器:"订单 1042 完成了"
↓(调你自己后端的普通接口)
你的服务端:决定打什么内容、发到哪台打印机,调用打印 API
↓
云打印服务 → 门店里的 Agent → 打印机
↓
Webhook 回调你的服务端:打印成功 / 失败,带原因前端只是向自己的 API 发一个普通请求——没有插件、没有启动参数、没有 localhost:
async function completeSale(orderId) {
await fetch(`/api/orders/${orderId}/complete`, { method: 'POST' });
// 就这样——不弹对话框,不用选打印机
}后端生成内容,发到该门店的打印机。用 PrintBase 就是一次 HTTPS 调用:
// POST /api/orders/:id/complete
import { renderReceipt } from './receipt.js';
export async function completeSale(order) {
const escpos = renderReceipt(order); // 给热敏小票机的 ESC/POS 字节
const store = await db.getStore(order.storeId); // 记录了本店的 printer_code
const res = await fetch('https://api.printbase.cloud/v1/print-jobs', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
printer_code: store.receiptPrinterCode,
content_type: 'raw', // ESC/POS 原样直通
content: escpos.toString('base64'),
copies: 1,
}),
});
const job = await res.json();
await db.recordPrintJob(order.id, job.id); // job_abc,status: queued
return job;
}打 PDF——发票、拣货单、票据——是同一个调用,把 content_type 换成 pdf,内容用 base64 的 content 或让 Agent 去下载的 content_url。
换来的正是浏览器给不了的东西:
- 永远是对的那台打印机。
printer_code来自你的数据库,而不是用户上次随手选中的项。小票进小票机,面单进标签机。 - 字节级一致的输出。
raw内容原样透传,一张 ESC/POS 小票或 ZPL 面单在每台机器上打出来都一样——不经过打印 CSS,也不被驱动重新解释。 - 有任务 ID,有真实结果。 订阅 Webhook,你会知道任务
completed,或者在wh-pc-02上因PRINTER_OFFLINE而failed。这就是"能打印的系统"和"能运维的打印系统"之间的差别。 - 浏览器侧零安装。 每个站点一个轻量 Agent,而不是每台机器、每个浏览器、每个用户配置都装一遍插件。
如果打印机就在用户自己机器上
也不是所有场景都是固定工位。有时候文档属于坐在那儿的人,必须从他自己的打印机出来——比如用户在家打印自己的快递面单。
如果对话框可以接受,window.print() 仍是正确工具。如果不能接受,那就回到"要在本地装东西",而且要认清:这本质上是个披着 Web 外衣的桌面软件问题。PrintBase 的 Agent 支持这种模式:开启本地网页打印后,它监听 http://127.0.0.1:17891,接收同样的 POST /v1/print-jobs 结构,先本地打印,再异步把任务补记到云端。它在 localhost 开发和 PrintBase 控制台里确实好用——但它只接受来自 localhost 和 printbase.cloud 的来源,所以你自己的 app.example.com 没法直接调它。其余场景走云端 API,让 Agent 安静地待在后台就好。
一张表选型
| 场景 | 用什么 | 弹框吗 |
|---|---|---|
| 用户打印自己正在看的页面 | window.print() + 打印样式表 | 弹,而且应该弹 |
| 固定工位:收银、仓库、后厨 | 后端 → 云打印 API → Agent | 不弹 |
| 后端事件触发打印(订单支付、面单下单) | 云打印 API,浏览器根本不参与 | 不弹 |
| 必须静默打到最终用户自己的打印机 | 装本地小助手,并接受随之而来的运维成本 | 不弹 |
| 依赖插件、kiosk 参数、PDF 自动打印的一切 | 别用 | 迟早坏 |
一句话总结
JavaScript 无法静默打印,也没有哪个开关或技巧能可靠地改变这一点——对话框是安全边界,不是没做完的功能。浏览器缺的其实不是权限,而是信息:该用哪台打印机、该用什么格式、打完之后怎么样了。这三样你的服务端全都有。
让页面只负责说"发生了什么",让后端决定"什么内容打到哪里"。这样"不弹框打印"就不再是浏览器难题,而只是一次普通的 API 调用。
PrintBase 就是这次 API 调用:后端 POST 一个任务,打印机旁的轻量 Agent 通过外发连接收到它,Webhook 告诉你实际打出来了什么。免费套餐 每月 100 个任务、不用绑卡,一个下午足够把 kiosk 模式的临时方案换掉。先看 文档;如果你做的是收银或自助终端,可以直接从 小票打印 API 概览页看起。
