> 技术文档 > Vue前端导出页面为PDF文件_前端生成pdf

Vue前端导出页面为PDF文件_前端生成pdf


文章目录

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

前言

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

Vue前端导出页面为PDF文件_前端生成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, \'
\'
);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按钮对应的方法如下:

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

Vue前端导出页面为PDF文件_前端生成pdf
以下代码是针对勾选数据的定义和响应式绑定

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, \'
\'
);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 压缩,既减轻了服务端压力,又保证了导出效果。实际应用中可根据页面复杂度调整预处理逻辑与批处理参数,平衡导出效率与准确性。