JavaScript實(shí)現(xiàn)HTML頁面轉(zhuǎn)換成PDF的技術(shù)方案
背景
為什么 HTML 轉(zhuǎn) PDF 如此重要?
在現(xiàn)代 Web 應(yīng)用中,HTML 轉(zhuǎn) PDF 是一個(gè)非常常見的需求場景:
- 客服系統(tǒng):導(dǎo)出聊天記錄用于存檔或投訴處理
- 電商平臺:生成訂單詳情、發(fā)票等 PDF 文檔
- 報(bào)表系統(tǒng):將可視化圖表和數(shù)據(jù)導(dǎo)出為 PDF 報(bào)告
- 在線文檔:支持用戶將網(wǎng)頁內(nèi)容離線保存
- 合同簽署:生成合同 PDF 用于電子簽名
然而,實(shí)現(xiàn)一個(gè)高質(zhì)量的 HTML 轉(zhuǎn) PDF 功能并不簡單。我們面臨以下挑戰(zhàn):
| 挑戰(zhàn) | 描述 |
|---|---|
| 樣式還原 | CSS 樣式、字體、漸變等能否完美呈現(xiàn)? |
| 分頁處理 | 長內(nèi)容如何智能分頁,避免內(nèi)容被截?cái)啵?/td> |
| 清晰度 | 導(dǎo)出的 PDF 是否足夠清晰,尤其在打印時(shí)? |
| 性能 | 大量內(nèi)容(如 1000 條消息)能否快速導(dǎo)出? |
| 兼容性 | 不同瀏覽器表現(xiàn)是否一致? |
傳統(tǒng)的 html2canvas + jsPDF 方案雖然能用,但在樣式還原度和截圖質(zhì)量上存在明顯不足。
今天筆者介紹一套新解決方案:snapdom + jsPDF。
snapdom 和 jsPDF 基礎(chǔ)理論知識
snapdom 是什么?
SnapDOM 是一個(gè)現(xiàn)代化的 DOM 截圖庫,它的核心特點(diǎn)是:
DOM Element → Canvas/PNG/SVG
核心優(yōu)勢
- 高保真截圖:完美還原 CSS 樣式,包括 flexbox、grid、漸變、陰影等
- 多種輸出格式:支持 Canvas、PNG、SVG 等多種格式
- 高清縮放:通過
scale參數(shù)實(shí)現(xiàn) 2x/3x 高清截圖 - 體積小巧:壓縮后僅 ~20KB
基礎(chǔ)用法
import { snapdom } from '@zumer/snapdom';
// 獲取 DOM 元素
const element = document.querySelector('.my-element');
// 截圖
const capture = await snapdom(element, {
scale: 2, // 2倍清晰度
quality: 0.95 // PNG 質(zhì)量
});
// 輸出方式
const canvas = await capture.toCanvas(); // Canvas 元素
const imgEl = await capture.toPng(); // <img> 元素,src 為 data URL
const svgStr = await capture.toSvg(); // SVG 字符串
關(guān)鍵參數(shù)說明
| 參數(shù) | 類型 | 默認(rèn)值 | 說明 |
|---|---|---|---|
scale | number | 1 | 縮放倍數(shù),2 表示 2 倍清晰度 |
quality | number | 0.92 | 圖片質(zhì)量,范圍 0-1 |
jsPDF 是什么?
jsPDF 是最流行的 JavaScript PDF 生成庫,支持在瀏覽器端直接創(chuàng)建 PDF 文件。
核心特點(diǎn)
- 純前端方案:無需服務(wù)端,瀏覽器直接生成
- 功能豐富:支持文本、圖片、表格、鏈接等
- 多種尺寸:A4、Letter 等標(biāo)準(zhǔn)紙張格式
- 插件生態(tài):支持 AutoTable 等擴(kuò)展插件
基礎(chǔ)用法
import { jsPDF } from 'jspdf';
// 創(chuàng)建 PDF 實(shí)例
const pdf = new jsPDF({
orientation: 'portrait', // 縱向
unit: 'mm', // 單位:毫米
format: 'a4', // A4 紙張
compress: true // 啟用壓縮
});
// 添加圖片
pdf.addImage(
imageDataUrl, // Base64 圖片數(shù)據(jù)
'PNG', // 圖片格式
10, // X 坐標(biāo)(mm)
10, // Y 坐標(biāo)(mm)
190, // 寬度(mm)
100 // 高度(mm)
);
// 添加新頁面
pdf.addPage();
// 保存文件
pdf.save('output.pdf');
A4 尺寸常量
// A4 標(biāo)準(zhǔn)尺寸(單位:mm) const A4_WIDTH_MM = 210; const A4_HEIGHT_MM = 297; // 頁面邊距 const MARGIN_MM = 10; // 可用內(nèi)容區(qū)域 const CONTENT_WIDTH_MM = 190; // 210 - 10*2 const CONTENT_HEIGHT_MM = 277; // 297 - 10*2
snapdom + jsPDF 組合的優(yōu)勢

案例講述
筆者寫一個(gè)IM產(chǎn)品中 MessageList 消息導(dǎo)出DEMO。接下來,我們通過一個(gè)完整的客服消息列表導(dǎo)出案例,講解如何使用 snapdom + jsPDF 實(shí)現(xiàn) HTML 轉(zhuǎn) PDF。
項(xiàng)目結(jié)構(gòu)
src/ ├── components/ │ ├── MessageList.tsx # 消息列表組件 │ └── MessageList.css # 消息列表樣式 ├── services/ │ └── messageExportService.ts # PDF 導(dǎo)出服務(wù)(核心) └── App.tsx
核心流程
整個(gè)導(dǎo)出過程分為 4 個(gè)步驟:

Step 1:DOM 截圖(snapdom)
第一步,使用 snapdom 將整個(gè)消息列表 DOM 轉(zhuǎn)換為高清 PNG 圖片。
// messageExportService.ts
import { snapdom } from '@zumer/snapdom';
// 圖片質(zhì)量配置
const IMAGE_QUALITY = 0.95;
const IMAGE_FORMAT = 'image/png' as const;
/**
* 將 DOM 元素轉(zhuǎn)換為圖片
*/
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;
// 臨時(shí)設(shè)置樣式,確保完整截圖
element.style.overflow = 'visible';
element.style.height = 'auto';
element.style.maxHeight = 'none';
try {
// 核心:使用 snapdom 進(jìn)行截圖
const capture = await snapdom(element, {
scale: 2, // 2倍清晰度
quality: quality
});
// 優(yōu)先使用 toPng()
const imgElement = await capture.toPng();
const dataUrl = imgElement.src;
// 驗(yàn)證數(shù)據(jù)有效性
if (!dataUrl || dataUrl.length < 100) {
console.log('toPng 返回?zé)o效,嘗試 toCanvas...');
const canvas = await capture.toCanvas();
return canvas.toDataURL(IMAGE_FORMAT, quality);
}
console.log('截圖成功,大小:', (dataUrl.length / 1024).toFixed(2), 'KB');
return dataUrl;
} finally {
// 恢復(fù)原始樣式
element.style.overflow = originalOverflow;
element.style.height = originalHeight;
element.style.maxHeight = originalMaxHeight;
}
}
關(guān)鍵點(diǎn)解析:
- 臨時(shí)修改樣式:將
overflow、height、maxHeight臨時(shí)設(shè)置為可見狀態(tài),確保截取完整內(nèi)容 - scale: 2:2 倍縮放提高清晰度,打印時(shí)效果更佳
- 降級處理:
toPng()失敗時(shí)自動回退到toCanvas() - 樣式恢復(fù):截圖完成后恢復(fù)原始樣式
Step 2:圖片分頁(Canvas)
長圖片需要按照 A4 頁面高度進(jìn)行分割,這是最復(fù)雜的一步。
// 尺寸常量
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;
// 分頁后的圖片數(shù)據(jù)
interface PageImageData {
dataUrl: string;
width: number;
height: number;
}
/**
* 將長圖片分割成多個(gè) 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 內(nèi)容區(qū)域轉(zhuǎn)換為像素(考慮 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
);
// 計(jì)算縮放比例(圖片寬度適配頁面寬度)
const widthScale = pageContentWidthPx / originalWidth;
const scaledHeight = originalHeight * widthScale;
// 計(jì)算總頁數(shù)
const totalPages = Math.ceil(scaledHeight / pageContentHeightPx);
console.log(`原始尺寸: ${originalWidth}x${originalHeight}px`);
console.log(`縮放后高度: ${scaledHeight}px, 總頁數(shù): ${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);
// 計(jì)算源圖片對應(yīng)的區(qū)域
const sourceStartY = startY / widthScale;
const sourceHeight = currentPageHeight / widthScale;
// 創(chuàng)建新 Canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = pageContentWidthPx;
canvas.height = currentPageHeight;
// 高質(zhì)量渲染
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 繪制當(dāng)前頁內(nèi)容
ctx.drawImage(
img,
0, sourceStartY, // 源圖片起始位置
originalWidth, sourceHeight, // 源圖片尺寸
0, 0, // 目標(biāo)起始位置
pageContentWidthPx, currentPageHeight // 目標(biāo)尺寸
);
// 轉(zhuǎn)換為 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;
});
}
分頁算法圖解:
原始長圖 (假設(shè) 5000px 高) ┌───────────────────┐ │ │ ─┐ │ Page 1 │ │ 1046px (277mm × 3.78 × 2) │ │ ─┘ ├───────────────────┤ │ │ ─┐ │ Page 2 │ │ 1046px │ │ ─┘ ├───────────────────┤ │ │ ─┐ │ Page 3 │ │ 1046px │ │ ─┘ ├───────────────────┤ │ │ ─┐ │ Page 4 │ │ 1046px │ │ ─┘ ├───────────────────┤ │ Page 5 │ ── 剩余 816px │ │ └───────────────────┘
Step 3:創(chuàng)建 PDF(jsPDF)
將分頁后的圖片逐一添加到 PDF 中。
import { jsPDF } from 'jspdf';
/**
* 從分頁圖片創(chuàng)建 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) => {
// 第一頁直接用,后續(xù)需要 addPage
if (index > 0) {
pdf.addPage();
}
// 像素轉(zhuǎn)毫米(考慮 scale=2)
const scaleFactor = 2;
const pageHeightMm = page.height / MM_TO_PX / scaleFactor;
// 圖片適配內(nèi)容區(qū)域?qū)挾?
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;
}
Step 4:主導(dǎo)出函數(shù)
將以上步驟串聯(lián)起來,提供統(tǒng)一的導(dǎo)出接口。
interface ExportConfig {
targetSelector: string; // CSS 選擇器
filename?: string; // 文件名
quality?: number; // 圖片質(zhì)量
}
/**
* 主導(dǎo)出函數(shù)
*/
export async function exportMessagesToPdf(config: ExportConfig): Promise<void> {
const {
targetSelector,
filename = 'messages.pdf',
quality = IMAGE_QUALITY
} = config;
console.log('=== 開始導(dǎo)出 PDF ===');
// 1. 獲取目標(biāo)元素
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. 創(chuàng)建 PDF
const pdf = createPdfFromPages(pages);
// 5. 保存文件
pdf.save(filename);
console.log('=== 導(dǎo)出完成 ===');
}
在組件中使用
// 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 {
// 生成帶時(shí)間戳的文件名
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('導(dǎo)出失敗:', error);
alert('導(dǎo)出失敗,請重試');
} 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 ? '導(dǎo)出中...' : '導(dǎo)出 PDF'}
</button>
</div>
<div className="message-list">
{messages.map(message => (
<MessageItem key={message.id} message={message} />
))}
</div>
</div>
);
};
完整效果
運(yùn)行項(xiàng)目后,點(diǎn)擊「導(dǎo)出 PDF」按鈕:
- 控制臺顯示詳細(xì)的導(dǎo)出日志
- 自動計(jì)算頁數(shù)并分頁
- 生成高清 PDF 文件并自動下載
=== 開始導(dǎo)出 PDF ===
目標(biāo)選擇器: .message-list-container
元素尺寸: { width: 600, height: 8500 }
開始截圖...
截圖完成,大小: 2847.65 KB
分頁完成,共 8 頁
添加第 1 頁: 190x277.00mm
添加第 2 頁: 190x277.00mm
...
添加第 8 頁: 190x156.32mm
=== 導(dǎo)出完成 ===
SnapDOM VS html2canvas
為什么選擇 SnapDOM 而不是更流行的 html2canvas?讓我們來對比一下:
詳細(xì)對比表
| 對比維度 | SnapDOM | html2canvas |
|---|---|---|
| 樣式還原 | ★★★★★ 接近完美 | ★★★☆☆ 部分樣式丟失 |
| Flexbox/Grid | ? 完美支持 | ?? 部分問題 |
| 漸變背景 | ? 完美支持 | ?? 可能失真 |
| 陰影效果 | ? 完美支持 | ?? 部分丟失 |
| 自定義字體 | ? 支持 | ?? 需要額外處理 |
| SVG 支持 | ? 原生支持 | ?? 有限支持 |
| 輸出格式 | PNG/Canvas/SVG | Canvas/PNG |
| 包大小 | ~20KB | ~60KB |
| 維護(hù)狀態(tài) | 活躍更新 | 較少更新 |
| API 設(shè)計(jì) | 現(xiàn)代 Promise | 回調(diào) + 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;
什么時(shí)候選擇 html2canvas?
雖然 SnapDOM 在大多數(shù)場景下更優(yōu)秀,但 html2canvas 在以下情況可能更適合:
- 項(xiàng)目已在使用:遷移成本較高
- 簡單場景:只需截取簡單文本,無復(fù)雜樣式
- 團(tuán)隊(duì)熟悉度:團(tuán)隊(duì)對 html2canvas 更熟悉
總結(jié)
核心要點(diǎn)回顧
- SnapDOM 提供高保真的 DOM 截圖能力,通過
scale: 2實(shí)現(xiàn) 2 倍清晰度 - jsPDF 是強(qiáng)大的 PDF 生成庫,支持 A4 紙張、壓縮等特性
- 分頁算法 是整個(gè)方案的核心難點(diǎn),需要精確計(jì)算像素與毫米的轉(zhuǎn)換
- SnapDOM 相比 html2canvas 在樣式還原度上有明顯優(yōu)勢
進(jìn)一步優(yōu)化方向
| 優(yōu)化點(diǎn) | 說明 |
|---|---|
| Web Worker | 將分頁計(jì)算放到 Worker 中,避免阻塞主線程 |
| 分段截圖 | 超長內(nèi)容分段截圖,避免內(nèi)存溢出 |
| 加載提示 | 添加進(jìn)度條,提升用戶體驗(yàn) |
| PDF 壓縮 | 使用 pdf-lib 進(jìn)一步壓縮 PDF 體積 |
| 頁眉頁腳 | 添加頁碼、時(shí)間戳等信息 |
以上就是JavaScript實(shí)現(xiàn)HTML頁面轉(zhuǎn)換成PDF的技術(shù)方案的詳細(xì)內(nèi)容,更多關(guān)于JavaScript HTML轉(zhuǎn)PDF的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
使用Vue3實(shí)現(xiàn)一個(gè)Upload組件的示例代碼
這篇文章主要介紹了使用Vue3實(shí)現(xiàn)一個(gè)Upload組件的示例代碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-05-05
JavaScript中使用構(gòu)造器創(chuàng)建對象無需new的情況說明
JS中創(chuàng)建對象可以直接使用直接量的方式,這里討論的是定義一個(gè)構(gòu)造器(function)的情況2012-03-03
JavaScript數(shù)組常用的增刪改查與其他屬性詳解
這篇文章主要給大家介紹了關(guān)于JavaScript數(shù)組常用的增刪改查與其他屬性的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10
基于javascript html5實(shí)現(xiàn)3D翻書特效
這篇文章主要介紹了基于javascript html5實(shí)現(xiàn)翻書特效的實(shí)現(xiàn)方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-03-03
JavaScript中的console.time()函數(shù)詳細(xì)介紹
這篇文章主要介紹了JavaScript中的console.time()函數(shù)詳細(xì)介紹,console.time()函數(shù)主要用來統(tǒng)計(jì)程序執(zhí)行時(shí)間,需要的朋友可以參考下2014-12-12
Javascript中String的常用方法實(shí)例分析
這篇文章主要介紹了Javascript中String的常用方法,實(shí)例分析了String常用的字符轉(zhuǎn)換、截取、分割等技巧,需要的朋友可以參考下2015-06-06

