Vue前端導(dǎo)出頁面為PDF文件的最佳方案
前言
小編最近遇到一個(gè)需求,要把前端渲染出來的頁面完整的導(dǎo)出為PDF格式,最開始的方案是想在服務(wù)端導(dǎo)出,使用Freemarker或者Thymeleaf模板引擎,但是頁面實(shí)在是有點(diǎn)復(fù)雜,開發(fā)起來比較費(fèi)勁,最終還是尋找前端導(dǎo)出PDF的方案。其實(shí)前端導(dǎo)出反而更好,可以減輕服務(wù)器端的壓力,導(dǎo)出來的樣式也更好看,給各位看下,筆者要導(dǎo)出的頁面,內(nèi)容還是挺多的吧。

一、導(dǎo)出工具類
下面直接展示PDF導(dǎo)出工具類
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
export default {
/**
* 將HTML元素導(dǎo)出為PDF
* @param element 需要導(dǎo)出的DOM元素
* @param fileName 導(dǎo)出的文件名
*/
async exportElementToPdf(element: HTMLElement, fileName: string = 'document'): Promise<void> {
if (!element) {
console.error('導(dǎo)出元素不能為空');
return;
}
try {
// 處理textarea元素,臨時(shí)替換為div以確保內(nèi)容完整顯示
const textareas = Array.from(element.querySelectorAll('textarea'));
const originalStyles: { [key: string]: string } = {};
const replacedElements: HTMLElement[] = [];
// 處理滾動(dòng)區(qū)域
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,保留內(nèi)容和樣式
textareas.forEach((textarea, index) => {
// 保存原始樣式
originalStyles[index] = textarea.style.cssText;
// 創(chuàng)建替代元素
const replacementDiv = document.createElement('div');
replacementDiv.innerHTML = textarea.value.replace(/\n/g, '
');
replacementDiv.style.cssText = textarea.style.cssText;
replacementDiv.style.height = 'auto'; // 確保高度自適應(yīng)內(nèi)容
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);
});
// 預(yù)加載所有圖片的增強(qiáng)方法
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) => {
// 如果圖片已經(jīng)完成加載,直接解析
if (img.complete && img.naturalHeight !== 0) {
resolve();
return;
}
// 為每個(gè)圖片添加加載和錯(cuò)誤事件監(jiān)聽器
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]; // 恢復(fù)原始src
resolve();
};
newImg.onerror = () => {
img.src = originalSrcs[index]; // 恢復(fù)原始src
resolve(); // 即使失敗也繼續(xù)執(zhí)行
};
// 強(qiáng)制重新加載
const src = img.src;
img.src = '';
setTimeout(() => {
newImg.src = src;
}, 100);
};
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
// 如果圖片沒有src或src是數(shù)據(jù)URL,直接解析
if (!img.src || img.src.startsWith('data:')) {
resolve();
}
});
})
);
};
// 預(yù)加載所有圖片
await preloadImages();
// 使用html2canvas將整個(gè)元素轉(zhuǎn)為單個(gè)canvas
const canvas = await html2canvas(element, {
scale: 2, // 提高清晰度
useCORS: true, // 允許加載跨域圖片
logging: false,
allowTaint: true, // 允許污染畫布
backgroundColor: '#ffffff', // 設(shè)置背景色為白色
imageTimeout: 15000, // 增加圖片加載超時(shí)時(shí)間到15秒
onclone: (documentClone) => {
// 在克隆的文檔中查找所有圖片
const clonedImages = documentClone.querySelectorAll('img');
// 確保所有圖片都設(shè)置了crossOrigin屬性
clonedImages.forEach(img => {
img.crossOrigin = "Anonymous";
// 對(duì)于數(shù)據(jù)URL的圖片跳過
if (img.src && !img.src.startsWith('data:')) {
// 添加時(shí)間戳以避免緩存問題
if (img.src.indexOf('?') === -1) {
img.src = `${img.src}?t=${new Date().getTime()}`;
} else {
img.src = `${img.src}&t=${new Date().getTime()}`;
}
}
});
return documentClone;
}
});
// 恢復(fù)原始DOM,移除臨時(shí)添加的元素
textareas.forEach((textarea, index) => {
textarea.style.cssText = originalStyles[index];
textarea.style.display = '';
if (replacedElements[index] && replacedElements[index].parentNode) {
replacedElements[index].parentNode.removeChild(replacedElements[index]);
}
});
// 恢復(fù)滾動(dòng)區(qū)域的樣式
scrollElements.forEach((el, index) => {
if (originalScrollStyles[index]) {
(el as HTMLElement).style.cssText = originalScrollStyles[index];
}
});
// 創(chuàng)建PDF(使用適合內(nèi)容的尺寸)
// 如果內(nèi)容寬高比接近A4,使用A4;否則使用自定義尺寸
const imgWidth = 210; // A4寬度(mm)
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// 使用一頁完整顯示內(nèi)容,不強(qiáng)制分頁
const pdf = new jsPDF({
orientation: imgHeight > 297 ? 'p' : 'p', // 如果內(nèi)容高度超過A4高度,使用縱向
unit: 'mm',
format: imgHeight > 297 ? [imgWidth, imgHeight] : 'a4' // 如果內(nèi)容高度超過A4高度,使用自定義尺寸
});
// 添加圖像到PDF,確保填滿頁面但保持比例
pdf.addImage(
canvas.toDataURL('image/jpeg', 1.0), // 使用高質(zhì)量
'JPEG',
0,
0,
imgWidth,
imgHeight
);
// 保存PDF
pdf.save(`${fileName}.pdf`);
} catch (error) {
console.error('導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤:', error);
}
}
};
這個(gè) 工具類考慮了導(dǎo)出的html頁面中的圖片和text滾動(dòng)文本框,使得導(dǎo)出來的PDF文件能夠完整展示原HTML頁面內(nèi)容,基本能做到95%以上的還原吧,導(dǎo)出的格式是A4紙張大小,方便打印出來。
二、單頁面詳情導(dǎo)出
比如說我現(xiàn)在有個(gè)頁面叫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">導(dǎo)出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>
這里的關(guān)鍵屬性是ref=“weekReportRef”,其聲明定義如下:
const weekReportRef = ref<HTMLElement | null>(null);
在Vue 3中,ref是一個(gè)非常重要的響應(yīng)式API,它有兩種主要用途:
- 在腳本中創(chuàng)建響應(yīng)式變量:通過
ref()函數(shù)創(chuàng)建一個(gè)響應(yīng)式引用 - 在模板中引用DOM元素或組件實(shí)例:通過在模板元素上添加
ref屬性
這里主要是利用了第二點(diǎn),代表了當(dāng)前組件的渲染實(shí)例,導(dǎo)出PDF按鈕對(duì)應(yīng)的方法如下:
// 下載PDF
const downloadPdf = async () => {
if (!weekReportRef.value) return;
isImporting.value = true;
// 創(chuàng)建文件名,例如:張三_2025年第28周_總結(jié)
const fileName = `${reportDetail.value?.weekReports.userName}${weekDesc.value}周報(bào)`;
ElLoading.service({
lock: true,
text: '正在導(dǎo)出PDF,請(qǐng)稍后...',
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('導(dǎo)出PDF失敗', error);
}
};
通過以上代碼,可以看到在調(diào)用導(dǎo)出PDF時(shí),傳入了當(dāng)前的組件的實(shí)例,其中isImporting這個(gè)屬性,是筆者為了限制某些按鈕什么的控件不要在導(dǎo)出后的PDF文件中顯示而添加的臨時(shí)屬性。
三、列表頁批量壓縮導(dǎo)出
上面說的是單頁面導(dǎo)出PDF,那如果有個(gè)列表頁,需要批量選擇然后導(dǎo)出怎么辦?導(dǎo)出過程中,又沒辦法一個(gè)個(gè)點(diǎn)進(jìn)去等待數(shù)據(jù)渲染。前輩大佬早就想到了這個(gè)場(chǎng)景,我們可以利用html中的標(biāo)簽iframe,在批量選擇導(dǎo)出時(shí),為每一個(gè)列表數(shù)據(jù)臨時(shí)創(chuàng)建一個(gè)渲染后的詳情頁面數(shù)據(jù),即Dom中的Dom,然后對(duì)嵌套頁面導(dǎo)出壓縮,當(dāng)然我們用戶自己是感知不到的。比如下面的列表:

以下代碼是針對(duì)勾選數(shù)據(jù)的定義和響應(yīng)式綁定
const selectedRows = ref<WeekReportsDetail[]>([]);
// 處理表格選擇變化
const handleSelectionChange = (selection: WeekReportsDetail[]) => {
selectedRows.value = selection;
};
批量導(dǎo)出壓縮PDF文件的代碼如下,比較復(fù)雜,僅供參考:
// 導(dǎo)出選中項(xiàng)到PDF并壓縮
const exportSelectedToPdf = async () => {
if (selectedRows.value.length === 0) {
ElNotification({
title: '提示',
message: '請(qǐng)先選擇要導(dǎo)出的周報(bào)',
type: 'warning',
});
return;
}
// 顯示加載中提示
const loading = ElLoading.service({
lock: true,
text: `正在準(zhǔn)備導(dǎo)出...`,
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)',
});
try {
// 創(chuàng)建ZIP實(shí)例
const zip = new JSZip();
const allPdfResults: { fileName: string, pdfBlob: Blob }[] = [];
// 定義批處理大小和函數(shù)
const batchSize = 5; // 每批處理的數(shù)量,可以根據(jù)實(shí)際情況調(diào)整
// 批量處理函數(shù)
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(`正在導(dǎo)出第 ${overall}/${selectedRows.value.length} 個(gè)周報(bào)...`);
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'; // 幾乎不可見但會(huì)渲染
// 加載詳情頁面的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) => {
// 設(shè)置超時(shí)
const timeoutId = setTimeout(() => {
rejectIframe(new Error('加載超時(shí)'));
}, 15000); // 15秒超時(shí)
iframe.onload = async () => {
clearTimeout(timeoutId);
try {
// 給頁面充分的時(shí)間加載數(shù)據(jù)和渲染
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和滾動(dòng)區(qū)域
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);
});
// 處理滾動(dòng)區(qū)域
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';
}
});
// 預(yù)加載所有圖片
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是數(shù)據(jù)URL,直接解析
if (!img.src || img.src.startsWith('data:')) {
resolveImg();
} else {
// 添加時(shí)間戳以避免緩存問題
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);
}
});
})
);
// 等待額外時(shí)間確保渲染完成
await new Promise(r => setTimeout(r, 1000));
// 創(chuàng)建周報(bào)文件名
const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
weekIndex: report.weekIndex,
yearIndex: report.year,
});
const fileName = `${report.userName}_${weekDesc}周報(bào).pdf`;
// 使用html2canvas轉(zhuǎn)換為canvas
const canvas = await html2canvas(reportElement as HTMLElement, {
scale: 2,
useCORS: true,
logging: false,
allowTaint: true,
backgroundColor: '#ffffff',
imageTimeout: 15000, // 增加超時(shí)時(shí)間
});
// 從canvas創(chuàng)建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');
// 恢復(fù)iframe中的DOM
iframeTextareas.forEach((textarea, index) => {
textarea.style.display = '';
if (replacedElements[index] && replacedElements[index].parentNode) {
replacedElements[index].parentNode.removeChild(replacedElements[index]);
}
});
// 解析PDF處理結(jié)果
resolveIframe();
// 直接添加到ZIP
zip.file(fileName, pdfBlob);
resolve({ fileName, pdfBlob });
} catch (error) {
console.error('處理PDF時(shí)出錯(cuò):', error);
rejectIframe(error);
}
};
iframe.onerror = () => {
clearTimeout(timeoutId);
rejectIframe(new Error('iframe加載失敗'));
};
});
// 如果成功處理了,跳出重試循環(huán)
break;
} catch (error) {
retryCount++;
console.warn(`處理PDF失敗,正在重試(${retryCount}/${maxRetries})...`, error);
// 如果已經(jīng)達(dá)到最大重試次數(shù),則放棄這個(gè)報(bào)告
if (retryCount > maxRetries) {
console.error(`無法處理周報(bào) ${report.id},已達(dá)到最大重試次數(shù)`);
// 創(chuàng)建一個(gè)空白PDF表示失敗
const weekDesc = DateTimeUtils.getWeekDescByYearAndWeek({
weekIndex: report.weekIndex,
yearIndex: report.year,
});
const fileName = `${report.userName}_${weekDesc}周報(bào)(處理失敗).pdf`;
// 創(chuàng)建一個(gè)簡(jiǎn)單的錯(cuò)誤PDF
const pdf = new jsPDF();
pdf.setFontSize(16);
pdf.text('處理此周報(bào)時(shí)出錯(cuò)', 20, 20);
pdf.setFontSize(12);
pdf.text(`用戶: ${report.userName}`, 20, 40);
pdf.text(`周報(bào)ID: ${report.id}`, 20, 50);
pdf.text(`時(shí)間: ${weekDesc}`, 20, 60);
pdf.text(`錯(cuò)誤信息: ${error || '未知錯(cuò)誤'}`, 20, 70);
const errorPdfBlob = pdf.output('blob');
zip.file(fileName, errorPdfBlob);
resolve({ fileName, pdfBlob: errorPdfBlob });
break;
}
// 等待一段時(shí)間再重試
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);
}
});
});
// 處理當(dāng)前批次
return await Promise.allSettled(batchPromises);
};
// 將報(bào)告分成多個(gè)批次
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} 批周報(bào)...`);
const batchResults = await processBatch(reportBatches[i]);
// 將結(jié)果添加到總結(jié)果中
batchResults.forEach(result => {
if (result.status === 'fulfilled') {
allPdfResults.push(result.value);
}
});
// 釋放一些內(nèi)存
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 = `周報(bào)匯總_${new Date().getTime()}.zip`;
link.click();
URL.revokeObjectURL(zipUrl);
ElNotification({
title: '導(dǎo)出成功',
message: `已將${allPdfResults.length}個(gè)周報(bào)導(dǎo)出為ZIP壓縮文件`,
type: 'success',
});
} catch (error) {
console.error('導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤:', error);
ElNotification({
title: '導(dǎo)出失敗',
message: '導(dǎo)出PDF時(shí)發(fā)生錯(cuò)誤,請(qǐng)稍后再試',
type: 'error',
});
} finally {
loading.close();
}
};
執(zhí)行流程與關(guān)鍵步驟
- 前置校驗(yàn)與初始化
- 選中項(xiàng)校驗(yàn):首先檢查 selectedRows(選中的周報(bào)數(shù)組)是否為空,若為空則通過 ElNotification 顯示警告提示(“請(qǐng)先選擇要導(dǎo)出的周報(bào)”),直接終止流程。
- 加載提示初始化:通過 ElLoading.service 創(chuàng)建全屏加載提示,顯示 “正在準(zhǔn)備導(dǎo)出…”,鎖定頁面交互以避免重復(fù)操作。
- 批量處理機(jī)制
為避免一次性處理過多數(shù)據(jù)導(dǎo)致瀏覽器性能問題,采用分批處理策略:
- 批處理配置:定義 batchSize = 5(每批處理 5 個(gè)周報(bào),可按需調(diào)整),將選中的周報(bào)數(shù)組拆分為多個(gè)批次(reportBatches)。
- 逐批處理:通過循環(huán)逐個(gè)處理每個(gè)批次,每批處理完成后等待 500ms 釋放內(nèi)存,降低瀏覽器資源占用。
- 單批周報(bào)處理(核心邏輯)
每批周報(bào)通過 processBatch 函數(shù)處理,單個(gè)周報(bào)的轉(zhuǎn)換流程如下:
- 創(chuàng)建隱藏 iframe:動(dòng)態(tài)生成一個(gè)不可見的 iframe(定位在頁面外,透明度 0.01),用于加載周報(bào)詳情頁(/center/detail/${report.id})。iframe 的作用是隔離詳情頁環(huán)境,避免直接操作當(dāng)前頁面 DOM 導(dǎo)致沖突。
- iframe 加載與重試機(jī)制:
- 為 iframe 設(shè)置 15 秒超時(shí)時(shí)間,若加載失敗則重試(最多重試 2 次),避免因網(wǎng)絡(luò)或資源加載問題導(dǎo)致單個(gè)周報(bào)處理失敗。
- 加載完成后等待 3 秒,確保詳情頁數(shù)據(jù)和樣式完全渲染。
- DOM 預(yù)處理(確保 PDF 內(nèi)容完整):
- 替換 textarea:將詳情頁中的 textarea 替換為 div(保留原樣式),因?yàn)?textarea 的滾動(dòng)特性可能導(dǎo)致內(nèi)容截?cái)?,替換后可完整顯示所有文本。
- 處理滾動(dòng)區(qū)域:將帶有 overflow: auto/scroll 或固定高度的元素改為 overflow: visible 且 maxHeight: none,確保內(nèi)容不被容器截?cái)唷?/li>
- 圖片預(yù)加載:遍歷詳情頁中的所有圖片,等待圖片加載完成(或超時(shí) / 錯(cuò)誤)后再繼續(xù),避免 PDF 中出現(xiàn)圖片缺失。通過添加時(shí)間戳(?t=${time})避免緩存影響。
- 轉(zhuǎn)換為 PDF:
- 用 html2canvas 將預(yù)處理后的詳情頁元素(.reports-detail-page)轉(zhuǎn)換為 canvas(scale: 2 提高清晰度)。
- 用 jsPDF 將 canvas 轉(zhuǎn)為 PDF,設(shè)置 A4 尺寸(或自適應(yīng)內(nèi)容高度),輸出為 Blob 格式。
異常處理:若多次重試后仍失敗,生成一個(gè) “錯(cuò)誤 PDF”(包含失敗原因、周報(bào) ID 等信息),避免單個(gè)失敗阻斷整個(gè)批次。
- 壓縮與下載
- ZIP 打包:所有 PDF 處理完成后,通過 JSZip 將所有 PDF Blob 打包為一個(gè) ZIP 文件,文件名格式為 “周報(bào)匯總_時(shí)間戳.zip”。
- 觸發(fā)下載:將 ZIP 文件轉(zhuǎn)換為 Blob URL,通過動(dòng)態(tài)創(chuàng)建 標(biāo)簽觸發(fā)瀏覽器下載,下載完成后釋放 URL 資源。
- 結(jié)果反饋與資源清理
- 成功反饋:若全部處理完成,通過 ElNotification 顯示成功提示(“已將 X 個(gè)周報(bào)導(dǎo)出為 ZIP 壓縮文件”)。
- 異常反饋:若過程中出現(xiàn)未捕獲的錯(cuò)誤,顯示錯(cuò)誤提示(“導(dǎo)出失敗,請(qǐng)稍后再試”)。
- 資源清理:無論成功或失敗,最終通過 loading.close() 關(guān)閉加載提示,釋放頁面鎖定。
核心步驟就是iframe,動(dòng)態(tài)生成一個(gè)不可見的 iframe(定位在頁面外,透明度 0.01),用于加載周報(bào)詳情頁(/center/detail/${report.id}),另外為什么采用批處理,不一次并發(fā)執(zhí)行呢?因?yàn)橐淮螆?zhí)行過多,渲染太多子頁面,超出瀏覽器承受范圍會(huì)報(bào)錯(cuò)。
四、總結(jié)
綜上,前端導(dǎo)出 PDF 方案通過 html2canvas 與 jsPDF 組合,結(jié)合 DOM 預(yù)處理解決了復(fù)雜頁面的完整還原問題。單頁導(dǎo)出利用 Vue 的 ref 獲取 DOM 元素直接轉(zhuǎn)換,批量導(dǎo)出則借助 iframe 隔離渲染環(huán)境并配合 JSZip 壓縮,既減輕了服務(wù)端壓力,又保證了導(dǎo)出效果。實(shí)際應(yīng)用中可根據(jù)頁面復(fù)雜度調(diào)整預(yù)處理邏輯與批處理參數(shù),平衡導(dǎo)出效率與準(zhǔn)確性。
以上就是Vue前端導(dǎo)出頁面為PDF文件的最佳方案的詳細(xì)內(nèi)容,更多關(guān)于Vue導(dǎo)出頁面為PDF的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Vue3在jsx下父子組件實(shí)現(xiàn)使用插槽方式
這篇文章主要介紹了Vue3在jsx下父子組件實(shí)現(xiàn)使用插槽方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10
教你三分鐘掌握Vue過濾器filters及時(shí)間戳轉(zhuǎn)換
這篇文章教你三分鐘掌握Vue過濾器filters及時(shí)間戳轉(zhuǎn)換,本文將結(jié)合時(shí)間戳轉(zhuǎn)換的例子帶你快速了解filters的用法,需要的朋友可以參考下2023-03-03
vue獲取DOM元素并設(shè)置屬性的兩種實(shí)現(xiàn)方法
下面小編就為大家?guī)硪黄獀ue獲取DOM元素并設(shè)置屬性的兩種實(shí)現(xiàn)方法。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-09-09
Vue3報(bào)錯(cuò)Cannot convert undefined or null&n
Vue3與Vue-cli5中出現(xiàn)“Cannot convert undefined or null to object”錯(cuò)誤,因基類組件data()未返回默認(rèn)空值對(duì)象導(dǎo)致屬性合并異常,解決方法是確保data()返回對(duì)象,避免繼承組件屬性沖突2025-08-08
vue-router中scrollBehavior的巧妙用法
本文給大家介紹vue-router中scrollBehavior的妙用,文中給大家提到了兩種解決方案,需要的朋友可以參考下2018-07-07
vue+element+springboot實(shí)現(xiàn)文件下載進(jìn)度條展現(xiàn)功能示例
本文主要介紹了vue + element-ui + springboot 實(shí)現(xiàn)文件下載進(jìn)度條展現(xiàn)功能,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11

