目录
- 背景:为什么说"先下载再打印"是反人类体验?
- 底层核心骨架:为什么必须使用"隐藏 Iframe 沙盒"?
- 格式一:PDF 直接打印(含 jsPDF 实例、Blob、URL)
- 用到了哪些技术?
- 怎么用的?(源码拆解)
- 为什么要这么用?(破解"打印弹窗闪退"与异步生命周期陷阱)
- 业务实战案例
- 格式二:图片直接打印(PNG / JPEG / Canvas 画布)
- 用到了哪些技术?
- 怎么用的?(源码拆解)
- 为什么要这么用?(消除浏览器自带页眉页脚、防换页腰斩、缓存就绪陷阱)
- 业务实战案例
- 格式三:Excel / 业务表格直接打印
- 用到了哪些技术?
- 怎么用的?(源码拆解)
- 为什么要这么用?(为什么不能直接送 xlsx 给打印机?表格细边框与排版)
- 业务实战案例
- 格式四:局部 DOM / 弹窗组件直接打印
- 用到了哪些技术?
- 怎么用的?(源码拆解)
- 为什么要这么用?(样式完美继承与隔离)
- 业务实战案例
- 完整封装:万能工具类
PrintUtils.ts源码 - 组件级实战:在 Vue 3 弹窗中通过 Switch 实现"导出/打印"无缝切换
- 全景对比与避坑总结
01. 背景:为什么说"先下载再打印"是反人类体验?
在制造业系统(MES/CAPP/ERP)、仓储物流(WMS)或电商后台管理中,我们经常遇到类似需求:
- "导出部件工序流程图(PNG/JPEG)"
- "导出工序规程卡(PDF)"
- "导出工艺明细表(Excel)"
按传统的做法,前端通常是触发一个下载事件,把文件存到用户的电脑"下载"文件夹里。但对于车间工人、仓库管理员或前台工作人员来说,他们的真实诉求往往是:"我就想打一张纸贴在机器上/包裹上,为什么还要我去文件夹翻半天再右键打印?能不能在网页上点一下,直接弹出打印机面板?!"
如果直接调用浏览器的 window.print(),默认会把当前窗口里所有的东西(包括导航栏、工具条、面包屑、甚至滚动条)全都打进去,排版一团糟。
因此,我们需要一个"前端静默打印系统"------在后台完成排版渲染,直接唤起系统打印对话框,无需用户落地任何文件。
02. 底层核心骨架:为什么必须使用"隐藏 Iframe 沙盒"?
无论是 PDF、图片、表格还是局部 HTML,要在主页面不受任何干扰的前提下调起打印,最优雅的方式就是:在页面不可见区域创建一个独立的 <iframe>,把要打印的内容装进去,然后调用 iframe.contentWindow.print()。
1. 架构示意图
less
┌─────────────────────────────────────────────────────────────┐
│ 浏览器主页面 (用户正常操作界面) │
│ │
│ [导出/打印按钮] │
│ │ │
│ ▼ 触发打印指令 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 全局隐藏 Iframe (__app_print_iframe__) │ │
│ │ style: position: fixed; top: -9999px; ... │ │
│ │ │ │
│ │ [内容沙盒] │ │
│ │ ├── PDF: blob:http://... (Chrome内置PDF插件渲染) │ │
│ │ ├── 图片: <html><img ... /></html> │ │
│ │ └── 表格: <html><table ... /></html> │ │
│ │ │ │
│ │ 执行 iframe.contentWindow.print() │ │
│ └──────────────────────┬──────────────────────────────┘ │
└──────────────────────────┼──────────────────────────────────┘
▼
┌───────────────────────┐
│ 调起系统打印机预览窗口 │
│ (只打印 Iframe 里的内容)│
└───────────────────────┘
2. 封装统一Iframe代码用于不同格式打印方法获取
typescript
let printIframeInstance: HTMLIFrameElement | null = null;
function getOrCreatePrintIframe(): HTMLIFrameElement {
if (!printIframeInstance || !document.body.contains(printIframeInstance)) {
printIframeInstance = document.createElement("iframe");
printIframeInstance.id = "__app_print_iframe__";
// 关键样式设置 设置隐藏
printIframeInstance.style.position = "fixed";
printIframeInstance.style.top = "-9999px";
printIframeInstance.style.left = "-9999px";
printIframeInstance.style.width = "10px";
printIframeInstance.style.height = "10px";
printIframeInstance.style.visibility = "hidden";
printIframeInstance.style.border = "none";
document.body.appendChild(printIframeInstance);
}
return printIframeInstance;
}
- 为什么不写
display: none,而是用top: -9999px; visibility: hidden;?
答 :在某些版本的 Chrome 和 Safari 中,如果 iframe 是display: none或者width: 0; height: 0,浏览器的渲染引擎会直接优化掉它的绘制过程,导致其内部的 PDF 插件或者图片不会被解码,进而导致打印出来是白纸!给一个极小的尺寸并移到屏幕可视区之外,既不影响视觉,又能保证渲染树正常构建。
3. 格式一:PDF 直接打印(含 jsPDF 实例、Blob、URL)
1. 用到了哪些?
URL.createObjectURL(blob)与URL.revokeObjectURL(url):将二进制的 Blob 数据转为浏览器可识别的临时 URL。jsPDF.output("blob"):提取前端通过jspdf库动态画出的 PDF 二进制流。jsPDF.autoPrint({ variant: "non-conform" }):在 PDF 的元数据结构(Dictionary)中注入原生自动打印动作指令(Action)。iframe.onload事件 + 微延迟:监听底层 PDF 渲染引擎的就绪状态。
2. 怎么用的?
typescript
let currentActiveBlobUrl: string | null = null;
export function printPDF(source: jsPDF | Blob | string): Promise<void> {
return new Promise((resolve, reject) => {
try {
// 1. 释放上一次可能存在的旧 Blob URL,防止内存泄漏
if (currentActiveBlobUrl) {
URL.revokeObjectURL(currentActiveBlobUrl);
currentActiveBlobUrl = null;
}
let blobUrl = "";
// 2. 统一转为可访问的 URL 字符串
if (typeof source === "string") {
blobUrl = source;
} else if (source instanceof Blob) {
blobUrl = URL.createObjectURL(source);
currentActiveBlobUrl = blobUrl;
} else if (source && typeof (source as jsPDF).output === "function") {
// 如果是 jsPDF 实例,先注入自动打印标记
try {
if (typeof (source as jsPDF).autoPrint === "function") {
(source as jsPDF).autoPrint({ variant: "non-conform" });
}
} catch (e) {}
const blob = (source as jsPDF).output("blob");
blobUrl = URL.createObjectURL(blob);
currentActiveBlobUrl = blobUrl;
} else {
throw new Error("传入的 PDF 数据源无效");
}
// 3. 拿到单例 iframe
const iframe = getOrCreatePrintIframe();
iframe.onload = null; // 清空可能残留的历史监听
iframe.onload = () => {
try {
// 4. 加 300ms 延时确保 Chrome 内置 PDF Viewer 插件完全挂载
setTimeout(() => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve();
} catch (err) {
console.warn("直接调用 iframe.print 失败,尝试回退处理", err);
resolve();
}
}, 300);
} catch (err) {
reject(err);
}
};
// 5. 将 PDF 流赋值给 iframe 开始加载
iframe.src = blobUrl;
} catch (error) {
reject(error);
}
});
}
3. 为什么要这么用?
① 为什么要在下一次打印的时候再清除上一次的内容?
- 根本原因 : 如果使用定时器
setTimeout(() => URL.revokeObjectURL(blobUrl), 3000)。在 Chrome 里,打印弹窗弹出来之后,底层的 PDF 阅读插件依然在通过这个blob:URL 读取切片数据。只要计时器一到把 URL revoke 了,底层文件流瞬间 404,打印插件判定"文件丢失",直接强行关闭打印面板(闪退)。 - 我们的解法 :不使用定时器去销毁 Blob!把上一个活跃的 URL 存在变量
currentActiveBlobUrl中,只有当用户下一次发起打印时,才去释放上一次的内存。这保证了在当前打印预览窗口存续期间,数据流永远有效。
② 为什么要给 jsPDF 加 autoPrint 指令?
jsPDF 支持通过 pdf.autoPrint({ variant: 'non-conform' }) 将 Adobe PDF 规范中的 /OpenAction << /S /JavaScript /JS (this.print({bUI: true, bSilent: false, bShrinkToFit: true});) >> 动作写入 PDF 文件头部。这样不仅可以通过 iframe print 调起,即使用户在某些老旧浏览器中用新标签页打开,PDF 也会自动弹出打印窗口。
③ 为什么要加 300ms 延迟?
因为当 iframe.src = blobUrl 触发 onload 时,仅仅表示 iframe 本身加载完了 HTML 容器,而 Chrome 内置的 PDF 插件(Chromium PDF Plugin)可能还在初始化它的内部虚拟 DOM。稍微延迟 300ms 能极大提高唤起打印机的成功率。
4. 业务实战案例
typescript
import { jsPDF } from "jspdf";
import { printPDF } from "@/utils/PrintUtils";
// 场景 A:前端用 jsPDF 动态生成一份 A4 横向工序单并直接打印
async function handlePrintJsPDF(dataUri: string, ksbh: string) {
const pdf = new jsPDF("l", "mm", "a4"); // 横向 landscape, 毫米, A4
// 把一张流程图绘制到 PDF 中 (留 4mm 边距)
pdf.addImage(dataUri, "JPEG", 4, 4, 289, 0);
// 一键直接打印,浏览器无下载,直接唤出打印机!
await printPDF(pdf);
}
// 场景 B:后端接口返回了 PDF 二进制流 (ArrayBuffer / Blob)
async function handlePrintRemotePdf(orderId: string) {
const response = await fetch(`/api/order/pdf?id=${orderId}`);
const blob = await response.blob();
// 直接打印 Blob
await printPDF(blob);
}
04. 格式二:图片直接打印(PNG / JPEG / Canvas 画布)
1. 用到了哪些?
- CSS
@page规则:用于控制打印介质(纸张大小、横向纵向、清除浏览器页眉页脚)。 - CSS
page-break-inside: avoid;:强制元素不可跨页切断。 - DOM
iframe.document.write(...):最小化Dom结构打印 HTML。 img.complete与img.onload状态判定 :解决图片缓存导致onload不触发的问题。
2. 怎么用的?
typescript
export function printImage(
imageSrc: string,
options?: { landscape?: boolean; title?: string }
): Promise<void> {
return new Promise((resolve, reject) => {
try {
const iframe = getOrCreatePrintIframe();
const doc = iframe.contentWindow?.document;
if (!doc) throw new Error("无法访问 iframe 文档");
// 判断纸张方向:横向 (landscape) 还是纵向 (portrait)
const orientation = options?.landscape ? "landscape" : "portrait";
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<title>${options?.title || "打印图片"}</title>
<style>
/* 核心:消除默认打印机页眉页脚与指定方向 */
@page {
size: auto ${orientation};
margin: 0;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #ffffff;
}
/* 核心:图片缩放保持比例且居中,坚决不分页切断 */
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
page-break-inside: avoid;
}
</style>
</head>
<body>
<img src="${imageSrc}" id="print-target" />
</body>
</html>
`);
doc.close();
const img = doc.getElementById("print-target") as HTMLImageElement | null;
if (!img) return resolve();
const triggerPrint = () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve();
} catch (err) {
reject(err);
}
};
// 如果图片已经读取完毕(如 Base64),直接打印;否则等待 onload
if (img.complete) {
setTimeout(triggerPrint, 100);
} else {
img.onload = () => setTimeout(triggerPrint, 100);
img.onerror = (e) => reject(e);
}
} catch (error) {
reject(error);
}
});
}
3. 为什么要这么用?
① 为什么打印图片时,纸张顶部总是印着网页标题,底部总是印着 file:///C:/Users/... 和打印时间?
- 原因:浏览器在打印时默认会添加 Header(标题和 URL)和 Footer(日期和页码)。
- 解法 :在 CSS 中加入
@page { margin: 0; }。当打印页边距被显式设为 0 时,浏览器会自动隐藏系统自带的页眉和页脚,打印出来的图片极其干净专业。
② 为什么必须同时检查 img.complete 和 img.onload?
- 原因 :前端大部分生成的图片是 Base64(Data URI)。在 Chromium 内核中,如果一个 Base64 图片非常小或者已经被浏览器完全缓存,
doc.write()写入完成的瞬间,img.complete可能已经是true了。此时浏览器不会再去派发onload事件 !如果代码里只傻傻等待img.onload,就会发生"无响应卡死"的 bug。
③ 为什么加 object-fit: contain; 和 page-break-inside: avoid;?
- 原因 :如果图片的原始分辨率过大(比如 4K 图),打印机会把它粗暴地截成两页,上半截在一张纸,下半截在第二张纸。通过
max-width: 100%; max-height: 100%; object-fit: contain;限制在单张纸内等比缩放,配合page-break-inside: avoid;告诉打印引擎"这个元素坚决不能在中间切开分两页"。
4. 业务实战案例
typescript
import { printImage } from "@/utils/PrintUtils";
// 场景:在 Konva 画布或者网页上截取了一张高精度工序设计图
function handlePrintCanvas(stage: any) {
// 1. 导出清晰度为 2 倍的 Base64 图片
const base64Data = stage.toDataURL({ pixelRatio: 2 });
// 2. 调起横向 A4 打印
printImage(base64Data, {
title: "流程图-A4横向",
landscape: true,
});
}
05. 格式三:业务表格直接打印
1. 用到了哪些?
- 语义化 HTML 表格元素 :
<table>、<thead>、<tbody>、<tr>、<th>、<td>。 - CSS
border-collapse: collapse;:消除单元格双线空隙,生成工业级 1px 细黑线实线表格。 - 分页样式规则 :
page-break-inside: avoid;作用于<tr>,防止表格数据行在翻页时文字被上下截断。
2. 怎么用的?
typescript
export interface PrintTableOptions extends PrintHTMLOptions {
headers: string[]; // 表头数组
rows: (string | number)[][]; // 二维表格内容数组
title?: string; // 表格标题
}
export function printTable(options: PrintTableOptions): Promise<void> {
const { title, headers, rows, landscape = true } = options;
// 1. 构造表头 HTML
const theadHtml = `
<thead>
<tr>
${headers.map((h) => `<th>${h}</th>`).join("")}
</tr>
</thead>
`;
// 2. 构造表体 HTML
const tbodyHtml = `
<tbody>
${rows
.map(
(row) => `
<tr>
${row.map((cell) => `<td>${cell !== undefined && cell !== null ? cell : ""}</td>`).join("")}
</tr>
`
)
.join("")}
</tbody>
`;
// 3. 构造完整表格
const tableHtml = `
${title ? `<h2 style="text-align: center; margin-bottom: 16px; font-size: 18px;">${title}</h2>` : ""}
<table class="print-table">
${theadHtml}
${tbodyHtml}
</table>
`;
// 4. 注入细线表格与分页控制样式
const defaultTableStyles = `
.print-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
/* 关键:行不被跨页截断 */
.print-table tr {
page-break-inside: avoid;
page-break-after: auto;
}
.print-table th, .print-table td {
border: 1px solid #333333;
padding: 6px 8px;
text-align: left;
}
.print-table th {
background-color: #f2f2f2;
font-weight: 600;
text-align: center;
}
`;
return printHTML(tableHtml, {
title: title || "数据表格打印",
landscape,
styles: `${defaultTableStyles}\n${options.styles || ""}`,
margin: options.margin || "8mm",
});
}
3. 为什么要这么用?
① 为什么不能把 .xlsx 接口返回的数据流直接发给打印机?
- 原因 :"为什么能打 PDF,却不能直接打 Excel?"
因为 PDF 格式本身就是为了打印(印刷)而发明的,现代浏览器内置了 PDF 渲染器;但.xlsx是微软定义的 Office Open XML 压缩包,浏览器没有任何原生插件能直接将.xlsx二进制文件光栅化成打印机指令。 - 解法 :在"直接打印"模式下,根本不需要走
xlsx的打包转换!我们只需要提取业务中的数据数组,直接在前端组装成符合工业制表标准的 HTML Table,样式完全由前端掌控,且速度比生成 Excel 快 10 倍以上。
② 为什么表格要设置 page-break-inside: avoid 在 <tr> 上?
- 原因:当表格很长有上百条记录时,必然会打印出好几页纸。如果不加这一行,打印机在换页临界点处,经常会出现"某一行字的上半截在第 1 页最底下,下半截在第 2 页最顶上"的尴尬情况。加上后,浏览器会自动判断:如果当前页剩余空间装不下一整行,整行自动移到下一页开头,排版极其专业。
4. 业务实战案例
typescript
import { printTable } from "@/utils/PrintUtils";
// 场景:从 Pinia / Vuex 或者后端获取工序列表,直接打出工艺明细表
async function handlePrintSheet(bh: string, list: any[]) {
// 1. 定义表头
const headers = ["序号", "名称", "代码", "等级", "时间(min)", "单价(元)"];
// 2. 映射成二维数据行
const rows = list.map((item, index) => [
index + 1,
item.name,
item.machine || "无",
item.level || "常规",
item.time,
item.price
]);
// 3. 直接调起打印
await printTable({
title: `流程明细: ${bh}`,
headers,
rows,
landscape: true, // 横向排版,适合宽表格
});
}
06. 格式四:局部 DOM / 弹窗组件直接打印
1. 用到了哪些?
element.innerHTML:抓取目标 DOM 片段的内容。document.querySelectorAll("style, link[rel='stylesheet']"):提取当前页面上所有的 CSS 样式规则(包括全局 CSS、组件 scoped 样式、UI 框架如 Vuetify / Element Plus 的样式)。
2. 怎么用的?
typescript
export function printDOM(
elementOrSelector: HTMLElement | string,
options?: PrintHTMLOptions
): Promise<void> {
// 1. 获取目标 DOM 节点
const el =
typeof elementOrSelector === "string"
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!el) {
return Promise.reject(new Error("找不到需要打印的 DOM 节点"));
}
// 2. 复制当前宿主页面的全部 style 标签和 link 样式表
let styles = "";
document.querySelectorAll("style, link[rel='stylesheet']").forEach((node) => {
styles += node.outerHTML;
});
// 3. 把提取出来的外部样式和局部 DOM 塞入 iframe 中独立打印
return printHTML(`<div>${el.innerHTML}</div>`, {
...options,
styles: `${styles}\n${options?.styles || ""}`,
});
}
3. 为什么要这么用?(核心避坑解析)
① 为什么不直接在主页面写 @media print 打印?
传统做法是在全局 CSS 里写:
css
@media print {
body * { visibility: hidden; }
#my-dialog, #my-dialog * { visibility: visible; }
}
缺点:
- 会污染主页面的布局上下文,在很多具有
overflow: hidden或固定定位position: fixed的复杂后台管理系统中,会导致打印预览页面空白、或者高度计算错误截断; - 会在打印期间看到主页面发生重排闪烁。通过把 DOM 的 innerHTML 抽离到独立 iframe,主页面完全不受任何视觉影响。
② 为什么一定要抽取 <style> 和 <link>?
如果只把 el.innerHTML 塞给 iframe,打印出来的内容会完全丢失样式(所有的按钮变成丑陋的纯文字,排版全乱)。通过抓取当前文档的全部样式表一起灌进 iframe,就能 100% 还原 UI 库(Vuetify、Element、Tailwind 等)原本的高级质感。
4. 业务实战案例
typescript
import { printDOM } from "@/utils/PrintUtils";
// 场景:页面上有一个预览弹窗卡片(id="preview-card"),只想打印卡片里的内容
function handlePrintModalContent() {
printDOM("#preview-card", {
title: "工单卡片打印",
landscape: false, // 纵向
margin: "12mm"
});
}
07. 完整封装:万能工具类 PrintUtils.ts 源码
将以下代码保存至 src/utils/PrintUtils.ts 即可直接使用:
typescript
import type { jsPDF } from "jspdf";
export interface PrintHTMLOptions {
title?: string;
landscape?: boolean;
styles?: string;
margin?: string;
}
export interface PrintTableOptions extends PrintHTMLOptions {
headers: string[];
rows: (string | number)[][];
title?: string;
}
// 全局单例隐藏 iframe 与当前活跃的 blob URL
let printIframeInstance: HTMLIFrameElement | null = null;
let currentActiveBlobUrl: string | null = null;
function getOrCreatePrintIframe(): HTMLIFrameElement {
if (!printIframeInstance || !document.body.contains(printIframeInstance)) {
printIframeInstance = document.createElement("iframe");
printIframeInstance.id = "__app_print_iframe__";
printIframeInstance.style.position = "fixed";
printIframeInstance.style.top = "-9999px";
printIframeInstance.style.left = "-9999px";
printIframeInstance.style.width = "10px";
printIframeInstance.style.height = "10px";
printIframeInstance.style.visibility = "hidden";
printIframeInstance.style.border = "none";
document.body.appendChild(printIframeInstance);
}
return printIframeInstance;
}
/**
* 打印 PDF(支持 jsPDF 实例、Blob 或 URL 路径)
*/
export function printPDF(source: jsPDF | Blob | string): Promise<void> {
return new Promise((resolve, reject) => {
try {
if (currentActiveBlobUrl) {
URL.revokeObjectURL(currentActiveBlobUrl);
currentActiveBlobUrl = null;
}
let blobUrl = "";
if (typeof source === "string") {
blobUrl = source;
} else if (source instanceof Blob) {
blobUrl = URL.createObjectURL(source);
currentActiveBlobUrl = blobUrl;
} else if (source && typeof (source as jsPDF).output === "function") {
try {
if (typeof (source as jsPDF).autoPrint === "function") {
(source as jsPDF).autoPrint({ variant: "non-conform" });
}
} catch (e) {}
const blob = (source as jsPDF).output("blob");
blobUrl = URL.createObjectURL(blob);
currentActiveBlobUrl = blobUrl;
} else {
throw new Error("传入的 PDF 数据源无效");
}
const iframe = getOrCreatePrintIframe();
iframe.onload = null;
iframe.onload = () => {
setTimeout(() => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve();
} catch (err) {
resolve();
}
}, 300);
};
iframe.src = blobUrl;
} catch (error) {
reject(error);
}
});
}
/**
* 打印图片(Base64 / Data URI / 网页图片地址)
*/
export function printImage(
imageSrc: string,
options?: { landscape?: boolean; title?: string }
): Promise<void> {
return new Promise((resolve, reject) => {
try {
const iframe = getOrCreatePrintIframe();
const doc = iframe.contentWindow?.document;
if (!doc) throw new Error("无法获取 iframe 文档");
const orientation = options?.landscape ? "landscape" : "portrait";
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<title>${options?.title || "打印图片"}</title>
<style>
@page { size: auto ${orientation}; margin: 0; }
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
background-color: #ffffff;
}
img { max-width: 100%; max-height: 100%; object-fit: contain; page-break-inside: avoid; }
</style>
</head>
<body>
<img src="${imageSrc}" id="print-target" />
</body>
</html>
`);
doc.close();
const img = doc.getElementById("print-target") as HTMLImageElement;
const triggerPrint = () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve();
} catch (err) {
reject(err);
}
};
if (img.complete) setTimeout(triggerPrint, 100);
else {
img.onload = () => setTimeout(triggerPrint, 100);
img.onerror = (e) => reject(e);
}
} catch (error) {
reject(error);
}
});
}
/**
* 打印自定义 HTML 内容
*/
export function printHTML(
htmlContent: string,
options?: PrintHTMLOptions
): Promise<void> {
return new Promise((resolve, reject) => {
try {
const iframe = getOrCreatePrintIframe();
const doc = iframe.contentWindow?.document;
if (!doc) throw new Error("无法获取 iframe 文档");
const orientation = options?.landscape ? "landscape" : "portrait";
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<title>${options?.title || "打印"}</title>
<style>
@page { size: auto ${orientation}; margin: ${options?.margin || "10mm"}; }
body { font-family: sans-serif; color: #333; background: #fff; margin: 0; padding: 0; }
${options?.styles || ""}
</style>
</head>
<body>${htmlContent}</body>
</html>
`);
doc.close();
setTimeout(() => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve();
} catch (err) {
reject(err);
}
}, 200);
} catch (error) {
reject(error);
}
});
}
/**
* 打印 DOM 局部节点
*/
export function printDOM(
elementOrSelector: HTMLElement | string,
options?: PrintHTMLOptions
): Promise<void> {
const el =
typeof elementOrSelector === "string"
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!el) return Promise.reject(new Error("找不到需要打印的 DOM 节点"));
let styles = "";
document.querySelectorAll("style, link[rel='stylesheet']").forEach((node) => {
styles += node.outerHTML;
});
return printHTML(`<div>${el.innerHTML}</div>`, {
...options,
styles: `${styles}\n${options?.styles || ""}`,
});
}
/**
* 打印表格数据(替代 Excel 下载,直接打印标准报表)
*/
export function printTable(options: PrintTableOptions): Promise<void> {
const { title, headers, rows, landscape = true } = options;
const theadHtml = `
<thead>
<tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr>
</thead>
`;
const tbodyHtml = `
<tbody>
${rows.map((row) => `
<tr>${row.map((cell) => `<td>${cell ?? ""}</td>`).join("")}</tr>
`).join("")}
</tbody>
`;
const tableHtml = `
${title ? `<h2 style="text-align: center; margin-bottom: 14px;">${title}</h2>` : ""}
<table class="print-table">
${theadHtml}
${tbodyHtml}
</table>
`;
const defaultTableStyles = `
.print-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.print-table tr { page-break-inside: avoid; }
.print-table th, .print-table td { border: 1px solid #333; padding: 6px 8px; }
.print-table th { background-color: #f2f2f2; font-weight: bold; text-align: center; }
`;
return printHTML(tableHtml, {
title: title || "数据表格打印",
landscape,
styles: `${defaultTableStyles}\n${options.styles || ""}`,
margin: options.margin || "8mm",
});
}
export const PrintUtils = {
printPDF,
printImage,
printHTML,
printDOM,
printTable,
};
export default PrintUtils;
08. 组件级实战:在 Vue 3 弹窗中通过 Switch 实现"导出/打印"无缝切换
为了让产品交互更加自然,我们在前端弹窗中采用**"开关分流模式"**:用户既可以按原逻辑下载文件,也可以通过 Switch 开关一键切换为直接打印。
实战示例代码(Vue 3 + Vuetify / Element Plus)
html
<template>
<v-dialog v-model="visible" width="400px">
<v-card title="导出与打印选项">
<v-card-text>
<!-- 核心 Switch:控制是否直接打印 -->
<v-switch
label="直接连接打印机打印"
v-model="isDirectPrint"
color="indigo"
inset
hide-details
/>
<div v-if="isDirectPrint" class="mt-3 text-caption text-grey">
开启后将跳过文件下载,直接调起系统打印机面板。
</div>
</v-card-text>
<v-card-actions class="justify-end">
<!-- 按钮文案根据 Switch 动态改变 -->
<v-btn color="primary" @click="handleAction" :loading="loading">
{{ isDirectPrint ? "立即打印" : "下载保存" }}
</v-btn>
<v-btn @click="visible = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { PrintUtils } from "@/utils/PrintUtils";
const visible = ref(false);
const isDirectPrint = ref(false); // 默认下载,开启后打印
const loading = ref(false);
const props = defineProps<{
dataUri?: string; // 图片或画布导出的 Base64
tableData?: any[]; // 表格数据
}>();
const handleAction = async () => {
loading.value = true;
try {
if (isDirectPrint.value) {
// 场景 1:如果存在图片数据,走图片打印
if (props.dataUri) {
await PrintUtils.printImage(props.dataUri, { landscape: true });
}
// 场景 2:如果是表格业务数据,走表格打印
else if (props.tableData) {
await PrintUtils.printTable({
title: "生产工单规程表",
headers: ["序号", "工序名称", "工时"],
rows: props.tableData.map((d, i) => [i + 1, d.name, d.time]),
});
}
visible.value = false;
} else {
// 传统下载模式
downloadFile();
}
} catch (err) {
console.error("操作失败", err);
} finally {
loading.value = false;
}
};
const downloadFile = () => {
// 原有下载逻辑(如 downloadURI 或 window.open)
console.log("执行常规下载...");
};
</script>
09. 全景对比与总结
| 打印类型 | 核心处理机制 | 为什么要这么做? | 最常见崩溃 / 翻车原因 |
|---|---|---|---|
Blob URL + 单例 iframe + autoPrint |
保证 Chromium PDF 插件在读取期间文件流不中断 | 盲目用 setTimeout 调用 URL.revokeObjectURL 或销毁 iframe 导致弹窗闪退 |
|
| 图片 | <img> + CSS @page { margin: 0; } |
消除浏览器默认的页眉页脚,图片等比自适应单页 | 只监听 onload 忽略 img.complete 导致 Base64 缓存卡死;未加 avoid 被截断 |
| 表格 (Excel) | 纯数据 ➔ 细实线语义化 <table> |
浏览器无法直接打印二进制 .xlsx;纯数据制表渲染速度快且样式可控 |
没设 border-collapse: collapse 导致边框模糊;未在 <tr> 加 page-break-inside: avoid 导致换页断字 |
| 局部 DOM | 提取 innerHTML + 复制全站 CSS <style> |
隔离主页面布局,避免全局打印样式闪烁,且 100% 保留 UI 框架质感 | 漏复制组件样式表导致打印出来是"无样式素颜排版" |