当前位置: 首页 > news >正文

Vue前端导出页面为PDF文件

文章目录

  • 前言
  • 一、导出工具类
  • 二、单页面详情导出
  • 三、列表页批量压缩导出
  • 四、总结


前言

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

在这里插入图片描述


一、导出工具类

下面直接展示PDF导出工具类

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]; // 恢复原始srcresolve();};newImg.onerror = () => {img.src = originalSrcs[index]; // 恢复原始srcresolve(); // 即使失败也继续执行};// 强制重新加载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将整个元素转为单个canvasconst 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);// 保存PDFpdf.save(`${fileName}.pdf`);} catch (error) {console.error('导出PDF时发生错误:', error);}}
};

这个 工具类考虑了导出的html页面中的图片和text滚动文本框,使得导出来的PDF文件能够完整展示原HTML页面内容,基本能做到95%以上的还原吧,导出的格式是A4纸张大小,方便打印出来。

二、单页面详情导出

比如说我现在有个页面叫detail.vue,页面模板部分如下

<template ><divclass="reports-detail-page"v-if="reportDetail"ref="weekReportRef"><imgsrc="/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”,其声明定义如下:

const weekReportRef = ref<HTMLElement | null>(null);

在Vue 3中,ref是一个非常重要的响应式API,它有两种主要用途:

  1. 在脚本中创建响应式变量:通过ref()函数创建一个响应式引用
  2. 在模板中引用DOM元素或组件实例:通过在模板元素上添加ref属性

这里主要是利用了第二点,代表了当前组件的渲染实例,导出PDF按钮对应的方法如下:

// 下载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,然后对嵌套页面导出压缩,当然我们用户自己是感知不到的。比如下面的列表:

在这里插入图片描述
以下代码是针对勾选数据的定义和响应式绑定

const selectedRows = ref<WeekReportsDetail[]>([]);
// 处理表格选择变化
const handleSelectionChange = (selection: WeekReportsDetail[]) => {selectedRows.value = selection;
};

批量导出压缩PDF文件的代码如下,比较复杂,仅供参考:

// 导出选中项到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'; // 几乎不可见但会渲染// 加载详情页面的URLiframe.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为diviframeTextareas.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转换为canvasconst canvas = await html2canvas(reportElement as HTMLElement, {scale: 2,useCORS: true,logging: false,allowTaint: true,backgroundColor: '#ffffff',imageTimeout: 15000, // 增加超时时间});// 从canvas创建PDFconst 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的Blobconst pdfBlob = pdf.output('blob');// 恢复iframe中的DOMiframeTextareas.forEach((textarea, index) => {textarea.style.display = '';if (replacedElements[index] && replacedElements[index].parentNode) {replacedElements[index].parentNode.removeChild(replacedElements[index]);}});// 解析PDF处理结果resolveIframe();// 直接添加到ZIPzip.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`;// 创建一个简单的错误PDFconst 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));}}// 移除iframeif (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();}
};

执行流程与关键步骤

  1. 前置校验与初始化
  • 选中项校验:首先检查 selectedRows(选中的周报数组)是否为空,若为空则通过 ElNotification 显示警告提示(“请先选择要导出的周报”),直接终止流程。
  • 加载提示初始化:通过 ElLoading.service 创建全屏加载提示,显示 “正在准备导出…”,锁定页面交互以避免重复操作。
  1. 批量处理机制
    为避免一次性处理过多数据导致浏览器性能问题,采用分批处理策略:
  • 批处理配置:定义 batchSize = 5(每批处理 5 个周报,可按需调整),将选中的周报数组拆分为多个批次(reportBatches)。
  • 逐批处理:通过循环逐个处理每个批次,每批处理完成后等待 500ms 释放内存,降低浏览器资源占用。
  1. 单批周报处理(核心逻辑)
    每批周报通过 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 等信息),避免单个失败阻断整个批次。
  1. 压缩与下载
  • ZIP 打包:所有 PDF 处理完成后,通过 JSZip 将所有 PDF Blob 打包为一个 ZIP 文件,文件名格式为 “周报汇总_时间戳.zip”。
  • 触发下载:将 ZIP 文件转换为 Blob URL,通过动态创建 标签触发浏览器下载,下载完成后释放 URL 资源。
  1. 结果反馈与资源清理
  • 成功反馈:若全部处理完成,通过 ElNotification 显示成功提示(“已将 X 个周报导出为 ZIP 压缩文件”)。
  • 异常反馈:若过程中出现未捕获的错误,显示错误提示(“导出失败,请稍后再试”)。
  • 资源清理:无论成功或失败,最终通过 loading.close() 关闭加载提示,释放页面锁定。

核心步骤就是iframe,动态生成一个不可见的 iframe(定位在页面外,透明度 0.01),用于加载周报详情页(/center/detail/${report.id}),另外为什么采用批处理,不一次并发执行呢?因为一次执行过多,渲染太多子页面,超出浏览器承受范围会报错。


四、总结

综上,前端导出 PDF 方案通过 html2canvas 与 jsPDF 组合,结合 DOM 预处理解决了复杂页面的完整还原问题。单页导出利用 Vue 的 ref 获取 DOM 元素直接转换,批量导出则借助 iframe 隔离渲染环境并配合 JSZip 压缩,既减轻了服务端压力,又保证了导出效果。实际应用中可根据页面复杂度调整预处理逻辑与批处理参数,平衡导出效率与准确性。

http://www.dtcms.com/a/272480.html

相关文章:

  • 【HDLBits习题 2】Circuit - Sequential Logic(4)More Circuits
  • AI驱动的业务系统智能化转型:从静态配置到动态认知的范式革命
  • 基础 IO
  • Spring Boot中的中介者模式:终结对象交互的“蜘蛛网”困境
  • JAVA JVM的内存区域划分
  • Redis的常用命令及`SETNX`实现分布式锁、幂等操作
  • Redis Stack扩展功能
  • K8S数据流核心底层逻辑剖析
  • AI进化论06:连接主义的复兴——神经网络的“蛰伏”与“萌动”
  • k8s集群--证书延期
  • 项目进度管控依赖Excel,如何提升数字化能力
  • 调度器与闲逛进程详解,(操作系统OS)
  • UI前端与数字孪生结合案例分享:智慧城市的智慧能源管理系统
  • 数据结构笔记10:排序算法
  • Windows 本地 使用mkcert 配置HTTPS 自签名证书
  • Java并发 - 阻塞队列详解
  • XSS(ctfshow)
  • 文心大模型4.5开源测评:保姆级部署教程+多维度测试验证
  • 图书管理系统(完结版)
  • PyCharm 中 Python 解释器的添加选项及作用
  • 创始人IP如何进阶?三次关键突破实现高效转化
  • QT解析文本框数据——详解
  • pycharm中自动补全方法返回变量
  • 自动化脚本配置网络IP、主机名、网段
  • React封装过哪些组件-下拉选择器和弹窗表单
  • 常用的.gitconfig 配置
  • 【显示模块】嵌入式显示与触摸屏技术理论
  • HarmonyOS AI辅助编程工具(CodeGenie)UI生成
  • 时序数据库的存储之道:从数据特性看技术要点
  • 使用深度学习框架yolov8训练监控视角下非机动车电动车头盔佩戴检测数据集VOC+YOLO格式11999张4类别步骤和流程