本文將深入講解如何使用 snapdom 和 jsPDF 實現高品質的 HTML 轉 PDF 功能,並透過一個完整的消息列表導出案例,帶你掌握這套方案的核心技術。
在現代 Web 應用中,HTML 轉 PDF 是一個非常常見的需求場景:
然而,實現一個高品質的 HTML 轉 PDF 功能並不簡單。我們面臨以下挑戰:
| 挑戰 | 描述 |
|---|---|
| 樣式還原 | CSS 樣式、字體、漸變等能否完美呈現? |
| 分頁處理 | 長內容如何智能分頁,避免內容被截斷? |
| 清晰度 | 導出的 PDF 是否足夠清晰,尤其在列印時? |
| 性能 | 大量內容(如 1000 條消息)能否快速導出? |
| 相容性 | 不同瀏覽器表現是否一致? |
傳統的 html2canvas + jsPDF 方案雖然能用,但在樣式還原度和截圖品質上存在明顯不足。
今天筆者介紹一套新解決方案:snapdom + jsPDF。
SnapDOM 是一個現代化的 DOM 截圖庫,它的核心特點是:
DOM Element → Canvas/PNG/SVG
scale 參數實現 2x/3x 高清截圖import { snapdom } from '@zumer/snapdom';
// 獲取 DOM 元素
const element = document.querySelector('.my-element');
// 截圖
const capture = await snapdom(element, {
scale: 2, // 2倍清晰度
quality: 0.95 // PNG 質量
});
// 輸出方式
const canvas = await capture.toCanvas(); // Canvas 元素
const imgEl = await capture.toPng(); // <img> 元素,src 為 data URL
const svgStr = await capture.toSvg(); // SVG 字符串
| 參數 | 類型 | 默認值 | 說明 |
|---|---|---|---|
scale |
number | 1 | 縮放倍數,2 表示 2 倍清晰度 |
quality |
number | 0.92 | 圖片質量,範圍 0-1 |
更多詳細內容請看 snapdom.dev/官方文檔。
jsPDF 是最流行的 JavaScript PDF 生成庫,支持在瀏覽器端直接創建 PDF 檔案。
import { jsPDF } from 'jspdf';
// 創建 PDF 實例
const pdf = new jsPDF({
orientation: 'portrait', // 豎向
unit: 'mm', // 單位:毫米
format: 'a4', // A4 紙張
compress: true // 啟用壓縮
});
// 添加圖片
pdf.addImage(
imageDataUrl, // Base64 圖片數據
'PNG', // 圖片格式
10, // X 坐標(mm)
10, // Y 坐標(mm)
190, // 寬度(mm)
100 // 高度(mm)
);
// 添加新頁面
pdf.addPage();
// 保存文件
pdf.save('output.pdf');
// A4 標準尺寸(單位:mm)
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
// 頁面邊距
const MARGIN_MM = 10;
// 可用內容區域
const CONTENT_WIDTH_MM = 190; // 210 - 10*2
const CONTENT_HEIGHT_MM = 277; // 297 - 10*2

筆者寫一個IM產品中 MessageList 消息導出DEMO。接下來,我們透過一個完整的客服消息列表導出案例,講解如何使用 snapdom + jsPDF 實現 HTML 轉 PDF。
src/
├── components/
│ ├── MessageList.tsx # 消息列表組件
│ └── MessageList.css # 消息列表樣式
├── services/
│ └── messageExportService.ts # PDF 導出服務(核心)
└── App.tsx
整個導出過程分為 4 個步驟:

第一步,使用 snapdom 將整個消息列表 DOM 轉換為高清 PNG 圖片。
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 {
// 核心:使用 snapdom 進行截圖
const capture = await snapdom(element, {
scale: 2, // 2倍清晰度
quality: quality
});
// 優先使用 toPng()
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 臨時設置為可見狀態,確保截取完整內容toPng() 失敗時自動回退到 toCanvas()長圖片需要按照 A4 頁面高度進行分割,這是最複雜的一步。
// 尺寸常量
const 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; // 190mm
const PDF_CONTENT_HEIGHT_MM = A4_HEIGHT_MM - PDF_MARGIN_MM * 2; // 277mm
// 1mm = 3.7795275590551 像素(96 DPI)
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;
// 將 A4 內容區域轉換為像素(考慮 scale=2)
const pageContentHeightPx = Math.floor(
PDF_CONTENT_HEIGHT_MM * MM_TO_PX * 2 // scale=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;
// 創建新 Canvas
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 // 目標尺寸
);
// 轉換為 data URL
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
│ │
└───────────────────┘
將分頁後的圖片逐一添加到 PDF 中。
import { 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) => {
// 第一頁直接用,後續需要 addPage
if (index > 0) {
pdf.addPage();
}
// 像素轉毫米(考慮 scale=2)
const scaleFactor = 2;
const pageHeightMm = page.height / MM_TO_PX / scaleFactor;
// 圖片適配內容區域寬度
const finalWidth = PDF_CONTENT_WIDTH_MM; // 190mm
const finalHeight = pageHeightMm;
// 位置:左上角對齊,保留 10mm 邊距
const x = PDF_MARGIN_MM;
const y = PDF_MARGIN_MM;
console.log(`添加第 ${index + 1} 頁: ${finalWidth}x${finalHeight.toFixed(2)}mm`);
// 添加圖片到 PDF
pdf.addImage(page.dataUrl, 'PNG', x, y, finalWidth, finalHeight);
});
return pdf;
}
將以上步驟串聯起來,提供統一的導出介面。
interface ExportConfig {
targetSelector: string; // CSS 選擇器
filename?: string; // 檔名
quality?: number; // 圖片品質
}
/**
* 主導出函數
*/
export async function exportMessagesToPdf(config: ExportConfig): Promise<void> {
const {
targetSelector,
filename = 'messages.pdf',
quality = IMAGE_QUALITY
} = config;
console.log('=== 開始導出 PDF ===');
// 1. 獲取目標元素
const element = document.querySelector(targetSelector) as HTMLElement;
if (!element) {
throw new Error(`元素未找到: ${targetSelector}`);
}
console.log('元素尺寸:', {
width: element.offsetWidth,
height: element.scrollHeight
});
// 2. DOM 截圖
const imageDataUrl = await captureElementToImage(element, quality);
console.log('截圖完成,大小:', (imageDataUrl.length / 1024).toFixed(2), 'KB');
// 3. 圖片分頁
const pages = await splitImageIntoPages(imageDataUrl);
console.log(`分頁完成,共 ${pages.length} 頁`);
// 4. 創建 PDF
const pdf = createPdfFromPages(pages);
// 5. 保存文件
pdf.save(filename);
console.log('=== 導出完成 ===');
}
// MessageList.tsx
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 而不是更流行的 html2canvas?讓我們來對比一下:
| 對比維度 | SnapDOM | html2canvas |
|---|---|---|
| 樣式還原 | ★★★★★ 接近完美 | ★★★☆☆ 部分樣式丟失 |
| Flexbox/Grid | ✅ 完美支援 | ⚠️ 部分問題 |
| 漸變背景 | ✅ 完美支援 | ⚠️ 可能失真 |
| 陰影效果 | ✅ 完美支援 | ⚠️ 部分丟失 |
| 自訂字型 | ✅ 支援 | ⚠️ 需要額外處理 |
| SVG 支援 | ✅ 原生支援 | ⚠️ 有限支援 |
| 輸出格式 | PNG/Canvas/SVG | Canvas/PNG |
| 包大小 | ~20KB | ~60KB |
| 維護狀態 | 活躍更新 | 較少更新 |
| API 設計 | 現代 Promise | 回調 + Promise |
html2canvas 方式:
import html2canvas from 'html2canvas';
// 需要處理各種相容性問題
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
logging: false,
allowTaint: true,
foreignObjectRendering: true, // 可能不生效
// 還需要處理字型、SVG 等問題...
});
const dataUrl = canvas.toDataURL('image/png');
SnapDOM 方式:
import { snapdom } from '@zumer/snapdom';
// 簡潔的 API,無需額外配置
const capture = await snapdom(element, {
scale: 2,
quality: 0.95
});
const dataUrl = (await capture.toPng()).src;
雖然 SnapDOM 在大多數場景下更優秀,但 html2canvas 在以下情況可能更適合:
scale: 2 實現 2 倍清晰度| 優化點 | 說明 |
|---|---|
| Web Worker | 將分頁計算放到 Worker 中,避免阻塞主線程 |
| 分段截圖 | 超長內容分段截圖,避免記憶體溢出 |
| 加載提示 | 添加進度條,提升用戶體驗 |
| PDF 壓縮 | 使用 pdf-lib 進一步壓縮 PDF 體積 |
| 頁眉頁腳 | 添加頁碼、時間戳等信息 |