1. 背景
目前很多中后台系统都有在线展示报告、导出检测或评估结果的需求,而且大家都想做到“页面怎么展示,下载的 PDF 就怎么长”。传统靠后端渲染或第三方服务的做法,又麻烦又难以保证样式统一。
这次我打算走纯前端路线,用 Vue3 + AntD + html2canvas + jsPdf 技术栈,把渲染的页面直接变成 PDF,这样既能在线预览,也能一键下载,不必额外部署服务。拿它来处理体检报告、检测数据、合同这类场景都比较顺手。
2. 准备页面骨架
要开始实现 PDF 相关操作的话,前提是你已经完成了业务页面。我这里打算用 Collapse 组件展示每条数据,并在面板内嵌 PdfDetail 组件来封装需要预览/下载的布局,你们可以根据业务需要搭建各式各样的结构(比如包含图片、文本、表格等)。
<Card>
<Collapse v-model:activeKey="activeKey" accordion :bordered="false" ghost>
<Collapse.Panel :show-arrow="false" v-for="ld in listData" :key="ld.id">
<template #header>
<Flex justify="space-between" align="center" class="w-full gap-4">
<Flex align="center" gap="small">
<span>{{ ld.name }}</span>
<span>{{ ld.fileSuffix }}</span>
<span>{{ ld.testTime }}</span>
</Flex>
<Flex>
<Button type="link" @click.stop="onPreviewPdf(ld)">预览PDF</Button>
<Button type="link" @click.stop="onDownloadPdf(ld)">下载</Button>
</Flex>
</Flex>
</template>
<PdfDetail :info="ld.detailDTO" />
</Collapse.Panel>
</Collapse>
</Card>
3. PDF 预览的核心逻辑
预览区域
用 <iframe> 标签显示 PDF 内容,通过模态框的形式呈现。
点击“预览PDF”,对应的 PdfDetail 组件 DOM 会被捕获成图片,捕获的就是下面这块内容:
<div
ref="pdfPreviewRef"
class="bg-white p-[24px] w-[794px] top-[-9999px] left-[-9999px] fixed"
aria-hidden="true"
>
<PdfDetail
v-if="renderTarget"
:key="renderTarget ? renderTarget.id : 'preview'"
:info="renderTarget && renderTarget.detailDTO ? renderTarget.detailDTO : {}"
/>
</div>
const renderTarget = computed(() => downloadTarget.value || previewTarget.value)
捕获成图片后,用 jsPDF 生成 Blob,并在弹窗内以 <iframe> 展示。这里的 <iframe> 设置了 src 指向 PDF 地址,浏览器就会把 PDF 直接嵌入在这块区域里显示,相当于一个内嵌的预览器。无需额外插件,只要浏览器原生支持就能看到内容。
<Modal
v-model:open="previewVisible"
:title="previewTarget?.name || 'PDF 预览'"
width="80vw"
:footer="null"
@cancel="onPreviewClose"
destroyOnClose
>
<div v-if="previewLoading" class="py-8 text-center text-gray-500">PDF 生成中,请稍候...</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="w-full h-[70vh]"
frameborder="0"
></iframe>
<div v-else class="py-8 text-center text-gray-500">暂无可预览的文件</div>
</Modal>
细心的友友会发现,我这里用了两次 PdfDetail 组件。他们各司其职,折叠列表处的 PdfDetail 组件是直接渲染给用户看的详情,点击面板就能展开看到。而 pdfPreviewRef 所包的 PdfDetail 组件不显示在界面上,专门给 html2canvas 截图用,好让我们生成 PDF 供预览和下载。
处理临时地址,防止内存泄漏
每次生成 PDF 时,jsPDF 会输出一个 Blob,我们调用 URL.createObjectURL(blob) 得到一个可供 <iframe> 加载的本地 URL。createObjectURL 会在浏览器里给那份 Blob 分配一块受管的内存,并返回一个临时地址指向它。只要这个地址还有效,那块内存就不会被释放,哪怕你已经把变量里的 Blob 或 DOM 节点删掉。对于单页应用来说,如果不断生成新的 PDF 预览而不 revokeObjectURL,这些旧地址就一直占着内存,久而久之会造成内存泄漏。为了避免内存泄漏,在预览关闭或生成新 PDF 前,需要调用 URL.revokeObjectURL 把旧的 URL 释放掉。
/**
* 销毁现有的预览 URL,避免内存泄漏。
* @returns {void}
*/
const revokePreviewUrl = () => {
if (previewPdfUrl.value) {
URL.revokeObjectURL(previewPdfUrl.value)
previewPdfUrl.value = ''
}
}
onBeforeUnmount(() => {
revokePreviewUrl()
})
等待图片加载完成后生成 PDF,避免 html2canvas 捕获到空白图像。
waitForImages 会在生成 PDF 之前把隐藏容器里的图片都等到加载完成。因为html2canvas 在捕获 DOM 时依赖图片内容。如果图片还在加载中,就会被渲染成空白,生成的 PDF 也会缺失图像。所以先 await waitForImages(...),确保最终输出完整。
/**
* 等待容器内所有 <img> 元素完成加载,避免 html2canvas 捕获到空白图像。
* @param {HTMLElement} rootEl 需要检查的根节点
* @returns {Promise<void>} 所有图片加载完成后 resolve
*/
const waitForImages = async (rootEl) => {
const images = Array.from(rootEl.querySelectorAll('img'))
await Promise.all(
images.map((img) => {
if (img.complete && img.naturalWidth !== 0) return Promise.resolve()
return new Promise((resolve) => {
img.onload = () => resolve()
img.onerror = () => resolve()
})
}),
)
}
生成 PDF
/**
* 打开预览弹窗并生成预览 PDF。
* @param {object} item 列表项数据
* @returns {Promise<void>} 预览流程完成后 resolve
*/
const onPreviewPdf = async (item) => {
if (!item?.detailDTO) {
message.warning('暂无可预览的数据')
return
}
previewTarget.value = item
downloadTarget.value = null
previewVisible.value = true
previewLoading.value = true
revokePreviewUrl()
await nextTick() // 等到下一轮 DOM 更新完成再执行代码
if (pdfPreviewRef.value) {
await nextTick()
await generatePreviewPdf()
} else {
previewLoading.value = false
}
}
/**
* 生成预览用的 PDF 链接地址。
* @returns {Promise<void>} 预览内容生成完毕后 resolve
*/
const generatePreviewPdf = async () => {
if (!pdfPreviewRef.value || !renderTarget.value) return
previewLoading.value = true
try {
const blob = await createPdfBlob()
revokePreviewUrl()
previewPdfUrl.value = URL.createObjectURL(blob)
} catch (error) {
console.error(error)
message.error('PDF 生成失败,请稍后重试')
} finally {
previewLoading.value = false
}
}
/**
* 将隐藏容器渲染成 pdf 并返回 blob 数据。
* @returns {Promise<Blob>} 生成完成的 PDF blob 对象
* @throws {Error} 当缺失渲染目标或容器时抛出
*/
const createPdfBlob = async () => {
if (!pdfPreviewRef.value || !renderTarget.value) {
throw new Error('缺少可生成的 PDF 内容')
}
await waitForImages(pdfPreviewRef.value)
const canvas = await html2canvas(pdfPreviewRef.value, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
})
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF('p', 'mm', 'a4')
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const pdfHeight = (canvas.height * pageWidth) / canvas.width // PDF 内容的高度
let position = 0
let heightLeft = pdfHeight
pdf.addImage(imgData, 'PNG', 0, position, pageWidth, pdfHeight)
heightLeft -= pageHeight
// 内容超过一页 PDF 的高度之后,需要新增一页
while (heightLeft > 0) {
position = heightLeft - pdfHeight // 计算要把 canvas 截的整张图向上挪多少
pdf.addPage()
pdf.addImage(imgData, 'PNG', 0, position, pageWidth, pdfHeight) // position: 控制图的顶端往下(正值)或往上(负值)偏移多少
heightLeft -= pageHeight
}
return pdf.output('blob')
}
3. PDF 下载的核心逻辑
onDownloadPdf 函数会将当前折叠列表的详情重新渲染成 PDF 并触发下载。首先要确保拿到可导出的目标数据,也就是所在折叠列表的详情内容。然后把目标写入 downloadTarget 响应式对象中,让隐藏的 PdfDetail 组件渲染出对应的 DOM,然后通过两次 nextTick() 等待 Vue DOM 更新完成。此时,需要生成的 PDF 内容已经成型。接着调用 createPdfBlob(),使用 html2canvas + jsPDF 将隐藏容器截图成多页的 PDF 的 Blob,生成临时 URL,借助动态创建的 <a> 元素手动触发浏览器下载,然后清理临时链接并重置 downloadTarget,避免状态残留。
/**
* 根据给定项目或列表第一项生成 PDF 并触发下载。
* @param {object} [item] 可选的列表项数据
* @returns {Promise<void>} 下载流程完成后 resolve
*/
const onDownloadPdf = async (item) => {
try {
let target = item
if (!target && !listData.value.length) {
await getList()
}
if (!target) {
target = listData.value[0]
}
if (!target?.detailDTO) {
message.warning('暂无可下载的数据')
return
}
downloadTarget.value = target
await nextTick()
if (pdfPreviewRef.value) {
await nextTick()
}
const blob = await createPdfBlob()
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = `${target.name || 'document'}.pdf`
anchor.rel = 'noopener'
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(objectUrl)
} catch (error) {
console.error(error)
message.error('文件下载失败,请稍后重试')
} finally {
downloadTarget.value = null
}
}