写在最前面的话----本文中的代码并非全部由我编写,是之前写项目时有这个需求,我在稀土掘金中找到的一篇相关的,但是实际效果并不理想不能实现我要的效果,并且存在分页不全,空白格高度计算有误等几个问题。这个是在原来基础上优化了问题,增加可用性的版本。比较可惜的是我当时没有保存,没能找到原文,有找到原文的可以评论私信,我将原文地址放在最上方。-----刚工作半年的前端开发
在 Web 开发中,我们经常需要将页面指定 DOM 元素导出为 PDF 文件(如报表、合同、数据清单等),但原生jspdf+html2canvas组合存在分页不准确、跨域图片报错、缺乏自定义页眉、打印与下载切换不便等问题。为此,我封装了一款通用 PDF 导出工具类,完美解决以上痛点,支持自动分页、打印 / 下载双模式、自定义页眉、灵活配置等核心功能,适配 Vue2/Vue3 / 原生 JS 项目,下面详细介绍其功能与使用方法。
一、工具类核心优势
基于html2canvas(DOM 转 Canvas)和jspdf(Canvas 转 PDF)开发,在原生能力基础上做了深度优化,核心优势如下:
- 自动智能分页:支持通过自定义类名标记分页节点,自动计算内容高度拆分 PDF 页面,解决长内容跨页断裂问题
- 双模式导出:支持直接下载 PDF 文件,或唤起浏览器打印窗口(无需下载直接打印)
- 自定义配置:可灵活配置缩放比例、DPI、页边距、跨域图片支持等参数
- 自定义页眉:支持在分页处添加自定义页眉(如文档标题、页码等)
- 无冗余残留:导出后自动清理分页时添加的空白节点和页眉节点,不污染原 DOM
- 多项目适配:兼容原生 JS、Vue2、Vue3 等多种项目场景,无需额外修改
- 调试友好:支持通过背景色显示空白分页节点,方便手动调整分页精度
二、前置依赖安装
工具类基于html2canvas和jspdf实现,使用前需先安装依赖:
# 安装核心依赖
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>
五、参数详细说明
工具类构造函数参数(按顺序):
| 参数名 | 类型 | 是否必传 | 默认值 | 说明 |
|---|---|---|---|---|
| isPrintPDF | boolean | 是 | - | 导出模式:true= 唤起打印窗口,false= 下载 PDF 文件 |
| ele | HTMLElement | 是 | - | 要导出为 PDF 的目标 DOM 元素(需通过getElementById/ref等获取) |
| pdfFileName | string | 是 | - | 导出的 PDF 文件名(无需带.pdf后缀) |
| splitClassName | string | 否 | - | 分页标记类名:给需要参与分页判断的节点添加该类名,工具类会自动拆分 |
| scrollWidth | number | 否 | ele.scrollWidth/ele.offsetWidth | 容器滚动宽度(适配横向滚动的 DOM,如宽报表) |
| options | Object | 否 | 见下方默认配置 | 扩展配置项(缩放、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样式,避免列宽自适应导致分页断裂
七、注意事项
- 跨域图片处理:需确保图片服务器配置
Access-Control-Allow-Origin,且工具类options.useCORS=true - 复杂布局适配:复杂布局(如嵌套表格、动态图表)可能需要手动调整
splitClassName或diff2Ele参数 - 性能优化:
scale和dpi值越大,PDF 越清晰但导出速度越慢、文件体积越大,建议根据需求平衡(默认scale:2足够) - DOM 污染避免:工具类会自动清理导出时添加的临时节点(空白节点 + 页眉),无需手动处理
- 浏览器兼容性:支持现代浏览器(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 导出工具类基于html2canvas和jspdf封装,解决了原生方案的诸多痛点,支持自动分页、打印 / 下载双模式、自定义页眉等核心功能,适配多项目场景。使用时只需传入目标 DOM、文件名等关键参数,即可快速实现 PDF 导出功能,同时支持灵活的自定义配置,满足不同业务需求。
如果遇到复杂布局适配问题,可根据实际场景调整分页标记类名、空白节点高度等参数,工具类内部保留了详细的调试日志,便于问题排查。欢迎根据自身项目需求扩展功能(如添加水印、加密 PDF、自定义页脚等)!