Vue3 前端批量打印 PDF/图片踩坑记:跨域、合并打印、对话框闪退与按钮一直 loading
前言
业务场景很常见:列表里有多份「单据文件」(PDF 或图片),用户点「开始打印」后,希望合并成一份文档,只弹一次浏览器打印框。
实现过程中踩了几类坑:
- 隐藏
iframe+ 跨域 PDF 触发SecurityError - 开发环境代理在打包后失效
- 多文件希望一次打印 → 用
pdf-lib前端合并 - 打印框「一闪而过」
- 打印按钮一直转圈不可点
结论先说:
- 地址栏直接打开 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()
会被浏览器同源策略拦截。
处理思路
- 同源部署:若线上前端与文件 CDN 同域,可把绝对地址改成相对路径再打印
- 异域测试环境:不要盲目改相对路径,否则会打到测试机自己(常见 404)
- 开发机 :靠 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() 调用后会立刻返回,并不等于「打印预览已稳定展示」。
如果你过早:
remove()掉 iframerevokeObjectURL('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 生命周期。
共享打印机更常造成:
- 打印机列表找不到
- 真正出纸失败、超时
- 驱动报错
一般不会让系统打印预览「刚弹就关」。
八、排查清单
- Network:打印前
fetch是绝对地址还是被错误改成了测试机相对路径? - Console:是否有
SecurityError/ CORS 错误? - 地址栏打开同一文件是否能打印?(区分文件问题 vs 程序问题)
- 是否在
print()后几百毫秒内remove(iframe)/revokeObjectURL? - 是否把
busy=false绑死在afterprint上?
九、总结
- 跨域不能直接 iframe.print(),要同源、代理、或 fetch+blob
- Vite 代理不等于生产可用,打包环境要另配 Nginx 或 CORS
- 多文件一次打印 :前端
pdf-lib合并最直接 - 打印框闪退:资源清太早
- 按钮死转圈 :别等不可靠的
afterprint才关 loading - 共享打印机通常不是这类「闪一下 / 一直 loading」的首要嫌疑
一句话:
程序化打印的关键,是把「调起打印」和「销毁打印载体」拆开管理。