🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

一種新HTML頁面轉換成 PDF 技術方案

背景

本文將深入講解如何使用 snapdom 和 jsPDF 實現高品質的 HTML 轉 PDF 功能,並透過一個完整的消息列表導出案例,帶你掌握這套方案的核心技術。

為什麼 HTML 轉 PDF 如此重要?

在現代 Web 應用中,HTML 轉 PDF 是一個非常常見的需求場景:

  1. 客服系統:導出聊天記錄用於存檔或投訴處理
  2. 電商平台:生成訂單詳情、發票等 PDF 文檔
  3. 報表系統:將可視化圖表和數據導出為 PDF 報告
  4. 在線文檔:支持用戶將網頁內容離線保存
  5. 合同簽署:生成合同 PDF 用於電子簽名

然而,實現一個高品質的 HTML 轉 PDF 功能並不簡單。我們面臨以下挑戰:

挑戰 描述
樣式還原 CSS 樣式、字體、漸變等能否完美呈現?
分頁處理 長內容如何智能分頁,避免內容被截斷?
清晰度 導出的 PDF 是否足夠清晰,尤其在列印時?
性能 大量內容(如 1000 條消息)能否快速導出?
相容性 不同瀏覽器表現是否一致?

傳統的 html2canvas + jsPDF 方案雖然能用,但在樣式還原度截圖品質上存在明顯不足。

今天筆者介紹一套新解決方案:snapdom + jsPDF

snapdom 和 jsPDF 基礎理論知識

snapdom 是什麼?

SnapDOM 是一個現代化的 DOM 截圖庫,它的核心特點是:

DOM Element → Canvas/PNG/SVG

核心優勢

  1. 高保真截圖:完美還原 CSS 樣式,包括 flexbox、grid、漸變、陰影等
  2. 多種輸出格式:支援 Canvas、PNG、SVG 等多種格式
  3. 高清縮放:通過 scale 參數實現 2x/3x 高清截圖
  4. 體積小巧:壓縮後僅 ~20KB

基礎用法

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 是什麼?

jsPDF 是最流行的 JavaScript PDF 生成庫,支持在瀏覽器端直接創建 PDF 檔案。

核心特點

  1. 純前端方案:無需服務端,瀏覽器直接生成
  2. 功能豐富:支持文本、圖片、表格、鏈接等
  3. 多種尺寸:A4、Letter 等標準紙張格式
  4. 插件生態:支持 AutoTable 等擴展插件

基礎用法

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 尺寸常量

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

snapdom + jsPDF 組合的優勢

image.png

案例講述

筆者寫一個IM產品中 MessageList 消息導出DEMO。接下來,我們透過一個完整的客服消息列表導出案例,講解如何使用 snapdom + jsPDF 實現 HTML 轉 PDF。

專案結構

src/
├── components/
│   ├── MessageList.tsx      # 消息列表組件
│   └── MessageList.css      # 消息列表樣式
├── services/
│   └── messageExportService.ts  # PDF 導出服務(核心)
└── App.tsx

核心流程

整個導出過程分為 4 個步驟
image.png

Step 1:DOM 截圖(snapdom)

第一步,使用 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;
  }
}

關鍵點解析

  1. 臨時修改樣式:將 overflowheightmaxHeight 臨時設置為可見狀態,確保截取完整內容
  2. scale: 2:2 倍縮放提高清晰度,列印時效果更佳
  3. 降級處理toPng() 失敗時自動回退到 toCanvas()
  4. 樣式恢復:截圖完成後恢復原始樣式

Step 2:圖片分頁(Canvas)

長圖片需要按照 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
│                   │
└───────────────────┘

Step 3:創建 PDF(jsPDF)

將分頁後的圖片逐一添加到 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;
}

Step 4:主導出函數

將以上步驟串聯起來,提供統一的導出介面。

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」按鈕:

  1. 控制台顯示詳細的導出日誌
  2. 自動計算頁數並分頁
  3. 生成高清 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?讓我們來對比一下:

詳細對比表

對比維度 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;

什麼時候選擇 html2canvas?

雖然 SnapDOM 在大多數場景下更優秀,但 html2canvas 在以下情況可能更適合:

  1. 專案已在使用:遷移成本較高
  2. 簡單場景:只需截取簡單文本,無複雜樣式
  3. 團隊熟悉度:團隊對 html2canvas 更熟悉

總結

核心要點回顧

  1. SnapDOM 提供高保真的 DOM 截圖能力,通過 scale: 2 實現 2 倍清晰度
  2. jsPDF 是強大的 PDF 生成庫,支持 A4 紙張、壓縮等特性
  3. 分頁算法 是整個方案的核心難點,需要精確計算像素與毫米的轉換
  4. SnapDOM 相比 html2canvas 在樣式還原度上有明顯優勢

進一步優化方向

優化點 說明
Web Worker 將分頁計算放到 Worker 中,避免阻塞主線程
分段截圖 超長內容分段截圖,避免記憶體溢出
加載提示 添加進度條,提升用戶體驗
PDF 壓縮 使用 pdf-lib 進一步壓縮 PDF 體積
頁眉頁腳 添加頁碼、時間戳等信息

原文出處:https://juejin.cn/post/7576608733612425242


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝14   💬4   ❤️1
297
🥈
我愛JS
📝1   💬3   ❤️2
42
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付