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, ' '); 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,页面模板部分如下
<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”,其声明定义如下:
const weekReportRef = ref<HTMLElement | null>(null);
在Vue 3中,ref
是一个非常重要的响应式API,它有两种主要用途:
- 在脚本中创建响应式变量:通过
ref()
函数创建一个响应式引用 - 在模板中引用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'; // 几乎不可见但会渲染 // 加载详情页面的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, ' '); 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 压缩,既减轻了服务端压力,又保证了导出效果。实际应用中可根据页面复杂度调整预处理逻辑与批处理参数,平衡导出效率与准确性。
以上就是Vue前端导出页面为PDF文件的最佳方案的详细内容,更多关于Vue导出页面为PDF的资料请关注脚本之家其它相关文章!