在线 PDF 预览与下载实践

62 阅读5分钟

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> 加载的本地 URLcreateObjectURL 会在浏览器里给那份 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
  }
}