[JS] 一站式搞定 PDF、图片、Dom弹窗、表格的浏览器打印功能

目录

  1. 背景:为什么说"先下载再打印"是反人类体验?
  2. 底层核心骨架:为什么必须使用"隐藏 Iframe 沙盒"?
  3. 格式一:PDF 直接打印(含 jsPDF 实例、Blob、URL)
    • 用到了哪些技术?
    • 怎么用的?(源码拆解)
    • 为什么要这么用?(破解"打印弹窗闪退"与异步生命周期陷阱)
    • 业务实战案例
  4. 格式二:图片直接打印(PNG / JPEG / Canvas 画布)
    • 用到了哪些技术?
    • 怎么用的?(源码拆解)
    • 为什么要这么用?(消除浏览器自带页眉页脚、防换页腰斩、缓存就绪陷阱)
    • 业务实战案例
  5. 格式三:Excel / 业务表格直接打印
    • 用到了哪些技术?
    • 怎么用的?(源码拆解)
    • 为什么要这么用?(为什么不能直接送 xlsx 给打印机?表格细边框与排版)
    • 业务实战案例
  6. 格式四:局部 DOM / 弹窗组件直接打印
    • 用到了哪些技术?
    • 怎么用的?(源码拆解)
    • 为什么要这么用?(样式完美继承与隔离)
    • 业务实战案例
  7. 完整封装:万能工具类 PrintUtils.ts 源码
  8. 组件级实战:在 Vue 3 弹窗中通过 Switch 实现"导出/打印"无缝切换
  9. 全景对比与避坑总结

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.completeimg.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.completeimg.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; }
}

缺点

  1. 会污染主页面的布局上下文,在很多具有 overflow: hidden 或固定定位 position: fixed 的复杂后台管理系统中,会导致打印预览页面空白、或者高度计算错误截断;
  2. 会在打印期间看到主页面发生重排闪烁。通过把 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. 全景对比与总结

打印类型 核心处理机制 为什么要这么做? 最常见崩溃 / 翻车原因
PDF 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 框架质感 漏复制组件样式表导致打印出来是"无样式素颜排版"
相关推荐
JavaGuide1 小时前
阿里 Qoder 又开源了一个专门给 Claude Code、Codex 做“体检”的项目
前端·后端
大模型码小白2 小时前
数据可视化:AI 生成 HTML5 动态交互式数据图表
前端·数据库·人工智能·深度学习·机器学习·信息可视化·html5
八荒启·交互动画2 小时前
Web特效01—什么是渲染
前端·javascript·网页特效·八荒启-交互动画
小肥君2 小时前
前端测试websocket
前端·websocket·状态模式
南京兴帝文化传媒有限公司2 小时前
基于地图平台的本地商户信息优化:药店夜间服务标注与客户转化实操
前端·javascript·数据库·人工智能·geo 优化·geo优化避坑·ai搜索获客
八荒启·交互动画2 小时前
Web特效04——GPUvs CPU,为什么图形计算要交给GPU,什么是“并行计算”
前端
呃呃呃呃ex3 小时前
2026年古法编程的末法时代,如何评估自己完成迅速转行
前端·后端
南京兴帝文化传媒有限公司3 小时前
地图SEO与AI搜索优化结合实践:宁国摄影工作室本地获客案例分析
大数据·前端·人工智能·geo 优化·geo优化避坑
用户921080262863 小时前
前端 Vue 专栏 07:模板编译、虚拟 DOM、Diff 与 key
前端