深度解析 Web 项目中生成 PDF 的多种方案。涵盖后端流下载、window.print() 技巧,以及 html2canvas-pro + jsPDF 实现的自动分页导出逻辑与多模板架构设计。
在工作中,我们经常会遇到需要生成PDF的业务,比如合同、报告等。
前后端h2
对于前端来说,最省事的就是后端生成 PDF 文件,前端根据返回的 URL 地址进行下载。
URL 下载h3
如果后端直接返回一个可访问的 URL 地址,我们可以通过以下几种方式进行下载:
1. 使用 window.open 或 location.hrefh4
这是最简单的方式,但缺点是无法控制下载后的文件名,且受浏览器拦截政策影响。
const downloadByUrl = (url: string) => { window.open(url, '_blank')}2. 使用 <a> 标签(推荐)h4
通过创建虚拟锚点并利用 download 属性,可以更好地控制下载行为。
/** * 通过 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)}文件流下载h3
如果后端返回的是文件流(Blob),由于浏览器无法直接解析这种数据格式作为下载源,我们需要通过 URL.createObjectURL 将其转换为一个临时的 blob:URL,然后利用 <a> 标签触发下载。
/** * 通过文件流下载文件 * @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)}前端生成 PDFh2
在有些业务上,需要纯前端生成 PDF。
window.print() 方法h3
这是调用浏览器原生打印功能最简单的方法。它会将当前页面的内容渲染到打印预览窗口中,用户可以选择保存为 PDF。
其实并不推荐,因为在很多复杂的结构中,需要做很多工作,才能达到理想的效果。 并且会有打印预览弹窗,无法实现无感打印。
const handlePrint = () => { window.print()}CSS 控制h4
为了让打印出来的效果更好,我们通常需要使用 @media print 查询来控制打印时的样式。
@media print { /* 隐藏不需要打印的元素,如导航栏、侧边栏、按钮 */ .no-print { display: none !important; }
/* 调整打印区域的宽度 */ .print-container { width: 100%; margin: 0; padding: 0; }
/* 强制分页 */ .page-break { page-break-after: always; }}html2canvas-pro + jsPDFh3
html2canvas 可以将网页内容转换为图片,然后 jsPDF 可以将图片转换为 PDF。
html2canvas-pro 是 html2canvas 的加强版分叉,完全兼容原版 API。它可以作为无缝替代品直接安装并导入(只需将 import html2canvas from 'html2canvas' 改为 import html2canvas from 'html2canvas-pro')。它修复了原版在处理现代 CSS(如 object-fit、clip-path)时的许多渲染 Bug。
下面是通用的代码,可用于 95% 的场景,该方法会自动分页,且不会切断元素。
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()
// 【优化1】同时收集元素的 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
// 【优化2】判断是不是最后一页 if (expectedPageBottom >= imgHeight) { actualPageBottom = imgHeight } else { // 只有不是最后一页,才去遍历判断是否被截断 for (let i = 0; i < breakPointsPt.length; i++) { const { top, bottom } = breakPointsPt[i]
// 【优化3】核心判断:元素的头在当前页,但尾巴超出了当前页的底部,说明被“腰斩”了 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`)}用法案例h4
在 React 中使用该方案:
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 模板架构设计h4
当项目中需要管理多个 PDF 模板时,建议采用“容器与显示分离”的架构,这样可以保证模板的纯净度(只负责 UI),同时方便在后台静默生成 PDF。
1. 目录结构建议h5
src/ ├── components/ │ └── pdf-templates/ # 所有的 PDF UI 模板 │ ├── Contract.tsx # 合同模板 │ ├── Invoice.tsx # 发票模板 │ └── index.ts # 统一导出 └── utils/ └── pdf.ts # 核心 exportPdf 方法2. 模板编写建议 (Pure UI)h5
模板组件应该只接收 data Props,不处理任何业务逻辑。
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. 数据获取与导出架构h5
推荐在需要导出 PDF 的页面中,通过一个隐藏的“渲染容器”来实现。这样可以在不影响主页面 UI 的情况下,获取最新的业务数据并生成 PDF。
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. 架构优势h5
- 关注点分离:页面只管触发,模板只管绘制,
utils只管转换。 - 数据解耦:PDF 模板的数据可以由父页面统一注入,也可以在
exportPdf调用前按需加载。 - 用户无感:通过
createPortal将渲染目标移出可视区域,用户在页面上感知不到“截图”的过程。
评论