Vue3 前端批量打印 PDF/图片踩坑记:跨域、合并打印、对话框闪退与按钮一直 loading

Vue3 前端批量打印 PDF/图片踩坑记:跨域、合并打印、对话框闪退与按钮一直 loading

前言

业务场景很常见:列表里有多份「单据文件」(PDF 或图片),用户点「开始打印」后,希望合并成一份文档,只弹一次浏览器打印框

实现过程中踩了几类坑:

  1. 隐藏 iframe + 跨域 PDF 触发 SecurityError
  2. 开发环境代理在打包后失效
  3. 多文件希望一次打印 → 用 pdf-lib 前端合并
  4. 打印框「一闪而过」
  5. 打印按钮一直转圈不可点

结论先说:

  • 地址栏直接打开 PDF 能打印 ≠ 页面里程序化打印一定能打印
  • 很多问题出在资源生命周期 / 事件不可靠,和共享打印机关系不大

一、目标架构

整体流程:

text 复制代码
收集可打印 URL
  → fetch 拉文件
  → 识别类型(PDF / PNG / JPG / 其他图片)
  → 合并为单个 PDF(pdf-lib)
  → 生成 blob URL
  → 离屏 iframe 加载后 window.print()
  → print() 后结束 loading
  → afterprint / 超时后再销毁 iframe 与 blob

支持扩展名大致如下:

ts 复制代码
/\.(pdf|png|jpe?g|gif|webp|bmp)$/i

二、坑 1:跨域 iframe 无法调用 print

现象

text 复制代码
Uncaught SecurityError: Failed to read a named property 'print' from 'Window':
Blocked a frame with origin "http://192.168.x.x:port"
from accessing a cross-origin frame.

原因

页面在 A 域名,PDF 在 B 域名。

把远端 PDF 直接塞进 iframe.src,再访问:

ts 复制代码
iframe.contentWindow.print()

会被浏览器同源策略拦截。

处理思路

  1. 同源部署:若线上前端与文件 CDN 同域,可把绝对地址改成相对路径再打印
  2. 异域测试环境:不要盲目改相对路径,否则会打到测试机自己(常见 404)
  3. 开发机 :靠 Vite server.proxy注意:代理不会打进生产包

可按当前页面是否与文件同域,做一层地址归一化:

ts 复制代码
function normalizeFileUrl(raw: string) {
  const url = raw.trim()
  const parsed = new URL(url)

  // 仅当「页面域名 === 文件域名」时,才改写成相对路径
  if (
    parsed.hostname === 'file.example.com' &&
    parsed.pathname.startsWith('/static-files/') &&
    window.location.hostname === parsed.hostname
  ) {
    return `${parsed.pathname}${parsed.search}${parsed.hash}`
  }

  // 测试机异域:保留绝对地址(需文件服务允许 CORS)
  return url
}

实测很多对象存储会对 GET 返回:

http 复制代码
Access-Control-Allow-Origin: *

这时前端可直接 fetch 绝对地址再转 blob 打印。


三、坑 2:npm run build 后代理失效

本地 vite 里配了:

json 复制代码
["/static-files", "https://file.example.com/static-files"]

本地正常,打包装到测试机就挂------很正常:

  • VITE_PROXY 只作用于开发服务器
  • build / build:dev 都不会把代理打进静态资源

测试环境要么:

  • Nginx 补同源反代,或
  • 代码在异域时保留绝对 URL + 依赖 CORS

四、一次打印多个文件:前端合并 PDF

不想弹 N 次打印框,就用 pdf-lib 合成一个 PDF:

ts 复制代码
import { PDFDocument } from 'pdf-lib'

type FileKind = 'pdf' | 'png' | 'jpg' | 'image'

type FileAsset = {
  buffer: ArrayBuffer
  kind: FileKind
}

export async function mergeDocumentsToPdf(assets: FileAsset[]) {
  const doc = await PDFDocument.create()

  for (const asset of assets) {
    if (asset.kind === 'pdf') {
      const src = await PDFDocument.load(asset.buffer)
      const pages = await doc.copyPages(src, src.getPageIndices())
      pages.forEach((p) => doc.addPage(p))
      continue
    }

    // png/jpg 可直接 embed;gif/webp/bmp 先 canvas 转 png
    let pngOrJpg = asset.buffer
    let isJpg = asset.kind === 'jpg'

    if (asset.kind === 'image') {
      pngOrJpg = await convertAnyImageToPng(asset.buffer)
      isJpg = false
    }

    const image = isJpg
      ? await doc.embedJpg(pngOrJpg)
      : await doc.embedPng(pngOrJpg)

    const page = doc.addPage([image.width, image.height])
    page.drawImage(image, {
      x: 0,
      y: 0,
      width: image.width,
      height: image.height,
    })
  }

  const bytes = await doc.save()
  return bytes.buffer.slice(
    bytes.byteOffset,
    bytes.byteOffset + bytes.byteLength,
  ) as ArrayBuffer
}

业务入口可以写成这样:

ts 复制代码
async function handleStartPrint() {
  const list = collectPrintableUrls()
  if (!list.length) return

  busy.value = true
  let objectUrl: string | null = null

  const disposeObjectUrl = () => {
    if (!objectUrl) return
    URL.revokeObjectURL(objectUrl)
    objectUrl = null
  }

  try {
    const assets = await Promise.all(
      list.map(async (href) => {
        const res = await fetch(href)
        if (!res.ok) throw new Error('fetch failed')
        const buffer = await res.arrayBuffer()
        return {
          buffer,
          kind: detectFileKind(href, res.headers.get('content-type')),
        }
      }),
    )

    const pdfBytes = await mergeDocumentsToPdf(assets)
    objectUrl = URL.createObjectURL(
      new Blob([pdfBytes], { type: 'application/pdf' }),
    )

    await triggerBrowserPrint(objectUrl, disposeObjectUrl)
  } catch (e) {
    console.error(e)
    disposeObjectUrl()
  } finally {
    busy.value = false
  }
}

要点:

  • busy 只覆盖「拉取 + 合并 + 触发 print」
  • blob/iframe 销毁要延后,不能和按钮状态绑死在同一步

五、坑 3:打印框「一闪而过」

现象

个别电脑上,打印预览刚出来就关。

浏览器地址栏直接打开同一个 PDF,打印完全正常

错误写法(反例)

ts 复制代码
iframe.onload = () => {
  iframe.contentWindow?.print()
  // 危险:print() 是异步的,对话框还没稳就清理了
  setTimeout(() => {
    iframe.remove()
    URL.revokeObjectURL(objectUrl)
  }, 800)
}

原理

print() 调用后会立刻返回,并不等于「打印预览已稳定展示」。

如果你过早:

  1. remove() 掉 iframe
  2. revokeObjectURL('blob:...')

预览就丢内容来源,对话框表现为「闪一下关掉」。

机器快慢不同 → 个别电脑才稳定复现,特别像「环境问题」,其实是时序竞态。

正确思路

  • 不要短延时硬删
  • 监听 afterprint / matchMedia('print'),对话框结束后再清理
  • 再加长超时兜底(防止事件永远不触发导致泄漏)

另外:尽量别用 display: none 的 iframe 打 PDF,部分浏览器预览异常;建议离屏:

ts 复制代码
iframe.style.cssText =
  'position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none;'

六、坑 4:按钮一直转圈(loading 不结束)

现象

点「开始打印」后按钮 loading 不停,再次点击不可用。

原因

你把「结束 loading」绑到了 afterprint

ts 复制代码
await waitUntilAfterPrint() // Chrome + iframe + blob PDF 经常不触发
busy.value = false

Chrome 对「iframe 内嵌 PDF + blob」时,afterprint 经常不触发

于是 Promise 一直挂着,按钮永远转圈。

正确拆分两个生命周期

阶段 做什么 何时结束
UI loading fetch / 合并 / 调起 print print() 调用成功后立刻结束
资源释放 保留 iframe + blob afterprint / media 退出 / 超时再删

调起打印并延后释放资源,示例:

ts 复制代码
function triggerBrowserPrint(src: string, onReleased?: () => void) {
  return new Promise<void>((resolve, reject) => {
    const frame = document.createElement('iframe')
    frame.style.cssText =
      'position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none;'
    frame.src = src
    document.body.appendChild(frame)

    let released = false
    let started = false
    let settleTimer: number | undefined
    let safetyTimer: number | undefined
    let mql: MediaQueryList | null = null

    const release = () => {
      if (released) return
      released = true
      clearTimeout(settleTimer)
      clearTimeout(safetyTimer)
      const win = frame.contentWindow
      win?.removeEventListener('afterprint', release)
      try {
        mql?.removeEventListener('change', onMqlChange)
      } catch {}
      frame.remove()
      onReleased?.()
    }

    const onMqlChange = (e: MediaQueryListEvent) => {
      if (!e.matches) release()
    }

    frame.onload = () => {
      const win = frame.contentWindow
      if (!win) {
        release()
        reject(new Error('window missing'))
        return
      }

      win.addEventListener('afterprint', release)
      try {
        mql = win.matchMedia('print')
        mql.addEventListener('change', onMqlChange)
      } catch {
        mql = null
      }

      // 兜底:防止资源永不释放
      safetyTimer = window.setTimeout(release, 10 * 60 * 1000)

      // 给 PDF 插件一点点渲染时间
      settleTimer = window.setTimeout(() => {
        try {
          win.focus()
          win.print()
          started = true
          resolve() // 关键!这里就结束按钮 loading
        } catch (err) {
          release()
          if (!started) reject(err)
        }
      }, 300)
    }

    frame.onerror = () => {
      release()
      reject(new Error('iframe load failed'))
    }
  })
}

七、和「共享打印机」有没有关系?

多数情况下:无关

  • 地址栏打开 PDF 能正常预览并打印
  • 只有页面按钮程序化打印异常

→ 优先查前端时序 / 跨域 / blob 生命周期。

共享打印机更常造成:

  • 打印机列表找不到
  • 真正出纸失败、超时
  • 驱动报错

一般不会让系统打印预览「刚弹就关」。


八、排查清单

  1. Network:打印前 fetch 是绝对地址还是被错误改成了测试机相对路径?
  2. Console:是否有 SecurityError / CORS 错误?
  3. 地址栏打开同一文件是否能打印?(区分文件问题 vs 程序问题)
  4. 是否在 print() 后几百毫秒内 remove(iframe) / revokeObjectURL
  5. 是否把 busy=false 绑死在 afterprint 上?

九、总结

  1. 跨域不能直接 iframe.print(),要同源、代理、或 fetch+blob
  2. Vite 代理不等于生产可用,打包环境要另配 Nginx 或 CORS
  3. 多文件一次打印 :前端 pdf-lib 合并最直接
  4. 打印框闪退:资源清太早
  5. 按钮死转圈 :别等不可靠的 afterprint 才关 loading
  6. 共享打印机通常不是这类「闪一下 / 一直 loading」的首要嫌疑

一句话:

程序化打印的关键,是把「调起打印」和「销毁打印载体」拆开管理。


参考


相关推荐
李伟_Li慢慢1 小时前
06-mini版WebGLState
前端·three.js
李伟_Li慢慢1 小时前
04-three.js的渲染流程概览
前端·three.js
李伟_Li慢慢1 小时前
07-WebGLProperties
前端·three.js
爱勇宝1 小时前
AI 都能写代码了,还要学计算机吗?
前端·后端·程序员
石像鬼₧魂石1 小时前
【Y2Ksoft】四季贵州水世界乐园管理系统 —— 玻璃拟态 · 单文件HTML · 开箱即用
前端·html
涛涛ing1 小时前
尤雨溪宣布:Vue 3.6 发布、Vue 4.0 启动、Vite 8 已落地
前端
Am-Chestnuts1 小时前
Mermaid流程图怎么导出Word/PDF?DS随心转批量保存AI多轮修改与源码
pdf·aigc·word·流程图
李伟_Li慢慢1 小时前
05-mini版材质
前端·three.js
学以智用1 小时前
HTTP 请求头 Headers 完整详解
前端·后端