使用 SnapDOM + jsPDF 生成高质量 PDF (含多页分页, 附源码)

在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>

深度解析:分页原理是怎样的?

jsPDFaddImage 方法允许我们指定图片绘制的 x, y 坐标。

  1. Canvas 思维:你可以把 PDF 页面想象成一个窗口,而生成长图是一张很长的海报。
  2. 第一页 :我们将海报的顶部对齐窗口顶部(y=0)。
  3. 第二页 :我们新建一个窗口,然后将海报向上推 一个窗口的高度(y = -PageHeight)。
    • 此时,海报的 0 ~ PageHeight 部分在窗口上方(不可见)。
    • 海报的 PageHeight ~ 2*PageHeight 部分刚好在窗口内(可见)。
  4. 循环:以此类推,直到海报底部也滚出窗口。

优缺点分析

  • 优点:简单通用,无需修改 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: 有两种方法:

  1. 将打印内容(如 InvoiceTemplate)放在一个独立的、不可见的容器中(如 position: absolute; left: -9999px)。
  2. 使用 snapdom 的选择器参数或临时隐藏 DOM 元素。在本 Demo 中,我们是直接传入了 InvoiceTemplate 的组件根元素,所以只要按钮不在这个组件里,自然就不会被打印。

获取完整源码:
https://gitee.com/zhp26zhp/snap-dom---js-pdf

总结与源码

使用 SnapDOM + jsPDF 你可以轻松实现:

  1. 所见即所得:完全还原Vue组件样式,支持圆角、阴影、渐变。
  2. 高清输出:自定义缩放倍数。
  3. 自动分页:通过简单的位移算法处理长表单。

希望这篇实战分享对你有帮助!如果你在项目中也需要生成精美的 PDF 报告,不妨试试这个组合。

相关推荐
AC赳赳老秦2 小时前
工业互联网赋能智造:DeepSeek解析产线传感器数据驱动质量管控新范式
前端·数据库·人工智能·zookeeper·json·flume·deepseek
Student_Zhang2 小时前
一个管理项目中所有弹窗的弹窗管理器(PopupManager)
前端·ios·github
网络风云2 小时前
HTML 模块化方案
前端·html
bosins2 小时前
基于Python实现PDF文件个人隐私信息检查
开发语言·python·pdf
灯把黑夜烧了一个洞2 小时前
2026年跨年倒计时网页版
javascript·css·html·2026跨年代码·新年代码
bosins2 小时前
基于Python开发PDF文件元数据查看器
开发语言·python·pdf
小满zs2 小时前
Next.js第十九章(服务器函数)
前端·next.js
仰望.2 小时前
vxe-table 如何实现分页勾选复选框功能,分页后还能支持多选的选中状态
前端·vue.js·vxe-table
zhenryx2 小时前
React Native 横向滚动指示器组件库(淘宝|京东...&旧版|新版)
javascript·react native·react.js