Vue/JS 通用 PDF 导出工具类:支持自动分页、打印下载、自定义页眉(基于 html2canvas+jspdf)

93 阅读11分钟

写在最前面的话----本文中的代码并非全部由我编写,是之前写项目时有这个需求,我在稀土掘金中找到的一篇相关的,但是实际效果并不理想不能实现我要的效果,并且存在分页不全,空白格高度计算有误等几个问题。这个是在原来基础上优化了问题,增加可用性的版本。比较可惜的是我当时没有保存,没能找到原文,有找到原文的可以评论私信,我将原文地址放在最上方。-----刚工作半年的前端开发

在 Web 开发中,我们经常需要将页面指定 DOM 元素导出为 PDF 文件(如报表、合同、数据清单等),但原生jspdf+html2canvas组合存在分页不准确、跨域图片报错、缺乏自定义页眉、打印与下载切换不便等问题。为此,我封装了一款通用 PDF 导出工具类,完美解决以上痛点,支持自动分页、打印 / 下载双模式、自定义页眉、灵活配置等核心功能,适配 Vue2/Vue3 / 原生 JS 项目,下面详细介绍其功能与使用方法。

一、工具类核心优势

基于html2canvas(DOM 转 Canvas)和jspdf(Canvas 转 PDF)开发,在原生能力基础上做了深度优化,核心优势如下:

  1. 自动智能分页:支持通过自定义类名标记分页节点,自动计算内容高度拆分 PDF 页面,解决长内容跨页断裂问题
  2. 双模式导出:支持直接下载 PDF 文件,或唤起浏览器打印窗口(无需下载直接打印)
  3. 自定义配置:可灵活配置缩放比例、DPI、页边距、跨域图片支持等参数
  4. 自定义页眉:支持在分页处添加自定义页眉(如文档标题、页码等)
  5. 无冗余残留:导出后自动清理分页时添加的空白节点和页眉节点,不污染原 DOM
  6. 多项目适配:兼容原生 JS、Vue2、Vue3 等多种项目场景,无需额外修改
  7. 调试友好:支持通过背景色显示空白分页节点,方便手动调整分页精度

二、前置依赖安装

工具类基于html2canvasjspdf实现,使用前需先安装依赖:

# 安装核心依赖
npm install html2canvas jspdf --save

三、工具类完整代码

直接复制以下代码到项目中(建议放在utils/PdfLoader.js目录下):

/**
 * 写在最前面的话----此工具类不一定适配所有项目及场景,请根据实际情况进行修改或扩展。
 * 复杂布局的分页可能不准确,请根据实际情况进行调整。
 * _createPageHeadNode方法中,可以根据实际情况调整测试页眉部分的信息。
 * _createEmptyNode方法中,可以根据实际情况添加空白节点信息
 * 通过改变背景色等方法更清晰的看到空白节点的大小问题
 * 手动调整_preprocessForPagination方法中const diff2Ele = node.offsetTop - 150;中的'150'
 * 来调整空白节点的高度,以达到分页效果。
*/

import html2Canvas from "html2canvas";
import JsPDF from "jspdf";
class PdfLoader {
    /**
     * PDF导出工具类
     * @param {boolean} isPrintPDF -导出下载pdf文件还是唤出打印窗口打印pdf
     * @param {HTMLElement} ele - 要导出为PDF的DOM元素
     * @param {string} pdfFileName - 导出的PDF文件名(不带扩展名)
     * @param {string} [splitClassName] - 用于分页判断的类名(可选)
     * @param {number} [scrollWidth] - 容器滚动宽度(可选)
     * @param {Object} [options] - 配置选项
     * @param {number} [options.scale=2] - 缩放比例
     * @param {number} [options.dpi=300] - DPI设置
     * @param {number} [options.margin=20] - PDF页边距
     * @param {boolean} [options.useCORS=true] - 是否使用CORS
     * @param {boolean} [options.allowTaint=true] - 是否允许污染画布
     */
    constructor(isPrintPDF, ele, pdfFileName, splitClassName, scrollWidth, options = {}) {
        if (!ele || !pdfFileName) {
            throw new Error("必须提供有效的DOM元素和PDF文件名");
        }
        this.isPrintPDF = isPrintPDF;
        this.ele = ele;
        this.pdfFileName = pdfFileName;
        this.splitClassName = splitClassName;
        this.scrollWidth = scrollWidth || ele.scrollWidth || ele.offsetWidth;

        this.A4_WIDTH = 595;
        this.A4_HEIGHT = 842;

        this.options = {
            scale: 2,
            dpi: 300,
            margin: 20,
            useCORS: true,
            allowTaint: true,
            ...options
        };
    }

    /**
     * 生成PDF并下载
     * @returns {Promise<void>}
     */
    async outPutPdfFn() {
        try {
            await this._preprocessForPagination();

            const canvas = await this._generateCanvas();

            this._cleanupPreprocessElements();

            const pdfData = await this._generatePdfFromCanvas(canvas);

            return pdfData;
        } catch (error) {
            alert("PDF导出失败,请检查配置或联系管理员");
            console.error("生成PDF时出错:", error);
            throw error; // 重新抛出错误以便外部处理
        }
    }

    /**
     * 预处理:添加分页标记
     * @private
     */
    async _preprocessForPagination() {
        if (!this.splitClassName) return;
        const childList = this.ele.getElementsByClassName(this.splitClassName);
        const eleBounding = this.ele.getBoundingClientRect();
        const pageHeight = (this.scrollWidth / this.A4_WIDTH) * this.A4_HEIGHT;
        let pageNum = 1;

        // 计算总内容高度(用于校验总页数)
        const totalContentHeight = this.ele.offsetHeight;
        const totalPages = Math.ceil(totalContentHeight / pageHeight); // 理论总页数
        console.log("总内容高度:", totalContentHeight, "单页高度:", pageHeight, "理论总页数:", totalPages);
        //
        //
        // 新增:记录已添加的空节点总高度(用于抵消后续计算)
        let addedEmptyHeight = 0;
        //
        //

        for (const node of childList) {
            // 关键:用offsetTop更准确(不受滚动影响),且已包含之前插入的空节点高度
            const diff2Ele = node.offsetTop - 150;
            let shouldInPage = Math.ceil((diff2Ele + node.offsetHeight) / pageHeight); // 用元素底部位置计算
    
            // 打印当前元素的关键参数
            console.log(`元素: ${node.nodeName}(${node.textContent.slice(0, 20)})`);
            console.log(`- 距离容器顶部: ${diff2Ele}px`);
            console.log(`- 应在页数: ${shouldInPage}, 当前页号: ${pageNum}`);



            if (pageNum < shouldInPage) {
                pageNum = shouldInPage;
                const parentNode = node.parentNode;
                // 传入当前累计的空节点高度,用于计算
                const emptyNode = this._createEmptyNode(pageHeight, pageNum, diff2Ele, node, addedEmptyHeight);
                const pageHead = this._createPageHeadNode();

                parentNode.insertBefore(emptyNode, node);
                parentNode.insertBefore(pageHead, node);

                //
                //
                // 累加已添加空节点的高度(用于后续计算)
                addedEmptyHeight += parseFloat(emptyNode.style.height);
                console.log(`已添加空节点总高度: ${addedEmptyHeight}px`);
                //
                //
            }
        }
    }

    /**
     * 创建空节点用于分页
     * @private
     */
    _createEmptyNode(pageHeight, pageNum, diff2Ele, node, addedEmptyHeight) {
        const emptyNode = document.createElement("div");
        emptyNode.className = "emptyDiv";
        // emptyNode.style.background = "rgba(255, 0, 0, 0.2)"; // 红色半透明,方便调试
        emptyNode.style.width = "100%";
        emptyNode.style.height = `${pageHeight * (pageNum - 1) - diff2Ele + this.options.margin}px`;

        // 原始高度计算
        let height = pageHeight * (pageNum - 1) - diff2Ele + this.options.margin;
        console.log(`创建空节点(元素: ${node.textContent.slice(0, 20)})`);
        console.log(`- 计算: ${pageHeight}*(${pageNum}-1) - ${diff2Ele} + ${this.options.margin} = ${height}`);

        // 关键修复1:限制空节点最大高度不超过单页高度(避免过高)
        const maxHeight = pageHeight; // 空节点最多占满一页
        height = Math.max(Math.min(height, maxHeight), 0);

        emptyNode.style.height = `${height}px`;

        return emptyNode;

    }

    /**
     * 创建页眉节点
     * @private
     */
    _createPageHeadNode() {
        const pageHead = document.createElement("div");
        pageHead.className = "pageHead";
        // pageHead.style.background = "rgba(125, 199, 122, 0.2)"; // 红色半透明,方便调试
        pageHead.innerHTML = `<h3 style="margin: 0; padding: 0;">${this.pdfFileName}</h3>`;
        return pageHead;
    }

    /**
     * 生成canvas
     * @private
     */
    _generateCanvas() {
        return html2Canvas(this.ele, {
            width: this.scrollWidth,
            height: this.ele.offsetHeight,
            scale: this.options.scale,
            dpi: this.options.dpi,
            useCORS: this.options.useCORS,
            allowTaint: this.options.allowTaint,
            logging: false, // 关闭日志提高性能
        });
    }

    /**
     * 清理预处理添加的DOM元素
     * @private
     */
    _cleanupPreprocessElements() {
        if (!this.splitClassName) return;

        const emptyNodes = this.ele.querySelectorAll('.emptyDiv');
        const headNodes = this.ele.querySelectorAll('.pageHead');

        emptyNodes.forEach(item => item.parentNode?.removeChild(item));
        headNodes.forEach(item => item.parentNode?.removeChild(item));
    }

    /**
     * 从canvas生成PDF
     * @private
     */
    _generatePdfFromCanvas(canvas) {
        return new Promise((resolve) => {
            const contentWidth = canvas.width;
            const contentHeight = canvas.height;
            const pageHeight = (contentWidth / this.A4_WIDTH) * this.A4_HEIGHT;
            const imgWidth = this.A4_WIDTH - this.options.margin;
            const imgHeight = (this.A4_WIDTH / contentWidth) * contentHeight;

            const pageData = canvas.toDataURL('image/jpeg', 1.0);

            const pdf = new JsPDF('p', 'pt', 'a4');
            let restHeight = contentHeight;
            let position = 0;

            if (restHeight < pageHeight) {
                // 单页
                // addImage(pageData, 'JPEG', 左,上,宽度,高度)
                pdf.addImage(pageData, 'JPEG', this.options.margin + 15, this.options.margin, imgWidth, imgHeight);
            } else {
                // 多页
                while (restHeight > 0) {
                    pdf.addImage(pageData, 'JPEG', this.options.margin + 15, position + this.options.margin, imgWidth, imgHeight);
                    restHeight -= pageHeight;
                    position -= this.A4_HEIGHT;

                    if (restHeight > 0) {
                        pdf.addPage();
                    }
                }
            }

            if (this.isPrintPDF) {
                pdf.autoPrint();
                window.open(pdf.output('bloburl'));
            } else {
                // 保存PDF
                pdf.save(`${this.pdfFileName}.pdf`);

            }
            resolve();
        });
    }
}

export default PdfLoader;

四、使用方法(支持多场景)

工具类适配原生 JS、Vue2、Vue3,以下是不同场景的完整使用示例:

场景 1:原生 JS 项目使用

<!-- HTML:目标导出DOM -->
<div id="exportContent">
  <!-- 给需要分页的节点添加 split-class 类名(与工具类参数对应) -->
  <div class="split-class">第一部分内容(会自动分页)</div>
  <div class="split-class">第二部分内容(跨页时自动拆分)</div>
  <img src="https://example.com/image.jpg" alt="示例图片"> <!-- 支持跨域图片 -->
</div>

<!-- 导出按钮 -->
<button onclick="exportToPdf()">下载PDF</button>
<button onclick="printPdf()">打印PDF</button>

<!-- JS:引入并使用工具类 -->
<script type="module">
import PdfLoader from './utils/PdfLoader.js';
windowWidth: 1920,//窗口大小
// 下载PDF
function exportToPdf() {
  // 1. 获取目标DOM
  const exportEle = document.getElementById('exportContent');
  // 2. 创建工具类实例(下载模式:isPrintPDF=false)
  const pdfLoader = new PdfLoader(
    false, // isPrintPDF:false=下载,true=打印
    exportEle, // 目标DOM
    '测试文档', // PDF文件名
    'split-class', // 分页标记类名
    exportEle.scrollWidth, // 滚动宽度(可选)
    { margin: 30, scale: 3 } // 扩展配置(可选)
  );
  // 3. 执行导出
  pdfLoader.outPutPdfFn().then(() => {
    console.log('PDF下载成功');
  }).catch(err => {
    console.error('PDF下载失败:', err);
  });
}

// 打印PDF(无需下载,直接唤起打印窗口)
function printPdf() {
  const exportEle = document.getElementById('exportContent');
  const pdfLoader = new PdfLoader(
    true, // isPrintPDF:true=打印模式
    exportEle,
    '测试文档',
    'split-class',
    windowWidth - 165
    //此处的值应为(实际需要打印的内容宽度 + 打印时的边距宽度),
    //窗口宽度 - 侧边栏 - 外边距 - 空白区域宽度 = 实际需要打印的内容宽度
    // 165是动态的,可以根据需要调整,测试打印
  );
  pdfLoader.outPutPdfFn();
}
</script>

场景 2:Vue2 项目使用

<template>
  <div>
    <!-- 目标导出DOM:给分页节点添加 split-class 类名 -->
    <div ref="exportContent" class="export-content">
      <div class="split-class">Vue2导出测试 - 第一部分</div>
      <div class="split-class">Vue2导出测试 - 第二部分</div>
      <div class="split-class">Vue2导出测试 - 第三部分(长内容自动分页)</div>
      <img src="https://example.com/chart.png" alt="报表图片">
    </div>

    <!-- 操作按钮 -->
    <button @click="handleDownloadPdf">下载PDF</button>
    <button @click="handlePrintPdf">打印PDF</button>
  </div>
</template>

<script>
import PdfLoader from '@/utils/PdfLoader'; // 引入工具类

export default {
  name: 'PdfExportDemo',
  data(){
    return:{
     windowWidth: 1920,//窗口大小
    }
  },
  methods: {
    // 下载PDF
    async handleDownloadPdf() {
      try {
        const exportEle = this.$refs.exportContent; // 获取目标DOM
        const pdfLoader = new PdfLoader(
          false, // 下载模式
          exportEle,
          'Vue2报表文档', // 文件名
          'split-class', // 分页类名
          null,
          {
            scale: 2.5, // 缩放比例(越高越清晰)
            margin: 25, // 页边距
            useCORS: true // 允许跨域图片
          }
        );
        await pdfLoader.outPutPdfFn();
        this.$message.success('PDF下载成功');
      } catch (err) {
        this.$message.error('PDF下载失败');
        console.error(err);
      }
    },

    // 打印PDF
    handlePrintPdf() {
      const exportEle = this.$refs.exportContent;
      try{
      const pdfLoader = new PdfLoader(
        true, // 打印模式
        exportEle,
        'Vue2报表文档',
        'split-class',
        this.windowWidth-165,
        //此处的值应为(实际需要打印的内容宽度 + 打印时的边距宽度),
        //窗口宽度 - 侧边栏 - 外边距 - 空白区域宽度 = 实际需要打印的内容宽度
        // 165是动态的,可以根据需要调整,测试打印
      );
      pdf.outPutPdfFn().then(() => {
          console.log('PDF生成完毕')
       })
       }catch (error) {
        console.log('导出PDF失败:', error)
        ElMessage.error('导出PDF失败!')
        // 这里可以添加错误提示
      }
    }
  }
};
</script>

<style scoped>
.export-content {
  width: 100%;
  padding: 20px;
}
.split-class {
  margin: 20px 0;
  padding: 10px;
}
</style>

场景 3:Vue3 项目使用

<template>
  <div>
    <div ref="exportContent">
      <div class="split-class">Vue3导出测试 - 产品清单</div>
      <div class="split-class">产品1:XXX(自动分页)</div>
      <div class="split-class">产品2:XXX(跨页不断裂)</div>
    </div>

    <button @click="downloadPdf">下载PDF</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import PdfLoader from '@/utils/PdfLoader';
const windowWidth = ref(1920)

// 获取目标DOM引用
const exportContent = ref(null);

// 下载PDF
const downloadPdf = async () => {
  if (!exportContent.value) return;
  
  try {
    const pdfLoader = new PdfLoader(
      false, // 下载模式
      exportContent.value,
      'Vue3产品清单',
      'split-class',
      windowWidth.value - 165,
      //此处的值应为(实际需要打印的内容宽度 + 打印时的边距宽度),
      //窗口宽度 - 侧边栏 - 外边距 - 空白区域宽度 = 实际需要打印的内容宽度
      // 165是动态的,可以根据需要调整,测试打印
      null,
      { dpi: 300, margin: 20 }
    );
     pdf.outPutPdfFn().then(() => {
          console.log('PDF生成完毕')
        })
  } catch (err) {
     console.log('导出PDF失败:', error)
     ElMessage.error('导出PDF失败!')
     // 这里可以添加错误提示
  }
};
</script>

五、参数详细说明

工具类构造函数参数(按顺序):

参数名类型是否必传默认值说明
isPrintPDFboolean-导出模式:true= 唤起打印窗口,false= 下载 PDF 文件
eleHTMLElement-要导出为 PDF 的目标 DOM 元素(需通过getElementById/ref等获取)
pdfFileNamestring-导出的 PDF 文件名(无需带.pdf后缀)
splitClassNamestring-分页标记类名:给需要参与分页判断的节点添加该类名,工具类会自动拆分
scrollWidthnumberele.scrollWidth/ele.offsetWidth容器滚动宽度(适配横向滚动的 DOM,如宽报表)
optionsObject见下方默认配置扩展配置项(缩放、DPI、页边距等)

options 扩展配置默认值:

{
  scale: 2, // Canvas缩放比例(建议1-3,越大越清晰但文件越大)
  dpi: 300, // 清晰度(默认300DPI,满足大多数场景)
  margin: 20, // PDF页边距(单位pt,默认20,可调整留白)
  useCORS: true, // 允许跨域图片(解决跨域图片无法导出的问题)
  allowTaint: true // 允许画布污染(兼容部分特殊格式图片)
}

六、高级自定义配置

1. 自定义页眉内容(如添加页码、日期)

修改工具类的_createPageHeadNode方法,示例:

_createPageHeadNode() {
  const pageHead = document.createElement("div");
  pageHead.className = "pageHead";
  // 自定义页眉:文档名 + 页码 + 日期
  const date = new Date().toLocaleDateString();
  pageHead.innerHTML = `
    <div style="display: flex; justify-content: space-between; align-items: center; margin: 10px 0;">
      <h3 style="margin: 0; font-size: 14px; color: #666;">${this.pdfFileName}</h3>
      <span style="font-size: 12px; color: #999;">${date}</span>
    </div>
  `;
  return pageHead;
}

2. 调整分页精度(解决分页不准确问题)

// 原代码
const diff2Ele = node.offsetTop - 150;
// 调整建议:根据实际布局上下微调(如120、180),数值越小空白节点越高,数值越大空白节点越低
const diff2Ele = node.offsetTop - 160; 

3. 调试空白节点位置

为空白节点添加背景色,直观看到分页位置,便于调整:

_createEmptyNode(...) {
  const emptyNode = document.createElement("div");
  emptyNode.className = "emptyDiv";
  emptyNode.style.background = "rgba(255, 0, 0, 0.2)"; // 红色半透明(调试用)
  // ...其他配置
}

4. 适配复杂布局(如表格、图表)

对于表格、ECharts 图表等复杂布局,需注意:

  • 确保目标 DOM 的width为固定值(避免自适应导致宽度计算偏差)
  • 图表需等待渲染完成后再调用导出(如 Vue 中用nextTick
  • 表格建议添加table-layout: fixed样式,避免列宽自适应导致分页断裂

七、注意事项

  1. 跨域图片处理:需确保图片服务器配置Access-Control-Allow-Origin,且工具类options.useCORS=true
  2. 复杂布局适配:复杂布局(如嵌套表格、动态图表)可能需要手动调整splitClassNamediff2Ele参数
  3. 性能优化scaledpi值越大,PDF 越清晰但导出速度越慢、文件体积越大,建议根据需求平衡(默认scale:2足够)
  4. DOM 污染避免:工具类会自动清理导出时添加的临时节点(空白节点 + 页眉),无需手动处理
  5. 浏览器兼容性:支持现代浏览器(Chrome、Firefox、Edge),IE 浏览器需额外兼容(建议放弃 IE 支持)

八、常见问题排查

1. 分页不准确,内容跨页断裂?

  • 给关键节点添加splitClassName(如每个段落、表格行)
  • 调整_preprocessForPagination方法中的150参数
  • 打开空白节点背景色调试,观察分页位置偏差

2. 图片无法导出或显示模糊?

  • 确认图片支持跨域(或配置useCORS: true
  • 提高options.scale值(如 3),同时调整dpi: 300
  • 避免使用base64过大的图片(会导致导出卡顿)

3. 导出的 PDF 文件过大?

  • 降低options.scale值(如 1.5)
  • 图片压缩后再导出(如将图片分辨率调整为 1920px 以内)
  • 关闭不必要的高清配置(如dpi: 200

4. Vue 项目中导出失败,提示ele is undefined

  • 确保通过ref获取 DOM 时,组件已挂载(如在mounted钩子中调用导出方法)
  • 避免在 DOM 未渲染完成时调用(如动态数据渲染后需用nextTick

九、总结

这款 PDF 导出工具类基于html2canvasjspdf封装,解决了原生方案的诸多痛点,支持自动分页、打印 / 下载双模式、自定义页眉等核心功能,适配多项目场景。使用时只需传入目标 DOM、文件名等关键参数,即可快速实现 PDF 导出功能,同时支持灵活的自定义配置,满足不同业务需求。

如果遇到复杂布局适配问题,可根据实际场景调整分页标记类名、空白节点高度等参数,工具类内部保留了详细的调试日志,便于问题排查。欢迎根据自身项目需求扩展功能(如添加水印、加密 PDF、自定义页脚等)!