背景
❝本文将深入讲解如何使用 snapdom 和 jsPDF 实现高质量的 HTML 转 PDF 功能,并通过一个完整的消息列表导出案例,带你掌握这套方案的核心技术。
❞
为什么 HTML 转 PDF 如此重要?
在现代 Web 应用中,「HTML 转 PDF」 是一个非常常见的需求场景:
- 「报表系统」:将可视化图表和数据导出为 PDF 报告
然而,实现一个「高质量」的 HTML 转 PDF 功能并不简单。我们面临以下挑战:
| |
|---|
| 「样式还原」 | |
| 「分页处理」 | |
| 「清晰度」 | |
| 「性能」 | |
| 「兼容性」 | |
传统的 html2canvas + jsPDF 方案虽然能用,但在「样式还原度」和「截图质量」上存在明显不足。
今天笔者介绍一套新解决方案:「snapdom + jsPDF」。
snapdom 和 jsPDF 基础理论知识
snapdom 是什么?
SnapDOM 是一个现代化的 DOM 截图库,它的核心特点是:
DOM Element → Canvas/PNG/SVG
核心优势
- 「高保真截图」:完美还原 CSS 样式,包括 flexbox、grid、渐变、阴影等
- 「多种输出格式」:支持 Canvas、PNG、SVG 等多种格式
- 「高清缩放」:通过
scale 参数实现 2x/3x 高清截图
基础用法
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport { snapdom } from '@zumer/snapdom';
const element = document.querySelector('.my-element');
const capture = await snapdom(element, { scale: 2, quality: 0.95 });
const canvas = await capture.toCanvas(); const imgEl = await capture.toPng(); const svgStr = await capture.toSvg();
关键参数说明
更多详细内容请看https://snapdom.dev/官方文档
jsPDF 是什么?
jsPDF 是最流行的 JavaScript PDF 生成库,支持在浏览器端直接创建 PDF 文件。
核心特点
- 「插件生态」:支持 AutoTable 等扩展插件
基础用法
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport { jsPDF } from 'jspdf';
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true });
pdf.addImage( imageDataUrl, 'PNG', 10, 10, 190, 100 );
pdf.addPage();
pdf.save('output.pdf');
A4 尺寸常量
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineconst A4_WIDTH_MM = 210;const A4_HEIGHT_MM = 297;
const MARGIN_MM = 10;
const CONTENT_WIDTH_MM = 190; const CONTENT_HEIGHT_MM = 277;
snapdom + jsPDF 组合的优势

案例讲述
笔者写一个IM产品中 MessageList 消息导出DEMO。接下来,我们通过一个完整的「客服消息列表导出」案例,讲解如何使用 snapdom + jsPDF 实现 HTML 转 PDF。
项目结构
src/├── components/│ ├── MessageList.tsx │ └── MessageList.css ├── services/│ └── messageExportService.ts └── App.tsx
核心流程
整个导出过程分为 「4 个步骤」:
image.png
Step 1:DOM 截图(snapdom)
第一步,使用 snapdom 将整个消息列表 DOM 转换为高清 PNG 图片。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
import { snapdom } from '@zumer/snapdom';
const IMAGE_QUALITY = 0.95;const IMAGE_FORMAT = 'image/png' as const;
* 将 DOM 元素转换为图片 */export async function captureElementToImage( element: HTMLElement, quality: number = IMAGE_QUALITY): Promise<string> { console.log('开始截图...');
const originalOverflow = element.style.overflow; const originalHeight = element.style.height; const originalMaxHeight = element.style.maxHeight;
element.style.overflow = 'visible'; element.style.height = 'auto'; element.style.maxHeight = 'none';
try { const capture = await snapdom(element, { scale: 2, quality: quality });
const imgElement = await capture.toPng(); const dataUrl = imgElement.src;
if (!dataUrl || dataUrl.length < 100) { console.log('toPng 返回无效,尝试 toCanvas...'); const canvas = await capture.toCanvas(); return canvas.toDataURL(IMAGE_FORMAT, quality); }
console.log('截图成功,大小:', (dataUrl.length / 1024).toFixed(2), 'KB'); return dataUrl;
} finally { element.style.overflow = originalOverflow; element.style.height = originalHeight; element.style.maxHeight = originalMaxHeight; }}
「关键点解析」:
- 「临时修改样式」:将
overflow、height、maxHeight 临时设置为可见状态,确保截取完整内容 - 「scale: 2」:2 倍缩放提高清晰度,打印时效果更佳
- 「降级处理」:
toPng() 失败时自动回退到 toCanvas()
Step 2:图片分页(Canvas)
长图片需要按照 A4 页面高度进行分割,这是最复杂的一步。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineconst A4_WIDTH_MM = 210;const A4_HEIGHT_MM = 297;const PDF_MARGIN_MM = 10;const PDF_CONTENT_WIDTH_MM = A4_WIDTH_MM - PDF_MARGIN_MM * 2; const PDF_CONTENT_HEIGHT_MM = A4_HEIGHT_MM - PDF_MARGIN_MM * 2;
const MM_TO_PX = 3.7795275590551;
interface PageImageData { dataUrl: string; width: number; height: number;}
* 将长图片分割成多个 A4 页面 */export async function splitImageIntoPages( imageDataUrl: string): Promise<PageImageData[]> {
return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => { const pages: PageImageData[] = []; const originalWidth = img.width; const originalHeight = img.height;
const pageContentHeightPx = Math.floor( PDF_CONTENT_HEIGHT_MM * MM_TO_PX * 2 ); const pageContentWidthPx = Math.floor( PDF_CONTENT_WIDTH_MM * MM_TO_PX * 2 );
const widthScale = pageContentWidthPx / originalWidth; const scaledHeight = originalHeight * widthScale;
const totalPages = Math.ceil(scaledHeight / pageContentHeightPx);
console.log(`原始尺寸: ${originalWidth}x${originalHeight}px`); console.log(`缩放后高度: ${scaledHeight}px, 总页数: ${totalPages}`);
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { const startY = pageIndex * pageContentHeightPx; const endY = Math.min(startY + pageContentHeightPx, scaledHeight); const currentPageHeight = Math.floor(endY - startY);
const sourceStartY = startY / widthScale; const sourceHeight = currentPageHeight / widthScale;
const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!;
canvas.width = pageContentWidthPx; canvas.height = currentPageHeight;
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
ctx.drawImage( img, 0, sourceStartY, originalWidth, sourceHeight, 0, 0, pageContentWidthPx, currentPageHeight );
const pageDataUrl = canvas.toDataURL(IMAGE_FORMAT, IMAGE_QUALITY);
pages.push({ dataUrl: pageDataUrl, width: pageContentWidthPx, height: currentPageHeight });
console.log(`第 ${pageIndex + 1}/${totalPages} 页处理完成`); }
resolve(pages); };
img.onerror = () => reject(new Error('图片加载失败')); img.src = imageDataUrl; });}
「分页算法图解」:
原始长图 (假设 5000px 高)┌───────────────────┐│ │ ─┐│ Page 1 │ │ 1046px (277mm × 3.78 × 2)│ │ ─┘├───────────────────┤│ │ ─┐│ Page 2 │ │ 1046px│ │ ─┘├───────────────────┤│ │ ─┐│ Page 3 │ │ 1046px│ │ ─┘├───────────────────┤│ │ ─┐│ Page 4 │ │ 1046px│ │ ─┘├───────────────────┤│ Page 5 │ ── 剩余 816px│ │└───────────────────┘
Step 3:创建 PDF(jsPDF)
将分页后的图片逐一添加到 PDF 中。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport { jsPDF } from 'jspdf';
* 从分页图片创建 PDF */export function createPdfFromPages(pages: PageImageData[]): jsPDF { const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true });
if (pages.length === 0) { throw new Error('没有可添加的页面'); }
pages.forEach((page, index) => { if (index > 0) { pdf.addPage(); }
const scaleFactor = 2; const pageHeightMm = page.height / MM_TO_PX / scaleFactor;
const finalWidth = PDF_CONTENT_WIDTH_MM; const finalHeight = pageHeightMm;
const x = PDF_MARGIN_MM; const y = PDF_MARGIN_MM;
console.log(`添加第 ${index + 1} 页: ${finalWidth}x${finalHeight.toFixed(2)}mm`);
pdf.addImage(page.dataUrl, 'PNG', x, y, finalWidth, finalHeight); });
return pdf;}
Step 4:主导出函数
将以上步骤串联起来,提供统一的导出接口。
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineinterface ExportConfig { targetSelector: string; filename?: string; quality?: number; }
* 主导出函数 */export async function exportMessagesToPdf(config: ExportConfig): Promise<void> { const { targetSelector, filename = 'messages.pdf', quality = IMAGE_QUALITY } = config;
console.log('=== 开始导出 PDF ===');
const element = document.querySelector(targetSelector) as HTMLElement; if (!element) { throw new Error(`元素未找到: ${targetSelector}`); }
console.log('元素尺寸:', { width: element.offsetWidth, height: element.scrollHeight });
const imageDataUrl = await captureElementToImage(element, quality); console.log('截图完成,大小:', (imageDataUrl.length / 1024).toFixed(2), 'KB');
const pages = await splitImageIntoPages(imageDataUrl); console.log(`分页完成,共 ${pages.length} 页`);
const pdf = createPdfFromPages(pages);
pdf.save(filename); console.log('=== 导出完成 ===');}
在组件中使用
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
import { exportMessagesToPdf } from '../services/messageExportService';
const MessageList: React.FC = () => { const messageListRef = useRef<HTMLDivElement>(null); const [isExporting, setIsExporting] = useState(false);
const handleExportToPdf = useCallback(async () => { setIsExporting(true);
try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `messages-${timestamp}.pdf`;
await exportMessagesToPdf({ targetSelector: '.message-list-container', filename, quality: 0.95 });
} catch (error) { console.error('导出失败:', error); alert('导出失败,请重试'); } finally { setIsExporting(false); } }, []);
return ( <div className="message-list-container" ref={messageListRef}> <div className="message-list-header"> <h2>消息记录</h2> <button className="export-button" onClick={handleExportToPdf} disabled={isExporting} > {isExporting ? '导出中...' : '导出 PDF'} </button> </div>
<div className="message-list"> {messages.map(message => ( <MessageItem key={message.id} message={message} /> ))} </div> </div> );};
完整效果
运行项目后,点击「导出 PDF」按钮:
=== 开始导出 PDF ===目标选择器: .message-list-container元素尺寸: { width: 600, height: 8500 }开始截图...截图完成,大小: 2847.65 KB分页完成,共 8 页添加第 1 页: 190x277.00mm添加第 2 页: 190x277.00mm...添加第 8 页: 190x156.32mm=== 导出完成 ===
SnapDOM VS html2canvas
为什么选择 SnapDOM 而不是更流行的 html2canvas?让我们来对比一下:
详细对比表
| | |
|---|
| 「样式还原」 | | |
| 「Flexbox/Grid」 | | |
| 「渐变背景」 | | |
| 「阴影效果」 | | |
| 「自定义字体」 | | |
| 「SVG 支持」 | | |
| 「输出格式」 | | |
| 「包大小」 | | |
| 「维护状态」 | | |
| 「API 设计」 | | |
代码对比
「html2canvas 方式:」
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport html2canvas from 'html2canvas';
const canvas = await html2canvas(element, { scale: 2, useCORS: true, logging: false, allowTaint: true, foreignObjectRendering: true, });
const dataUrl = canvas.toDataURL('image/png');
「SnapDOM 方式:」
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport { snapdom } from '@zumer/snapdom';
const capture = await snapdom(element, { scale: 2, quality: 0.95});
const dataUrl = (await capture.toPng()).src;
什么时候选择 html2canvas?
虽然 SnapDOM 在大多数场景下更优秀,但 html2canvas 在以下情况可能更适合:
- 「团队熟悉度」:团队对 html2canvas 更熟悉
总结
核心要点回顾
- 「SnapDOM」 提供高保真的 DOM 截图能力,通过
scale: 2 实现 2 倍清晰度 - 「jsPDF」 是强大的 PDF 生成库,支持 A4 纸张、压缩等特性
- 「分页算法」 是整个方案的核心难点,需要精确计算像素与毫米的转换
- 「SnapDOM」 相比 html2canvas 在样式还原度上有明显优势
进一步优化方向
| |
|---|
| 「Web Worker」 | |
| 「分段截图」 | |
| 「加载提示」 | |
| 「PDF 压缩」 | |
| 「页眉页脚」 | |
阅读原文:https://mp.weixin.qq.com/s/vhNmHV5X-TKVs0axY0HqYA
该文章在 2026/1/6 9:01:49 编辑过