打印功能看着不起眼,真到项目里要落地的时候,坑比想象中多得多。后台管理系统要打发货单、财务系统要打对账单、医疗系统要打处方笺、教育系统要打成绩单,几乎每个B端项目都绕不开“把页面内容变成一张规整的纸”这件事。浏览器自带的window.print()谁都会调,但调完之后你会发现:打出来带着侧边栏、带着导航条、带着一堆按钮,分页位置乱七八糟,表格跨页表头消失,颜色全变黑白。这时候就需要一个能把“指定区域”干净利落地送上打印机的方案,vue-print-nb就是这类需求里出场率相当高的一个 Vue 指令库。
它做的事情很聚焦:给你一个v-print指令,绑上要打印区域的 id,点一下就把那块 DOM 拎出来塞进一个隐藏的 iframe 里,再触发打印。听起来简单,但真正用好它,需要搞清楚它的原理边界、样式隔离机制、分页控制手段,以及一堆只有踩过才知道的细节。这篇内容适合正在做 Vue 项目、需要落地打印功能的前端同学,不管你是刚接触指令式插件的新手,还是已经用过但被分页和样式问题折磨过的老手,下面这些拆解和实操记录应该都能对上你的场景。
1. 先搞清楚这个指令到底在干什么
1.1 从一次真实需求说起
我最早接触vue-print-nb是在一个订单管理模块里。需求很朴素:订单列表每行有个“打印”按钮,点了之后把当前订单的详情打出来,A4纸,带公司抬头、商品明细表格、金额汇总、底部签字栏。听起来十分钟的活,结果第一版用window.print()打出来,整个页面连同左侧菜单、顶部面包屑、右下角的客服悬浮球全上了纸,客户当场就笑了。
后来换成“打开新窗口再打印”,又遇到新问题:新窗口里样式全丢,因为样式是打包进主应用的,新窗口拿不到;手动把样式内联进去吧,组件库的样式几百KB,维护起来要命。再后来试了 iframe 方案,自己写了一套 DOM 搬运逻辑,能跑,但边界情况太多——图片没加载完就打印、表格跨页断行、打印完 iframe 不销毁导致内存泄漏。折腾到第三轮,才把vue-print-nb引进来,它把这些脏活封装掉了,我只需要关心“打哪块”和“打成什么样”。
所以这个库解决的核心问题可以概括成一句话:把页面中指定 DOM 区域的内容和样式,完整、隔离地送进打印流程,同时不污染当前页面、不依赖新窗口、不要求你手写 iframe 搬运代码。它不是一个“打印排版引擎”,别指望它帮你做复杂的分页计算,它更像一个“搬运工+触发器”,排版的上限取决于你给的 DOM 和 CSS。
1.2 指令背后的 iframe 搬运原理
理解原理很重要,因为后面所有的坑都和它有关。vue-print-nb的底层逻辑大致是这样几步:
- 你在模板里写
<div id="printArea">...</div>,然后在按钮上写v-print="'#printArea'"或者v-print="printObj"。 - 点击触发后,库会创建一个隐藏的
iframe,插入到当前文档里。 - 它把目标区域的
innerHTML克隆一份,写入 iframe 的document。 - 关键一步:它会把当前页面里所有的
<style>、<link rel="stylesheet">标签也复制进 iframe,这样打印内容才能继承到样式。 - 等 iframe 内容加载完成后,调用 iframe 内部
window.print()。 - 打印结束(或用户取消)后,销毁 iframe。
这个流程解释了很多现象。比如为什么打印出来的内容样式和你页面上看到的一致——因为样式表被整体复制过去了;比如为什么有时候打印会“慢半拍”——因为它在等 iframe 的onload;比如为什么某些动态渲染的图表打出来是空白——因为克隆的是 DOM 快照,Canvas 内容不会跟着innerHTML走。
注意:正因为是“克隆 DOM + 复制样式表”,所以打印区域里如果有依赖 JavaScript 运行时才渲染的内容(比如 ECharts 的 canvas、某些懒加载组件),克隆过去之后是拿不到渲染结果的。这类内容需要特殊处理,后面会讲。
1.3 它和另外几种打印方案的取舍
市面上做前端打印,主流就四条路,我把它们的适用边界列出来,方便你对号入座:
| 方案 | 核心思路 | 优点 | 短板 | 适合场景 |
|---|---|---|---|---|
window.print()裸调 | 直接触发浏览器打印当前页 | 零依赖 | 无法控制打印范围,整页上纸 | 几乎不适用业务系统 |
| 新窗口打印 | window.open后写入内容 | 隔离性好 | 样式需重新注入,弹窗易被拦截 | 简单独立票据 |
| 手写 iframe | 自己搬运 DOM 和样式 | 完全可控 | 代码量大,边界情况多 | 有强定制需求的团队 |
vue-print-nb | 指令封装 iframe 搬运 | 开箱即用,Vue 集成顺滑 | 复杂排版需配合 CSS 调优 | 中后台系统的主流选择 |
选vue-print-nb的核心理由是“性价比”:它把 iframe 方案里最繁琐的样式复制、生命周期管理封装好了,你付出的成本只是学一个指令的用法。代价是你要接受它的抽象边界——它不解决分页,不解决 canvas 快照,不解决打印预览的定制 UI,这些得你自己补。
2. 接入与基础配置的完整走一遍
2.1 安装与全局注册
安装没什么花样,包管理器直接装:
npm install vue-print-nb --saveVue 2 和 Vue 3 的注册方式略有差异,这是很多人第一次接入时卡住的地方。Vue 2 用Vue.use(),Vue 3 用app.use():
// Vue 2 import Vue from 'vue' import Print from 'vue-print-nb' Vue.use(Print) // Vue 3 import { createApp } from 'vue' import App from './App.vue' import print from 'vue-print-nb' const app = createApp(App) app.use(print) app.mount('#app')注册完之后,v-print指令在任意组件里都能用,不需要每个文件单独 import。这里有个小细节:如果你项目里用了按需引入或者 Vite 的自动导入插件,注意别把这个指令漏掉,指令不像组件那样有模板里的显式引用,漏注册的表现是“指令不生效但也不报错”,排查起来很费时间。
2.2 两种绑定写法:字符串与对象
最简写法是直接绑选择器字符串:
<button v-print="'#printArea'">打印</button> <div id="printArea"> <!-- 要打印的内容 --> </div>但实际项目里几乎都会用对象写法,因为需要配置项:
<button v-print="printConfig">打印</button>data() { return { printConfig: { id: 'printArea', popTitle: '订单详情', extraCss: 'https://cdn.example.com/print.css', extraHead: '<meta charset="utf-8">', beforeOpenCallback() { console.log('打印前') }, openCallback() { console.log('打印中') }, closeCallback() { console.log('打印后') } } } }几个配置项的实际作用值得说清楚:
id:目标区域的 id,不带#。这是最核心的参数,写错了就是白屏。popTitle:打印时浏览器打印对话框里显示的文档标题,也影响部分浏览器生成的页眉页脚文字。extraCss:额外注入的样式表地址,适合把打印专用样式单独抽成一个文件放 CDN。extraHead:往 iframe 的 head 里塞额外标签,比如字符集声明、viewport。- 三个回调:
beforeOpenCallback在创建 iframe 前触发,openCallback在 iframe 内容写入后触发,closeCallback在打印窗口关闭后触发。回调里可以做数据准备、埋点、状态复位。
提示:
id参数不要带#,这是新手最常见的错误之一。字符串写法v-print="'#printArea'"带#,对象写法id: 'printArea'不带,两种写法规则不同,混用就会失效。
2.3 打印区域的 DOM 组织原则
打印区域怎么组织,直接决定了最终纸张效果。我的经验是遵循“三隔离”原则:
第一,打印区域要独立于页面布局。不要把#printArea直接挂在一个带侧边栏的 flex 容器里,因为克隆的是这个容器的innerHTML,容器本身的布局样式如果依赖父级,克隆过去可能变形。最稳妥的做法是在页面最外层单独放一个打印专用容器,平时用display: none或者定位到屏幕外藏起来,打印时它才被搬运。
第二,打印区域内的样式尽量自包含。不要依赖“从祖先元素继承下来的字号、颜色、行高”。因为 iframe 里没有你的祖先链,继承会断掉。显式给打印容器设定font-size、color、line-height,内部元素再基于它做相对设置。
第三,打印区域里避免放交互组件。按钮、下拉框、日期选择器这些在纸上没有意义,反而会占位置。如果打印内容和页面展示内容差异大,宁可单独维护一份“打印模板 DOM”,用v-show控制显隐,也不要试图用 CSS 把交互组件藏起来——藏不干净是常态。
<!-- 页面展示区 --> <div class="page-content"> <el-table :data="orderList">...</el-table> </div> <!-- 打印专用区,屏幕外隐藏 --> <div id="printArea" class="print-only"> <h2>订单详情</h2> <table class="print-table">...</table> </div>.print-only { position: absolute; left: -9999px; top: 0; }用position: absolute; left: -9999px而不是display: none,是因为某些浏览器对display: none的元素在克隆时计算样式会有偏差,屏幕外定位更保险。
3. 样式隔离与打印排版的核心手段
3.1 @media print 才是排版的主战场
vue-print-nb负责搬运,真正决定纸张长相的是@media print媒体查询。所有“屏幕上这样、纸上那样”的差异,都应该在这里定义:
@media print { /* 隐藏不需要打印的元素 */ .no-print { display: none !important; } /* 打印区域铺满纸张 */ #printArea { width: 100%; font-size: 12pt; color: #000; } /* 表格边框在打印时更清晰 */ .print-table { border-collapse: collapse; width: 100%; } .print-table th, .print-table td { border: 1px solid #000; padding: 6px 8px; } }这里有个容易被忽略的点:@media print里的样式在 iframe 中同样生效,因为样式表被整体复制过去了。所以你写在主应用里的打印样式,不需要额外配置就能作用到打印内容上。但反过来,如果你用了extraCss注入外部样式,要注意加载时序——外部 CSS 是异步加载的,如果 iframe 的onload早于 CSS 加载完成,可能出现样式闪烁或缺失。稳妥做法是把关键打印样式写在主应用的<style>里,而不是依赖extraCss。
3.2 分页控制:page-break 的三个属性
打印最头疼的就是分页。一张 A4 纸放不下所有内容时,浏览器会自动切页,但切在哪里、切完之后表头还在不在,全靠 CSS 控制。三个核心属性:
page-break-before/break-before:在元素前强制分页。page-break-after/break-after:在元素后强制分页。page-break-inside/break-inside:控制元素内部是否允许被切断。
实际用法举例:一个订单有多页商品明细,希望每个商品大类从新页开始:
@media print { .category-block { break-before: page; } /* 表格行不允许从中间断开 */ .print-table tr { break-inside: avoid; } /* 表头在每页重复 */ .print-table thead { display: table-header-group; } }display: table-header-group这一条是表格跨页的救命稻草。默认情况下表格跨页时表头只在第一页出现,加上这个属性后,浏览器会在每一页的表格顶部重复渲染thead。同理tfoot配display: table-footer-group可以让页脚重复。
注意:
break-inside: avoid在部分旧版浏览器里对表格行的支持不完整,可能出现“设置了但还是断”的情况。这时候的兜底方案是把每一行包成一个div,对div设break-inside: avoid,牺牲一点语义换取稳定性。
3.3 打印尺寸与页边距的设定
纸张尺寸和页边距,严格来说不完全由 CSS 决定——用户在打印对话框里可以改。但你可以通过@page规则给出建议值:
@page { size: A4 portrait; margin: 15mm 12mm; }size支持A4、A5、letter等预设,也支持210mm 297mm这种自定义尺寸,portrait是纵向、landscape是横向。margin控制页边距,单位用mm比px更符合印刷习惯。
这里要泼一盆冷水:@page的margin在 Chrome 里会被打印对话框的“边距”选项覆盖,用户选了“无边距”,你的设定就失效。所以不要把关键内容贴着边缘放,留出至少 10mm 的安全区。另外size属性在部分浏览器里只对第一页生效,多页文档的尺寸一致性要靠内容宽度约束来保证。
3.4 背景色和图片的打印开关
浏览器默认不打印背景色和背景图,这是省墨策略。如果你的票据需要底色(比如表头灰底),必须显式开启:
@media print { .print-table th { background-color: #f0f0f0 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } }print-color-adjust: exact告诉浏览器“别自作主张给我改成白色”。但即便如此,用户在打印对话框里如果勾了“背景图形”关闭,还是打不出来。所以重要信息不要只靠背景色区分,加个边框或者文字标注做冗余。
图片方面,打印区域里的<img>只要src是完整 URL 且已加载完成,克隆过去能正常显示。但如果图片是 base64 内联的,注意体积——太大的 base64 会让 iframe 内容写入变慢。还有懒加载图片,如果克隆时src还是占位符,打出来就是空白,解决办法是在beforeOpenCallback里先把图片全部加载完再触发打印。
4. 动态内容与异步数据的处理套路
4.1 打印前等待数据就绪
最常见的翻车场景:用户点了打印,但表格数据还在请求中,打出来是空表。vue-print-nb的指令是同步触发的,它不会帮你等数据。所以要么在按钮上做禁用控制,要么在beforeOpenCallback里做拦截。
我习惯的做法是给打印按钮加一个loading状态,数据没回来之前按钮不可点:
<button v-print="printConfig" :disabled="!dataReady" >打印</button>computed: { dataReady() { return this.orderDetail && this.orderDetail.items.length > 0 } }如果数据是异步的且必须在点击后才拉取(比如打印时才去查最新数据),那就不能用指令的默认触发,改成手动调用:
async handlePrint() { await this.fetchPrintData() this.$nextTick(() => { // 等 DOM 更新后再触发打印 this.printConfig.id = 'printArea' // 通过 ref 或者重新赋值触发 }) }$nextTick这一步不能省,因为数据变了但 DOM 还没渲染完,这时候克隆拿到的是旧 DOM。
4.2 Canvas 与图表内容的打印方案
前面提过,innerHTML克隆拿不到 canvas 的像素内容。如果打印区域里有 ECharts 图表,打出来就是空白框。解决办法是在打印前把 canvas 转成图片,替换掉原 canvas:
beforeOpenCallback() { const canvases = document.querySelectorAll('#printArea canvas') canvases.forEach(canvas => { const img = document.createElement('img') img.src = canvas.toDataURL('image/png') img.style.width = canvas.style.width img.style.height = canvas.style.height canvas.parentNode.replaceChild(img, canvas) }) }注意toDataURL要求 canvas 没有被跨域图片污染,否则会抛安全错误。如果图表里用了跨域图片资源,需要服务端配 CORS 或者改用同源代理。
还有一种更省事的思路:图表区域干脆不放 canvas,打印模板里用静态图片或者纯 CSS 画的简化版图表替代。打印场景对图表精度要求通常不高,能表达趋势就行。
4.3 二维码、条码的生成时机
票据上经常要打二维码或条码。这类内容如果是用 JS 库动态生成的(比如基于 canvas 或 svg),同样面临“克隆时机”问题。稳妥做法是在数据就绪后、打印触发前,把二维码先生成好并渲染进 DOM,确保克隆时它已经存在。
如果用的是 SVG 形式的二维码,克隆没问题,因为 SVG 是 DOM 的一部分。如果用的是 canvas 形式,参考上一节的转图片方案。我一般优先选 SVG 方案,省去转换步骤,打印清晰度也更好。
5. 常见问题排查与避坑清单
5.1 打印出来是空白页
这是反馈最多的问题,原因通常有三类:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全空白 | id写错或带了# | 检查对象写法id是否与 DOM 一致 |
| 空白但打印对话框正常弹出 | 目标元素当时是display: none | 改用屏幕外定位隐藏 |
| 只有部分内容缺失 | 内容是异步渲染的,克隆时还没生成 | 用$nextTick或加载状态控制 |
第一类最好排查,第二类最隐蔽。有些人为了“打印时才显示”,把打印区域设成v-show="false",结果克隆到的是隐藏状态,打出来自然空白。记住:打印区域在克隆那一刻必须是“可渲染”的,隐藏要用屏幕外定位,不要用display: none。
5.2 样式丢失或错乱
样式问题的根源基本都在“样式表复制不完整”。几种典型情况:
- 用了 CSS Modules 或 scoped 样式:scoped 样式靠
>openCallback() { setTimeout(() => { this.printing = false }, 1000) }, closeCallback() { this.printing = false }5.4 多页打印的页码与页眉页脚
浏览器打印对话框默认会加页眉(标题、URL)和页脚(页码、日期)。这些内容不受 CSS 控制,用户可以在对话框里手动关掉,但你不能替用户关。如果业务要求“必须带页码”,纯前端方案做不到精确控制,只能:
- 在内容里手动插入页码占位,但无法知道总页数。
- 用
@page的 margin box(@top-center等),但浏览器支持度很差。 - 接受浏览器默认页脚,在
popTitle里设置好标题,让页眉看起来专业一点。
现实一点说,中后台系统的打印需求,页码往往不是硬性要求,能保证内容完整、表头重复、分页合理就已经及格了。
5.5 性能与内存的隐性成本
每次打印都创建一个 iframe、复制全部样式表、写入 DOM,这个开销在样式表很大的项目里不可忽视。如果页面有几百KB的 CSS,每次打印都要复制一遍,连续打印多次会有明显卡顿。优化手段:
- 把打印专用样式抽成独立小文件,用
extraCss引入,减少主样式表的复制量(但要注意前面说的加载时序问题)。 - 避免在打印区域放超大图片,base64 图片尤其要控制体积。
- 打印完成后确认 iframe 被销毁,可以在
closeCallback里检查document.querySelectorAll('iframe')的数量。
6. 一套可直接复用的打印模板结构
6.1 目录与文件组织
把打印相关的东西集中管理,别散落在各个业务组件里。我的习惯是建一个
print目录:src/ print/ print.css // 打印专用样式 usePrint.js // 打印逻辑封装 components/ OrderPrint.vue // 订单打印模板组件print.css里只放@media print和@page规则,通过全局引入。usePrint.js封装常用的打印前准备逻辑(图片加载、canvas 转换、数据校验)。6.2 打印模板组件的标准骨架
<template> <div :id="printId" class="print-container"> <header class="print-header"> <h1>{{ title }}</h1> <div class="print-meta"> <span>单号:{{ orderNo }}</span> <span>日期:{{ printDate }}</span> </div> </header> <table class="print-table"> <thead> <tr> <th>序号</th> <th>名称</th> <th>数量</th> <th>金额</th> </tr> </thead> <tbody> <tr v-for="(item, index) in items" :key="index"> <td>{{ index + 1 }}</td> <td>{{ item.name }}</td> <td>{{ item.qty }}</td> <td>{{ item.amount }}</td> </tr> </tbody> </table> <footer class="print-footer"> <div>合计:{{ totalAmount }}</div> <div class="sign-area">签字:__________</div> </footer> </div> </template>配套的打印样式要点:容器宽度用
100%而不是固定px,字号用pt而不是px(pt是印刷单位,跨设备更稳定),表格用border-collapse: collapse保证边框不双线。6.3 打印逻辑的封装示例
把重复的“准备-触发-清理”流程封装成一个组合式函数,业务组件里只调一行:
// usePrint.js import { ref } from 'vue' export function usePrint(printId) { const printing = ref(false) const prepareImages = () => { const imgs = document.querySelectorAll(`#${printId} img`) return Promise.all( Array.from(imgs).map(img => { if (img.complete) return Promise.resolve() return new Promise(resolve => { img.onload = resolve img.onerror = resolve }) }) ) } const convertCanvas = () => { const canvases = document.querySelectorAll(`#${printId} canvas`) canvases.forEach(canvas => { const img = document.createElement('img') img.src = canvas.toDataURL('image/png') img.style.width = canvas.style.width img.style.height = canvas.style.height canvas.parentNode.replaceChild(img, canvas) }) } const printConfig = { id: printId, popTitle: '打印预览', beforeOpenCallback: async () => { printing.value = true await prepareImages() convertCanvas() }, closeCallback: () => { printing.value = false } } return { printing, printConfig } }业务组件里:
import { usePrint } from '@/print/usePrint' export default { setup() { const { printing, printConfig } = usePrint('orderPrintArea') return { printing, printConfig } } }这样每个打印场景只需要维护自己的模板 DOM,逻辑复用,改一处全局生效。
7. 几个只有踩过才知道的细节
第一个细节:打印区域的 id 不要用动态值。有些人为了支持“一个页面多个打印区域”,把 id 绑成
:id="'print-' + orderId",然后v-print的配置里也动态拼。理论上可行,但指令在初始化时可能缓存了选择器,动态变化后不生效。稳妥做法是每个打印场景用固定 id,多个区域就写多个指令实例。第二个细节:打印按钮本身不要放在打印区域里。这个听起来是废话,但真有人把按钮和内容放同一个容器,然后靠
@media print把按钮藏起来。藏不干净的时候,纸上就多出一个按钮。物理隔离最省心。第三个细节:测试打印一定要用真实打印机或“另存为 PDF”。浏览器开发者工具里的“打印预览模拟”和真实打印结果有差异,尤其是分页位置和背景色。我习惯在开发阶段就用“另存为 PDF”验证,PDF 的分页表现和实际打印最接近。
第四个细节:注意 iframe 的沙箱限制。如果项目配了严格的 CSP(内容安全策略),动态创建的 iframe 可能被拦截,表现为点击打印没反应。排查时看控制台有没有 CSP 报错,有的话需要在策略里放行
frame-src或者调整 iframe 的创建方式。第五个细节:移动端基本别指望这套方案。
vue-print-nb依赖浏览器的打印能力,移动端浏览器对window.print()的支持参差不齐,iOS Safari 的表现尤其不可控。移动端打印需求通常走“生成 PDF 后分享”的路线,用html2canvas+jsPDF组合,那是另一套技术栈了。这套方案我在几个中后台项目里反复用过,订单、对账单、报表、工单这几类场景都能覆盖。真正花时间的从来不是接入指令本身,而是把打印模板的 DOM 结构理顺、把
@media print里的分页规则调对、把异步内容的就绪时机卡准。指令只是那根导火索,炸药得自己配。