前端 PDF 导出:从文件流下载到自动分页

在工作中,我们经常会遇到需要生成 PDF 的业务,比如合同、报告等。

前后端合作

对于前端来说,最省事的就是后端生成 PDF 文件,前端根据返回的 URL 地址进行下载。

URL 下载

如果后端直接返回一个可访问的 URL 地址,我们可以通过以下几种方式进行下载:

1. 使用 window.openlocation.href

这是最简单的方式,但缺点是无法控制下载后的文件名,且受浏览器拦截政策影响。

typescript 复制代码
const downloadByUrl = (url: string) => {
  window.open(url, '_blank')
}

2. 使用 <a> 标签(推荐)

通过创建虚拟锚点并利用 download 属性,可以更好地控制下载行为。

typescript 复制代码
/**
 * 通过 URL 下载文件
 * @param url 文件地址
 * @param fileName 自定义文件名
 */
export const downloadFileByUrl = (url: string, fileName?: string) => {
  const link = document.createElement('a')
  link.href = url

  // 如果提供了文件名,则设置 download 属性
  if (fileName) {
    link.download = fileName
  }

  link.target = '_blank'
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 清理
  document.body.removeChild(link)
}

文件流下载

如果后端返回的是文件流(Blob),由于浏览器无法直接解析这种数据格式作为下载源,我们需要通过 URL.createObjectURL 将其转换为一个临时的 blob:URL,然后利用 <a> 标签触发下载。

typescript 复制代码
/**
 * 通过文件流下载文件
 * @param data 文件流数据 (Blob | ArrayBuffer | string)
 * @param fileName 下载后的文件名
 * @param mimeType 文件的 MIME 类型 (可选,如果不传则尝试从 data 中获取或使用默认值)
 */
export const downloadFileByStream = (data: any, fileName: string, mimeType?: string) => {
  // 1. 优先获取数据的类型
  const type = mimeType || (data instanceof Blob ? data.type : 'application/octet-stream')

  // 2. 将数据封装为 Blob 对象
  const blob = data instanceof Blob ? data : new Blob([data], { type })

  // 3. 创建一个临时的 URL 指向该 Blob 对象
  const blobURL = window.URL.createObjectURL(blob)

  // 4. 创建虚拟锚点触发下载
  const link = document.createElement('a')
  link.href = blobURL
  link.download = fileName
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 5. 下载执行后释放 URL 对象和 DOM 节点
  document.body.removeChild(link)
  // 不释放可能导致内存泄露,过早释放可能会导致下载失败,可以延迟触发
  window.URL.revokeObjectURL(blobURL)
}

前端生成 PDF

在有些业务上,需要纯前端生成 PDF。

window.print() 方法

这是调用浏览器原生打印功能最简单的方法。它会将当前页面的内容渲染到打印预览窗口中,用户可以选择保存为 PDF。

其实并不推荐,因为在很多复杂的结构中,需要做很多工作,才能达到理想的效果。 并且会有打印预览弹窗,无法实现无感打印。

typescript 复制代码
const handlePrint = () => {
  window.print()
}

CSS 控制

为了让打印出来的效果更好,我们通常需要使用 @media print 查询来控制打印时的样式。

css 复制代码
@media print {
  /* 隐藏不需要打印的元素,如导航栏、侧边栏、按钮 */
  .no-print {
    display: none !important;
  }

  /* 调整打印区域的宽度 */
  .print-container {
    width: 100%;
    margin: 0;
    padding: 0;
  }

  /* 强制分页 */
  .page-break {
    page-break-after: always;
  }
}

html2canvas-pro + jsPDF

html2canvas 可以将网页内容转换为图片,然后 jsPDF 可以将图片转换为 PDF。

html2canvas-prohtml2canvas 的加强版分叉,完全兼容原版 API 。它可以作为无缝替代品 直接安装并导入(只需将 import html2canvas from 'html2canvas' 改为 import html2canvas from 'html2canvas-pro')。它修复了原版在处理现代 CSS(如 object-fitclip-path)时的许多渲染 Bug。

下面是通用的代码,可用于 95% 的场景,该方法会自动分页,且不会切断元素。

typescript 复制代码
import html2canvas from 'html2canvas-pro' // 推荐使用 pro 版本无缝替代
import jsPDF from 'jspdf'

/**
 * 将指定 DOM 导出为 PDF
 * @param domId 目标 DOM 元素的 ID
 * @param title 导出的文件名
 */
export const exportPdf = async (domId: string, title?: string): Promise<void> => {
  const ele = document.getElementById(domId)
  if (!ele) throw new Error('未找到目标元素')

  const scale = window.devicePixelRatio > 1 ? window.devicePixelRatio : 2

  // 获取所有防截断元素(防止元素被分页切开,如表格行、标题、段落等)
  const nodes = ele.querySelectorAll('tr, h2, h3, h4, h5, p, img')
  const containerRect = ele.getBoundingClientRect()

  // 同时收集元素的 top 和 bottom 坐标
  const breakPointsPx = Array.from(nodes).map((node) => {
    const rect = node.getBoundingClientRect()
    return {
      top: rect.top - containerRect.top,
      bottom: rect.bottom - containerRect.top,
    }
  })

  // 生成画布
  const canvas = await html2canvas(ele, {
    scale,
    useCORS: true, // 允许图片跨域
    backgroundColor: '#ffffff',
  })

  const imgDataUrl = canvas.toDataURL('image/jpeg', 1.0)

  // 初始化 PDF 对象:p-竖向,pt-点(单位),a4-纸张规格
  const pdf = new jsPDF('p', 'pt', 'a4')
  const a4Width = pdf.internal.pageSize.getWidth()
  const a4Height = pdf.internal.pageSize.getHeight()

  // 计算图片缩放比例:根据宽度适配 A4
  const ratio = a4Width / canvas.width
  const imgWidth = a4Width
  const imgHeight = canvas.height * ratio

  // 将坐标单位从 px 转换为 pt (符合 PDF 内部计算)
  const breakPointsPt = breakPointsPx.map((bp) => ({
    top: bp.top * ratio,
    bottom: bp.bottom * ratio,
  }))

  const topMargin = 30 // 页眉预留
  const bottomMargin = 30 // 页脚预留
  const pageContentHeight = a4Height - topMargin - bottomMargin

  let currentRenderY = 0 // 已完成渲染的 Y 轴偏移

  while (currentRenderY < imgHeight) {
    let expectedPageBottom = currentRenderY + pageContentHeight
    let actualPageBottom = expectedPageBottom

    // 判断是不是最后一页
    if (expectedPageBottom >= imgHeight) {
      actualPageBottom = imgHeight
    } else {
      // 只有不是最后一页,才去遍历判断是否被截断
      for (let i = 0; i < breakPointsPt.length; i++) {
        const { top, bottom } = breakPointsPt[i]

        // 核心判断:元素的头在当前页,但尾巴超出了当前页的底部,说明被"腰斩"了
        if (top > currentRenderY && top < expectedPageBottom && bottom > expectedPageBottom) {
          actualPageBottom = top // 在被截断元素的顶部切一刀,将其整体推到下一页
          break
        }
      }
    }

    if (actualPageBottom === currentRenderY) actualPageBottom = expectedPageBottom

    // 1. 渲染当前页图像(利用负偏移显示指定区域)
    pdf.addImage(imgDataUrl, 'JPEG', 0, topMargin - currentRenderY, imgWidth, imgHeight)

    // 2. 顶部遮罩(覆盖负偏移区域产生的重叠部分)
    if (currentRenderY > 0) {
      pdf.setFillColor(255, 255, 255)
      pdf.rect(0, 0, a4Width, topMargin, 'F')
    }

    // 3. 底部遮罩(留白并遮挡截断处的残影)
    const currentRenderBottomY = topMargin + (actualPageBottom - currentRenderY)
    pdf.setFillColor(255, 255, 255)
    pdf.rect(0, currentRenderBottomY, a4Width, a4Height - currentRenderBottomY, 'F')

    currentRenderY = actualPageBottom

    // 如果还没画完,添加新的一页
    if (currentRenderY + 5 < imgHeight) {
      pdf.addPage()
    }
  }
  const fileName = title ? `${title}_${Date.now()}` : Date.now().toString()
  pdf.save(`${fileName}.pdf`)
}

CodeSandbox 案例

用法案例

在 React 中使用该方案:

tsx 复制代码
import { exportPdf } from './utils/pdf'

const ReportPage = () => {
  const handleDownload = async () => {
    try {
      // 传入容器 ID 和文件名
      await exportPdf('pdf-content', '月度分析报告')
    } catch (error) {
      console.error('生成 PDF 失败:', error)
    }
  }

  return (
    <div>
      <button onClick={handleDownload}>下载报告</button>

      {/* 这里的 ID 必须与 exportPdf 传入的一致 */}
      <div id="pdf-content" style={{ padding: '20px', background: '#fff' }}>
        <h2>报表标题</h2>
        <p>这里是很长很长的内容,可能会跨页...</p>
        <table>
          <tbody>
            <tr>
              <td>数据行 1</td>
            </tr>
            {/* 这里的 tr 会被防截断逻辑自动推送到下一页容器中 */}
            <tr>
              <td>数据行 2</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  )
}

进阶:PDF 模板架构设计

当项目中需要管理多个 PDF 模板时,建议采用"容器与显示分离"的架构,这样可以保证模板的纯净度(只负责 UI),同时方便在后台静默生成 PDF。

1. 目录结构建议
text 复制代码
src/
  ├── components/
  │   └── pdf-templates/      # 所有的 PDF UI 模板
  │       ├── Contract.tsx    # 合同模板
  │       ├── Invoice.tsx     # 发票模板
  │       └── index.ts        # 统一导出
  └── utils/
      └── pdf.ts              # 核心 exportPdf 方法
2. 模板编写建议

模板组件应该只接收 data Props,不处理任何业务逻辑。

tsx 复制代码
// src/components/pdf-templates/ContractTemplate.tsx
interface IProps {
  data: any
}

export const ContractTemplate = ({ data }: IProps) => (
  <div id="pdf-render-target" style={{ width: '800px', padding: '40px' }}>
    <h1>{data.title}</h1>
    {/* 自由编写复杂的 PDF 样式 */}
  </div>
)
3. 数据获取与导出架构

推荐在需要导出 PDF 的页面中,通过一个隐藏的"渲染容器"来实现。这样可以在不影响主页面 UI 的情况下,获取最新的业务数据并生成 PDF。

tsx 复制代码
// src/pages/OrderDetails.tsx
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { exportPdf } from '../utils/pdf'
import { ContractTemplate } from '../components/pdf-templates'

const OrderDetails = () => {
  const [isExporting, setIsExporting] = useState(false)
  const [data, setData] = useState(null)

  const startExport = async () => {
    setIsExporting(true)

    // 1. 获取业务数据 (如从 API 获取)
    const res = await fetchOrderData()
    setData(res)

    // 2. 等待 React 渲染 DOM (利用 setTimeout 确保渲染完成)
    setTimeout(async () => {
      try {
        await exportPdf('pdf-render-target', '业务合同')
      } finally {
        setIsExporting(false)
      }
    }, 100)
  }

  return (
    <div>
      <button onClick={startExport} disabled={isExporting}>
        {isExporting ? '正在生成...' : '下载 PDF'}
      </button>

      {/* 通过 Portal 将模板渲染在屏幕外,实现"无感"生成 */}
      {isExporting &&
        data &&
        createPortal(
          <div style={{ position: 'absolute', left: '-9999px', top: 0 }}>
            <ContractTemplate data={data} />
          </div>,
          document.body
        )}
    </div>
  )
}
4. 架构优势
  • 关注点分离 :页面只管触发,模板只管绘制,utils 只管转换。
  • 数据解耦 :PDF 模板的数据可以由父页面统一注入,也可以在 exportPdf 调用前按需加载。
  • 用户无感 :通过 createPortal 将渲染目标移出可视区域,用户在页面上感知不到"截图"的过程。
相关推荐
蓝鲸有腿2 小时前
项目部署后->这样通知用户刷新
前端
少卿2 小时前
OpenClaw github 技能:让 GitHub 操作像聊天一样简单
前端
Ekehlaft2 小时前
同题画图大考,AiPy 章鱼适配性拉满,OpenClaw 龙虾全程 “哑火”
前端
掘金酱2 小时前
小册上新|玩🦐吗?ai 编程全栈指南了解一下?
前端·人工智能·ai编程
小小小小宇2 小时前
富文本编辑器知识体系(一)
前端
发现一只大呆瓜3 小时前
深度拆解 fetch-event-source库实现原理
前端·javascript·面试
2601_953465613 小时前
HLS.js 原生开发!m3u8live.cn打造最贴合项目的 M3U8 在线播放器
开发语言·前端·javascript·python·json·ecmascript·前端开发工具
前端Hardy3 小时前
为什么资深前端都在悄悄学 WebAssembly?
前端·javascript·面试
发现一只大呆瓜3 小时前
SSE 流式传输:中断超时处理
前端·javascript·面试