Vue前端导出页面为PDF文件

文章目录


前言

笔者最近遇到一个需求,要把前端渲染出来的页面完整的导出为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,它有两种主要用途:

  1. 在脚本中创建响应式变量 :通过ref()函数创建一个响应式引用
  2. 在模板中引用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();
	}
};

执行流程与关键步骤

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

相关推荐
慧一居士28 分钟前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead30 分钟前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina7 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路8 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409198 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app