在Web开发中,将页面内容导出为PDF是一个非常常见的需求。过去,大家通常使用 html2canvas + jsPDF 的组合,但在面对复杂的CSS(如渐变、阴影)、Grid布局或大量DOM时,html2canvas 经常会出现样式丢失或渲染不准确的问题。
今天,我将向大家分享一个更现代、更高性能的替代方案------SnapDOM 。它是一个专为高精度DOM捕捉而设计的引擎,配合 jsPDF 可以实现完美的PDF导出体验。
方案对比:为什么不选 html2canvas?
在决定使用新技术之前,我们先看看主流方案的对比:
| 特性 | html2canvas | SnapDOM | 备注 |
|---|---|---|---|
| 渲染原理 | 解析 DOM 并重建 Canvas | 深度克隆 DOM 并嵌入 SVG/Canvas | SnapDOM 更接近原生浏览器渲染 |
| CSS 支持 | 有限 (Flex/Grid/Filter 支持一般) | 优秀 (支持所有现代 CSS 特性) | SnapDOM 基本是"所见即所得" |
| 伪元素 | 支持,但常见 bug | 完美支持 (::before, ::after) | |
| 外部资源 | 需配置 CORS/Proxy | 内置更智能的资源内联机制 | 对图片/字体处理更友好 |
| 性能 | 随 DOM 数量线性下降 | 极快 (基于原生 API 优化) | 大数据量下优势明显 |
第一步:环境准备 & 安装
首先,在你的 Vue 3 项目中安装必要的依赖:
bash
npm install @zumer/snapdom jspdf
第二步:编写"打印模版"组件
建议将需要导出的内容封装为一个独立的组件,这样更有利于样式的隔离和控制,确保导出的PDF布局整洁。由于 SnapDOM 是基于 DOM 截图,所有的 CSS 都会被忠实记录,所以推荐使用 scoped 样式。
src/components/InvoiceTemplate.vue
vue
<script setup>
defineProps({
data: {
type: Object,
default: () => ({
invoiceNumber: 'INV-2023-001',
date: '2023-12-26',
customerName: 'Tech Innovators Inc.',
items: [
{ desc: 'Web Development Services', qty: 1, price: 1500.00 },
{ desc: 'UI/UX Design Phase', qty: 1, price: 800.00 },
{ desc: 'Server Setup & Deployment', qty: 2, price: 200.00 },
]
})
}
})
</script>
<template>
<div class="invoice-container">
<div class="header">
<div class="brand">
<h1>DevStudio</h1>
<p>专业数字化解决方案</p>
</div>
<div class="details">
<h2>账单明细</h2>
<p><strong>单号:</strong> {{ data.invoiceNumber }}</p>
<p><strong>日期:</strong> {{ data.date }}</p>
</div>
</div>
<div class="bill-to">
<h3>客户信息:</h3>
<p>{{ data.customerName }}</p>
<p>北京市海淀区中关村科技园</p>
<p>创新大厦 A 座 888 室</p>
</div>
<table class="items-table">
<thead>
<tr>
<th>服务项目</th>
<th class="text-right">数量</th>
<th class="text-right">单价</th>
<th class="text-right">金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data.items" :key="index">
<td>{{ item.desc }}</td>
<td class="text-right">{{ item.qty }}</td>
<td class="text-right">¥{{ item.price.toFixed(2) }}</td>
<td class="text-right">¥{{ (item.qty * item.price).toFixed(2) }}</td>
</tr>
</tbody>
</table>
<div class="footer">
<div class="total-section">
<p>小计: ¥{{ data.items.reduce((acc, item) => acc + item.qty * item.price, 0).toFixed(2) }}</p>
<p>税费 (10%): ¥{{ (data.items.reduce((acc, item) => acc + item.qty * item.price, 0) * 0.1).toFixed(2) }}</p>
<h3 class="grand-total">总计: ¥{{ (data.items.reduce((acc, item) => acc + item.qty * item.price, 0) * 1.1).toFixed(2) }}</h3>
</div>
<div class="terms">
<p>感谢您的信任与支持!</p>
<p>请在收到账单后 30 个工作日内完成付款。</p>
</div>
</div>
</div>
</template>
<style scoped>
.invoice-container {
background: white;
color: #333;
padding: 40px;
border-radius: 8px;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
width: 700px;
min-height: 900px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
margin: 0 auto;
text-align: left;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #eee;
}
.brand h1 {
color: #2c3e50;
margin: 0;
font-size: 28px;
}
.brand p {
color: #7f8c8d;
margin: 5px 0 0;
}
.details {
text-align: right;
font-weight: 300;
}
.details h2 {
color: #7f8c8d;
margin: 0 0 10px;
font-weight: 300;
font-size: 24px;
}
.bill-to {
margin-bottom: 40px;
}
.bill-to h3 {
color: #7f8c8d;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
}
.items-table th {
background: #f8f9fa;
color: #2c3e50;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #ddd;
}
.items-table td {
padding: 15px 12px;
border-bottom: 1px solid #f5f5f5;
}
.text-right {
text-align: right;
}
.footer {
margin-top: auto;
padding-top: 20px;
border-top: 2px solid #eee;
}
.total-section {
text-align: right;
margin-bottom: 30px;
}
.grand-total {
color: #e74c3c;
font-size: 24px;
border-top: 1px solid #eee;
padding-top: 10px;
margin-top: 10px;
}
.terms {
text-align: center;
color: #95a5a6;
font-size: 14px;
}
</style>
第三步:核心逻辑与分页实现
对于长内容(超过一页A4纸),如果不处理,PDF只会显示被截断的第一页。我们需要通过算法将生成的长图切割到多个PDF页面中。
src/App.vue
vue
<script setup>
import { ref, computed } from 'vue';
import InvoiceTemplate from './components/InvoiceTemplate.vue';
import { snapdom } from '@zumer/snapdom';
import { jsPDF } from 'jspdf';
const printContent = ref(null);
const isGenerating = ref(false);
const isLongContent = ref(false);
const invoiceData = computed(() => {
const baseData = {
invoiceNumber: 'INV-2023-001',
date: '2023-12-26',
customerName: '某科技创新有限公司',
items: [
{ desc: '网站定制开发服务 (首付)', qty: 1, price: 1500.00 },
{ desc: 'UI/UX 交互设计', qty: 1, price: 800.00 },
{ desc: '服务器部署与维护', qty: 2, price: 200.00 },
]
};
if (isLongContent.value) {
// Generate 50 items to force multi-page
const extraItems = Array.from({ length: 50 }).map((_, i) => ({
desc: `增值服务项目 #${i + 1} - 长期技术支持与系统维护升级`,
qty: 1,
price: 100.00
}));
return {
...baseData,
items: [...baseData.items, ...extraItems]
};
}
return baseData;
});
const downloadPDF = async () => {
if (!printContent.value) return;
isGenerating.value = true;
try {
const element = printContent.value.$el;
// 1. 使用 SnapDOM 生成长图
// scale: 2 保证视网膜屏级别的清晰度 (相当于 @2x 图)
// ignore: 可以通过选择器忽略不想打印的元素
const img = await snapdom.toPng(element, {
scale: 2,
style: {
transform: 'scale(1)', // 确保导出时不受父级缩放影响
transformOrigin: 'top left'
}
});
// 2. 初始化 jsPDF (A4纸, pt单位)
// p = portrait (纵向), pt = points (点), a4 = 纸张格式
const doc = new jsPDF('p', 'pt', 'a4');
// 3. 计算尺寸换算
const pdfPageWidth = doc.internal.pageSize.getWidth();
const pdfPageHeight = doc.internal.pageSize.getHeight();
// 图片在PDF中的实际显示宽度和高度(按比例缩放)
const imgWidth = img.width;
const imgHeight = img.height;
const printWidth = pdfPageWidth;
const printHeight = (imgHeight * printWidth) / imgWidth;
// 4. 分页核心算法
let heightLeft = printHeight; // 剩余未打印的高度
let position = 0; // 当前页面的图片Y轴偏移量
// 第一页
doc.addImage(img.src, 'PNG', 0, position, printWidth, printHeight);
heightLeft -= pdfPageHeight;
// 如果还有剩余高度,循环通过 addPage() 添加新页面
while (heightLeft > 0) {
position -= pdfPageHeight; // 向上移动图片位置,露出下一截内容
doc.addPage();
doc.addImage(img.src, 'PNG', 0, position, printWidth, printHeight);
heightLeft -= pdfPageHeight;
}
// 5. 保存
doc.save(isLongContent.value ? '账单报表-分页版.pdf' : '账单报表.pdf');
} catch (error) {
console.error('导出失败详情:', error);
alert('导出失败,请检查控制台');
} finally {
isGenerating.value = false;
}
};
</script>
<template>
<div class="app-container">
<div class="actions">
<h1>Vue 3 + SnapDOM + jsPDF 演示</h1>
<p class="subtitle">极速生成高质量 PDF (支持自动分页)</p>
<div class="button-group">
<button @click="isLongContent = !isLongContent" class="secondary">
{{ isLongContent ? '切换回单页内容' : '切换为长内容 (测试分页)' }}
</button>
<button @click="downloadPDF" :disabled="isGenerating">
{{ isGenerating ? '正在生成...' : '导出 PDF' }}
</button>
</div>
</div>
<div class="preview-area">
<!-- Pass dynamic data -->
<InvoiceTemplate ref="printContent" :data="invoiceData" />
</div>
</div>
</template>
<style scoped>
.app-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding-bottom: 50px;
}
.actions {
text-align: center;
margin-bottom: 1rem;
}
.subtitle {
color: rgba(255,255,255,0.7);
margin-bottom: 2rem;
}
.button-group {
display: flex;
gap: 15px;
justify-content: center;
}
button.secondary {
background-color: #64748b;
color: #fff;
}
button.secondary:hover {
background-color: #475569;
}
.preview-area {
background: transparent; /* The component itself has a white background */
padding: 20px;
border-radius: 12px;
/* Add a subtle shadow to emphasize the paper look on the screen */
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.2));
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>
深度解析:分页原理是怎样的?
jsPDF 的 addImage 方法允许我们指定图片绘制的 x, y 坐标。
- Canvas 思维:你可以把 PDF 页面想象成一个窗口,而生成长图是一张很长的海报。
- 第一页 :我们将海报的顶部对齐窗口顶部(
y=0)。 - 第二页 :我们新建一个窗口,然后将海报向上推 一个窗口的高度(
y = -PageHeight)。- 此时,海报的
0 ~ PageHeight部分在窗口上方(不可见)。 - 海报的
PageHeight ~ 2*PageHeight部分刚好在窗口内(可见)。
- 此时,海报的
- 循环:以此类推,直到海报底部也滚出窗口。
优缺点分析:
- 优点:简单通用,无需修改 DOM 结构,样式 100% 还原。
- 缺点:它是纯视觉切割,可能会把一行文字从中间切成两半(上半截在上一页,下半截在下一页)。
- 改进思路 :对于非常严格的报表,建议在生成图片前,预先计算每个 item 的高度,在 DOM 中插入强制分页符(
page-break-after),但这通常需要配合浏览器原生打印(window.print),而在jsPDF纯前端生成方案中,上述"图片切分法"是性价比最高的方案。
常见问题 (FAQ)
Q1: 导出的 PDF 图片模糊怎么办?
A : 这是分辨率不足导致的。在调用
snapdom.toPng(el, { scale: 2 })时,增加scale的值(如 2 或 3)。这相当于提高了 Canvas 的 DPI。注意:scale过高会导致生成图片体积过大,可能会导致浏览器崩溃。
Q2: 图片或字体没有显示出来?
A : 这通常是跨域 (CORS) 问题。SnapDOM 会尝试内联图片,但如果图片服务器没有返回正确的
Access-Control-Allow-Origin头,Canvas 会被污染或无法读取。确保你的 CDN 资源允许跨域,或者将图片转为 Base64 字符串再渲染。
Q3: 如何不打印页面上的"导出"按钮?
A: 有两种方法:
- 将打印内容(如
InvoiceTemplate)放在一个独立的、不可见的容器中(如position: absolute; left: -9999px)。- 使用
snapdom的选择器参数或临时隐藏 DOM 元素。在本 Demo 中,我们是直接传入了InvoiceTemplate的组件根元素,所以只要按钮不在这个组件里,自然就不会被打印。
获取完整源码:
https://gitee.com/zhp26zhp/snap-dom---js-pdf
总结与源码
使用 SnapDOM + jsPDF 你可以轻松实现:
- 所见即所得:完全还原Vue组件样式,支持圆角、阴影、渐变。
- 高清输出:自定义缩放倍数。
- 自动分页:通过简单的位移算法处理长表单。
希望这篇实战分享对你有帮助!如果你在项目中也需要生成精美的 PDF 报告,不妨试试这个组合。