文章目录
前言
笔者最近遇到一个需求,要把前端渲染出来的页面完整的导出为PDF格式,最开始的方案是想在服务端导出,使用Freemarker或者Thymeleaf模板引擎,但是页面实在是有点复杂,开发起来比较费劲,最终还是寻找前端导出PDF的方案。其实前端导出反而更好,可以减轻服务器端的压力,导出来的样式也更好看,给各位看下,笔者要导出的页面,内容还是挺多的吧。

一、导出工具类
下面直接展示PDF导出工具类
typescript
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
export default {
/**
* 将HTML元素导出为PDF
* @param element 需要导出的DOM元素
* @param fileName 导出的文件名
*/
async exportElementToPdf(element: HTMLElement, fileName: string = 'document'): Promise<void> {
if (!element) {
console.error('导出元素不能为空');
return;
}
try {
// 处理textarea元素,临时替换为div以确保内容完整显示
const textareas = Array.from(element.querySelectorAll('textarea'));
const originalStyles: { [key: string]: string } = {};
const replacedElements: HTMLElement[] = [];
// 处理滚动区域
const scrollElements = element.querySelectorAll('[style*="overflow"],[style*="height"]');
const originalScrollStyles: { [key: string]: string } = {};
scrollElements.forEach((el, index) => {
const computedStyle = window.getComputedStyle(el);
if (computedStyle.overflow === 'auto' || computedStyle.overflow === 'scroll' ||
computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll') {
originalScrollStyles[index] = (el as HTMLElement).style.cssText;
(el as HTMLElement).style.overflow = 'visible';
(el as HTMLElement).style.maxHeight = 'none';
(el as HTMLElement).style.height = 'auto';
}
});
// 替换所有textarea为div,保留内容和样式
textareas.forEach((textarea, index) => {
// 保存原始样式
originalStyles[index] = textarea.style.cssText;
// 创建替代元素
const replacementDiv = document.createElement('div');
replacementDiv.innerHTML = textarea.value.replace(/\n/g, '<br>');
replacementDiv.style.cssText = textarea.style.cssText;
replacementDiv.style.height = 'auto'; // 确保高度自适应内容
replacementDiv.style.minHeight = window.getComputedStyle(textarea).height;
replacementDiv.style.border = window.getComputedStyle(textarea).border;
replacementDiv.style.padding = window.getComputedStyle(textarea).padding;
replacementDiv.style.boxSizing = 'border-box';
replacementDiv.style.whiteSpace = 'pre-wrap';
replacementDiv.style.overflowY = 'visible';
// 替换元素
textarea.parentNode?.insertBefore(replacementDiv, textarea);
textarea.style.display = 'none';
replacedElements.push(replacementDiv);
});
// 预加载所有图片的增强方法
const preloadImages = async () => {
// 查找所有图片元素
const images = Array.from(element.querySelectorAll('img'));
// 记录原始的src属性
const originalSrcs = images.map(img => img.src);
// 确保所有图片都完全加载
await Promise.all(
images.map((img, index) => {
return new Promise<void>((resolve) => {
// 如果图片已经完成加载,直接解析
if (img.complete && img.naturalHeight !== 0) {
resolve();
return;
}
// 为每个图片添加加载和错误事件监听器
const onLoad = () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
resolve();
};
const onError = () => {
console.warn(`无法加载图片: ${img.src}`);
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
// 尝试重新加载图片
const newImg = new Image();
newImg.crossOrigin = "Anonymous";
newImg.onload = () => {
img.src = originalSrcs[index]; // 恢复原始src
resolve();
};
newImg.onerror = () => {
img.src = originalSrcs[index]; // 恢复原始src
resolve(); // 即使失败也继续执行
};
// 强制重新加载
const src = img.src;
img.src = '';
setTimeout(() => {
newImg.src = src;
}, 100);
};
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
// 如果图片没有src或src是数据URL,直接解析
if (!img.src || img.src.startsWith('data:')) {
resolve();
}
});
})
);
};
// 预加载所有图片
await preloadImages();
// 使用html2canvas将整个元素转为单个canvas
const canvas = await html2canvas(element, {
scale: 2, // 提高清晰度
useCORS: true, // 允许加载跨域图片
logging: false,
allowTaint: true, // 允许污染画布
backgroundColor: '#ffffff', // 设置背景色为白色
imageTimeout: 15000, // 增加图片加载超时时间到15秒
onclone: (documentClone) => {
// 在克隆的文档中查找所有图片
const clonedImages = documentClone.querySelectorAll('img');
// 确保所有图片都设置了crossOrigin属性
clonedImages.forEach(img => {
img.crossOrigin = "Anonymous";
// 对于数据URL的图片跳过
if (img.src && !img.src.startsWith('data:')) {
// 添加时间戳以避免缓存问题
if (img.src.indexOf('?') === -1) {
img.src = `${img.src}?t=${new Date().getTime()}`;
} else {
img.src = `${img.src}&t=${new Date().getTime()}`;
}
}
});
return documentClone;
}
});
// 恢复原始DOM,移除临时添加的元素
textareas.forEach((textarea, index) => {
textarea.style.cssText = originalStyles[index];
textarea.style.display = '';
if (replacedElements[index] && replacedElements[index].parentNode) {
replacedElements[index].parentNode.removeChild(replacedElements[index]);
}
});
// 恢复滚动区域的样式
scrollElements.forEach((el, index) => {
if (originalScrollStyles[index]) {
(el as HTMLElement).style.cssText = originalScrollStyles[index];
}
});
// 创建PDF(使用适合内容的尺寸)
// 如果内容宽高比接近A4,使用A4;否则使用自定义尺寸
const imgWidth = 210; // A4宽度(mm)
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 使用一页完整显示内容,不强制分页
const pdf = new jsPDF({
orientation: imgHeight > 297 ? 'p' : 'p', // 如果内容高度超过A4高度,使用纵向
unit: 'mm',
format: imgHeight > 297 ? [imgWidth, imgHeight] : 'a4' // 如果内容高度超过A4高度,使用自定义尺寸
});
// 添加图像到PDF,确保填满页面但保持比例
pdf.addImage(
canvas.toDataURL('image/jpeg', 1.0), // 使用高质量
'JPEG',
0,
0,
imgWidth,
imgHeight
);
// 保存PDF
pdf.save(`${fileName}.pdf`);
} catch (error) {
console.error('导出PDF时发生错误:', error);
}
}
};
这个 工具类考虑了导出的html页面中的图片和text滚动文本框,使得导出来的PDF文件能够完整展示原HTML页面内容,基本能做到95%以上的还原吧,导出的格式是A4纸张大小,方便打印出来。
二、单页面详情导出
比如说我现在有个页面叫detail.vue,页面模板部分如下
xml
<template >
<div
class="reports-detail-page"
v-if="reportDetail"
ref="weekReportRef"
>
<img
src="/icon/read.png"
class="read-mark"
:class="{
'read-mark-mobile': mainStates.isMobile,
}"
alt="已审批"
v-if="reportDetail.weekReports.status === 1"
/>
<el-button class="export-pdf" type="primary" v-if="!isImporting" size="small" @click="downloadPdf">导出PDF</el-button>
<week-report
:is-plan="false"
v-if="reportDetail.lastWeekReports"
:week-report="reportDetail.lastWeekReports"
:self-comments="reportDetail.weekReportsSelfCommentsList"
/>
<week-report
:is-plan="true"
:week-report="reportDetail.weekReports"
:self-comments="reportDetail.weekReportsSelfCommentsList"
/>
<comment-area :is-importing="isImporting" :report-detail="reportDetail" />
</div>
</template>
这里的关键属性是ref="weekReportRef",其声明定义如下:
typescript
const weekReportRef = ref<HTMLElement | null>(null);
在Vue 3中,ref
是一个非常重要的响应式API,它有两种主要用途:
- 在脚本中创建响应式变量 :通过
ref()
函数创建一个响应式引用 - 在模板中引用DOM元素或组件实例 :通过在模板元素上添加
ref
属性
这里主要是利用了第二点,代表了当前组件的渲染实例,导出PDF按钮对应的方法如下:
typescript
// 下载PDF
const downloadPdf = async () => {
if (!weekReportRef.value) return;
isImporting.value = true;
// 创建文件名,例如:张三_2025年第28周_总结
const fileName = `${reportDetail.value?.weekReports.userName}${weekDesc.value}周报`;
ElLoading.service({
lock: true,
text: '正在导出PDF,请稍后...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)',
});
try {
// 使用nextTick等待DOM更新完成
await nextTick();
await PdfExportUtils.exportElementToPdf(weekReportRef.value, fileName).then(()=>{
isImporting.value = false;
ElLoading.service().close();
});
} catch (error) {
console.error('导出PDF失败', error);
}
};
通过以上代码,可以看到在调用导出PDF时,传入了当前的组件的实例,其中isImporting这个属性,是笔者为了限制某些按钮什么的控件不要在导出后的PDF文件中显示而添加的临时属性。
三、列表页批量压缩导出
上面说的是单页面导出PDF,那如果有个列表页,需要批量选择然后导出怎么办?导出过程中,又没办法一个个点进去等待数据渲染。前辈大佬早就想到了这个场景,我们可以利用html中的标签iframe,在批量选择导出时,为每一个列表数据临时创建一个渲染后的详情页面数据,即Dom中的Dom,然后对嵌套页面导出压缩,当然我们用户自己是感知不到的。比如下面的列表:

以下代码是针对勾选数据的定义和响应式绑定
typescript
const selectedRows = ref<WeekReportsDetail[]>([]);
// 处理表格选择变化
const handleSelectionChange = (selection: WeekReportsDetail[]) => {
selectedRows.value = selection;
};
批量导出压缩PDF文件的代码如下,比较复杂,仅供参考:
typescript
// 导出选中项到PDF并压缩
const exportSelectedToPdf = async () => {
if (selectedRows.value.length === 0) {
ElNotification({
title: '提示',
message: '请先选择要导出的周报',
type: 'warning',
});
return;
}
// 显示加载中提示
const loading = ElLoading.service({
lock: true,
text: `正在准备导出...`,
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)',
});
try {
// 创建ZIP实例
const zip = new JSZip();
const allPdfResults: { fileName: string, pdfBlob: Blob }[] = [];
// 定义批处理大小和函数
const batchSize = 5; // 每批处理的数量,可以根据实际情况调整
// 批量处理函数
const processBatch = async (batchReports: WeekReportsDetail[]) => {
const batchPromises = batchReports.map((report) => {
return new Promise<{fileName: string, pdfBlob: Blob}>(async (resolve, reject) => {
try {
const overall = selectedRows.value.indexOf(report) + 1;
loading.setText(`正在导出第 ${overall}/${selectedRows.value.length} 个周报...`);
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.left = '0';
iframe.style.top = '0';
iframe.style.width = '1024px';
iframe.style.height = '768px';
iframe.style.border = 'none';
iframe.style.zIndex = '-1';
iframe.style.opacity = '0.01'; // 几乎不可见但会渲染
// 加载详情页面的URL
iframe.src = `${window.location.origin}/center/detail/${report.id}?corpId=${mainStates.corpId}&isImporting=true`;
document.body.appendChild(iframe);
// 使用Promise包装iframe加载和处理
let retryCount = 0;
const maxRetries = 2;
while (retryCount <= maxRetries) {
try {
await new Promise<void>((resolveIframe, rejectIframe) => {
// 设置超时
const timeoutId = setTimeout(() => {
rejectIframe(new Error('加载超时'));
}, 15000); // 15秒超时
iframe.onload = async () => {
clearTimeout(timeoutId);
try {
// 给页面充分的时间加载数据和渲染
await new Promise(r => setTimeout(r, 3000));
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDocument) {
resolveIframe();
return;
}
const reportElement = iframeDocument.querySelector('.reports-detail-page');
if (!reportElement) {
resolveIframe();
return;
}
// 处理iframe中的所有textarea和滚动区域
const iframeTextareas = Array.from(reportElement.querySelectorAll('textarea'));
const replacedElements: HTMLElement[] = [];
// 替换所有textarea为div
iframeTextareas.forEach((textarea) => {
const replacementDiv = document.createElement('div');
replacementDiv.innerHTML = textarea.value.replace(/\n/g, '<br>');
replacementDiv.style.cssText = textarea.style.cssText;
replacementDiv.style.height = 'auto';
replacementDiv.style.minHeight = window.getComputedStyle(textarea).height;
replacementDiv.style.boxSizing = 'border-box';
replacementDiv.style.whiteSpace = 'pre-wrap';
replacementDiv.style.overflowY = 'visible';
textarea.parentNode?.insertBefore(replacementDiv, textarea);
textarea.style.display = 'none';
replacedElements.push(replacementDiv);
});
// 处理滚动区域
const scrollElements = reportElement.querySelectorAll('[style*="overflow"],[style*="height"]');
scrollElements.forEach((el) => {
const computedStyle = window.getComputedStyle(el);
if (computedStyle.overflow === 'auto' || computedStyle.overflow === 'scroll' ||
computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll') {
(el as HTMLElement).style.overflow = 'visible';
(el as HTMLElement).style.maxHeight = 'none';
(el as HTMLElement).style.height = 'auto';
}
});
// 预加载所有图片
const images = Array.from(reportElement.querySelectorAll('img'));
await Promise.all(
images.map(img => {
return new Promise<void>((resolveImg) => {
if (img.complete && img.naturalHeight !== 0) {
resolveImg();
return;
}
const onLoad = () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
resolveImg();
};
const onError = () => {
console.warn(`无法加载图片: ${img.src}`);
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
resolveImg();
};
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
// 如果图片没有src或src是数据URL,直接解析
if (!img.src || img.src.startsWith('data:')) {
resolveImg();
} else {
// 添加时间戳以避免缓存问题
const currentSrc = img.src;
img.src = '';
setTimeout(() => {
if (currentSrc.indexOf('?') === -1) {
img.src = `${currentSrc}?t=${new Date().getTime()}`;
} else {
img.src = `${currentSrc}&t=${new Date().getTime()}`;
}
}, 50);
}
});
})
);
// 等待额外时间确保渲染完成
await new Promise(r => setTimeout(r, 1000));
// 创建周报文件名
const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
weekIndex: report.weekIndex,
yearIndex: report.year,
});
const fileName = `${report.userName}_${weekDesc}周报.pdf`;
// 使用html2canvas转换为canvas
const canvas = await html2canvas(reportElement as HTMLElement, {
scale: 2,
useCORS: true,
logging: false,
allowTaint: true,
backgroundColor: '#ffffff',
imageTimeout: 15000, // 增加超时时间
});
// 从canvas创建PDF
const imgWidth = 210; // A4宽度(mm)
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const pdf = new jsPDF({
orientation: imgHeight > 297 ? 'p' : 'p',
unit: 'mm',
format: imgHeight > 297 ? [imgWidth, imgHeight] : 'a4',
});
pdf.addImage(
canvas.toDataURL('image/jpeg', 1.0),
'JPEG',
0,
0,
imgWidth,
imgHeight,
);
// 获取PDF的Blob
const pdfBlob = pdf.output('blob');
// 恢复iframe中的DOM
iframeTextareas.forEach((textarea, index) => {
textarea.style.display = '';
if (replacedElements[index] && replacedElements[index].parentNode) {
replacedElements[index].parentNode.removeChild(replacedElements[index]);
}
});
// 解析PDF处理结果
resolveIframe();
// 直接添加到ZIP
zip.file(fileName, pdfBlob);
resolve({ fileName, pdfBlob });
} catch (error) {
console.error('处理PDF时出错:', error);
rejectIframe(error);
}
};
iframe.onerror = () => {
clearTimeout(timeoutId);
rejectIframe(new Error('iframe加载失败'));
};
});
// 如果成功处理了,跳出重试循环
break;
} catch (error) {
retryCount++;
console.warn(`处理PDF失败,正在重试(${retryCount}/${maxRetries})...`, error);
// 如果已经达到最大重试次数,则放弃这个报告
if (retryCount > maxRetries) {
console.error(`无法处理周报 ${report.id},已达到最大重试次数`);
// 创建一个空白PDF表示失败
const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
weekIndex: report.weekIndex,
yearIndex: report.year,
});
const fileName = `${report.userName}_${weekDesc}周报(处理失败).pdf`;
// 创建一个简单的错误PDF
const pdf = new jsPDF();
pdf.setFontSize(16);
pdf.text('处理此周报时出错', 20, 20);
pdf.setFontSize(12);
pdf.text(`用户: ${report.userName}`, 20, 40);
pdf.text(`周报ID: ${report.id}`, 20, 50);
pdf.text(`时间: ${weekDesc}`, 20, 60);
pdf.text(`错误信息: ${error || '未知错误'}`, 20, 70);
const errorPdfBlob = pdf.output('blob');
zip.file(fileName, errorPdfBlob);
resolve({ fileName, pdfBlob: errorPdfBlob });
break;
}
// 等待一段时间再重试
await new Promise(r => setTimeout(r, 2000));
}
}
// 移除iframe
if (document.body.contains(iframe)) {
document.body.removeChild(iframe);
}
} catch (error) {
console.error('PDF生成失败:', error);
reject(error);
}
});
});
// 处理当前批次
return await Promise.allSettled(batchPromises);
};
// 将报告分成多个批次
const reportBatches: WeekReportsDetail[][] = [];
for (let i = 0; i < selectedRows.value.length; i += batchSize) {
reportBatches.push(selectedRows.value.slice(i, i + batchSize));
}
// 逐批处理
for (let i = 0; i < reportBatches.length; i++) {
loading.setText(`正在处理第 ${i+1}/${reportBatches.length} 批周报...`);
const batchResults = await processBatch(reportBatches[i]);
// 将结果添加到总结果中
batchResults.forEach(result => {
if (result.status === 'fulfilled') {
allPdfResults.push(result.value);
}
});
// 释放一些内存
await new Promise(r => setTimeout(r, 500));
}
// 生成ZIP文件
loading.setText('正在生成ZIP文件...');
// 生成并下载ZIP文件
const zipBlob = await zip.generateAsync({type: 'blob'});
const zipUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = zipUrl;
link.download = `周报汇总_${new Date().getTime()}.zip`;
link.click();
URL.revokeObjectURL(zipUrl);
ElNotification({
title: '导出成功',
message: `已将${allPdfResults.length}个周报导出为ZIP压缩文件`,
type: 'success',
});
} catch (error) {
console.error('导出PDF时发生错误:', error);
ElNotification({
title: '导出失败',
message: '导出PDF时发生错误,请稍后再试',
type: 'error',
});
} finally {
loading.close();
}
};
执行流程与关键步骤
- 前置校验与初始化
- 选中项校验:首先检查 selectedRows(选中的周报数组)是否为空,若为空则通过 ElNotification 显示警告提示("请先选择要导出的周报"),直接终止流程。
- 加载提示初始化:通过 ElLoading.service 创建全屏加载提示,显示 "正在准备导出...",锁定页面交互以避免重复操作。
- 批量处理机制
为避免一次性处理过多数据导致浏览器性能问题,采用分批处理策略:
- 批处理配置:定义 batchSize = 5(每批处理 5 个周报,可按需调整),将选中的周报数组拆分为多个批次(reportBatches)。
- 逐批处理:通过循环逐个处理每个批次,每批处理完成后等待 500ms 释放内存,降低浏览器资源占用。
- 单批周报处理(核心逻辑)
每批周报通过 processBatch 函数处理,单个周报的转换流程如下:
- 创建隐藏 iframe:动态生成一个不可见的 iframe(定位在页面外,透明度 0.01),用于加载周报详情页(/center/detail/${report.id})。iframe 的作用是隔离详情页环境,避免直接操作当前页面 DOM 导致冲突。
- iframe 加载与重试机制:
- 为 iframe 设置 15 秒超时时间,若加载失败则重试(最多重试 2 次),避免因网络或资源加载问题导致单个周报处理失败。
- 加载完成后等待 3 秒,确保详情页数据和样式完全渲染。
- DOM 预处理(确保 PDF 内容完整):
- 替换 textarea:将详情页中的 textarea 替换为 div(保留原样式),因为 textarea 的滚动特性可能导致内容截断,替换后可完整显示所有文本。
- 处理滚动区域:将带有 overflow: auto/scroll 或固定高度的元素改为 overflow: visible 且 maxHeight: none,确保内容不被容器截断。
- 图片预加载:遍历详情页中的所有图片,等待图片加载完成(或超时 / 错误)后再继续,避免 PDF 中出现图片缺失。通过添加时间戳(?t=${time})避免缓存影响。
- 转换为 PDF:
- 用 html2canvas 将预处理后的详情页元素(.reports-detail-page)转换为 canvas(scale: 2 提高清晰度)。
- 用 jsPDF 将 canvas 转为 PDF,设置 A4 尺寸(或自适应内容高度),输出为 Blob 格式。
异常处理:若多次重试后仍失败,生成一个 "错误 PDF"(包含失败原因、周报 ID 等信息),避免单个失败阻断整个批次。
- 压缩与下载
- ZIP 打包:所有 PDF 处理完成后,通过 JSZip 将所有 PDF Blob 打包为一个 ZIP 文件,文件名格式为 "周报汇总_时间戳.zip"。
- 触发下载:将 ZIP 文件转换为 Blob URL,通过动态创建 标签触发浏览器下载,下载完成后释放 URL 资源。
- 结果反馈与资源清理
- 成功反馈:若全部处理完成,通过 ElNotification 显示成功提示("已将 X 个周报导出为 ZIP 压缩文件")。
- 异常反馈:若过程中出现未捕获的错误,显示错误提示("导出失败,请稍后再试")。
- 资源清理:无论成功或失败,最终通过 loading.close() 关闭加载提示,释放页面锁定。
核心步骤就是iframe,动态生成一个不可见的 iframe(定位在页面外,透明度 0.01),用于加载周报详情页(/center/detail/${report.id}),另外为什么采用批处理,不一次并发执行呢?因为一次执行过多,渲染太多子页面,超出浏览器承受范围会报错。
四、总结
综上,前端导出 PDF 方案通过 html2canvas 与 jsPDF 组合,结合 DOM 预处理解决了复杂页面的完整还原问题。单页导出利用 Vue 的 ref 获取 DOM 元素直接转换,批量导出则借助 iframe 隔离渲染环境并配合 JSZip 压缩,既减轻了服务端压力,又保证了导出效果。实际应用中可根据页面复杂度调整预处理逻辑与批处理参数,平衡导出效率与准确性。