從文件流下載到自動(dòng)分頁(yè)解析前端PDF文件生成與導(dǎo)出功能
在工作中,我們經(jīng)常會(huì)遇到需要生成 PDF 的業(yè)務(wù),比如合同、報(bào)告等。
前后端合作
對(duì)于前端來(lái)說(shuō),最省事的就是后端生成 PDF 文件,前端根據(jù)返回的 URL 地址進(jìn)行下載。
URL 下載
如果后端直接返回一個(gè)可訪問(wèn)的 URL 地址,我們可以通過(guò)以下幾種方式進(jìn)行下載:
1. 使用window.open或location.href
這是最簡(jiǎn)單的方式,但缺點(diǎn)是無(wú)法控制下載后的文件名,且受瀏覽器攔截政策影響。
const downloadByUrl = (url: string) => {
window.open(url, '_blank')
}2. 使用<a>標(biāo)簽(推薦)
通過(guò)創(chuàng)建虛擬錨點(diǎn)并利用 download 屬性,可以更好地控制下載行為。
/**
* 通過(guò) URL 下載文件
* @param url 文件地址
* @param fileName 自定義文件名
*/
export const downloadFileByUrl = (url: string, fileName?: string) => {
const link = document.createElement('a')
link.href = url
// 如果提供了文件名,則設(shè)置 download 屬性
if (fileName) {
link.download = fileName
}
link.target = '_blank'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
}
文件流下載
如果后端返回的是文件流(Blob),由于瀏覽器無(wú)法直接解析這種數(shù)據(jù)格式作為下載源,我們需要通過(guò) URL.createObjectURL 將其轉(zhuǎn)換為一個(gè)臨時(shí)的 blob:URL,然后利用 <a> 標(biāo)簽觸發(fā)下載。
/**
* 通過(guò)文件流下載文件
* @param data 文件流數(shù)據(jù) (Blob | ArrayBuffer | string)
* @param fileName 下載后的文件名
* @param mimeType 文件的 MIME 類(lèi)型 (可選,如果不傳則嘗試從 data 中獲取或使用默認(rèn)值)
*/
export const downloadFileByStream = (data: any, fileName: string, mimeType?: string) => {
// 1. 優(yōu)先獲取數(shù)據(jù)的類(lèi)型
const type = mimeType || (data instanceof Blob ? data.type : 'application/octet-stream')
// 2. 將數(shù)據(jù)封裝為 Blob 對(duì)象
const blob = data instanceof Blob ? data : new Blob([data], { type })
// 3. 創(chuàng)建一個(gè)臨時(shí)的 URL 指向該 Blob 對(duì)象
const blobURL = window.URL.createObjectURL(blob)
// 4. 創(chuàng)建虛擬錨點(diǎn)觸發(fā)下載
const link = document.createElement('a')
link.href = blobURL
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 5. 下載執(zhí)行后釋放 URL 對(duì)象和 DOM 節(jié)點(diǎn)
document.body.removeChild(link)
// 不釋放可能導(dǎo)致內(nèi)存泄露,過(guò)早釋放可能會(huì)導(dǎo)致下載失敗,可以延遲觸發(fā)
window.URL.revokeObjectURL(blobURL)
}
前端生成 PDF
在有些業(yè)務(wù)上,需要純前端生成 PDF。
window.print()方法
這是調(diào)用瀏覽器原生打印功能最簡(jiǎn)單的方法。它會(huì)將當(dāng)前頁(yè)面的內(nèi)容渲染到打印預(yù)覽窗口中,用戶(hù)可以選擇保存為 PDF。
其實(shí)并不推薦,因?yàn)樵诤芏鄰?fù)雜的結(jié)構(gòu)中,需要做很多工作,才能達(dá)到理想的效果。 并且會(huì)有打印預(yù)覽彈窗,無(wú)法實(shí)現(xiàn)無(wú)感打印。
const handlePrint = () => {
window.print()
}
CSS 控制
為了讓打印出來(lái)的效果更好,我們通常需要使用 @media print 查詢(xún)來(lái)控制打印時(shí)的樣式。
@media print {
/* 隱藏不需要打印的元素,如導(dǎo)航欄、側(cè)邊欄、按鈕 */
.no-print {
display: none !important;
}
/* 調(diào)整打印區(qū)域的寬度 */
.print-container {
width: 100%;
margin: 0;
padding: 0;
}
/* 強(qiáng)制分頁(yè) */
.page-break {
page-break-after: always;
}
}html2canvas-pro+jsPDF
html2canvas 可以將網(wǎng)頁(yè)內(nèi)容轉(zhuǎn)換為圖片,然后 jsPDF 可以將圖片轉(zhuǎn)換為 PDF。
html2canvas-pro 是 html2canvas 的加強(qiáng)版分叉,完全兼容原版 API。它可以作為無(wú)縫替代品直接安裝并導(dǎo)入(只需將 import html2canvas from 'html2canvas' 改為 import html2canvas from 'html2canvas-pro')。它修復(fù)了原版在處理現(xiàn)代 CSS(如 object-fit、clip-path)時(shí)的許多渲染 Bug。
下面是通用的代碼,可用于 95% 的場(chǎng)景,該方法會(huì)自動(dòng)分頁(yè),且不會(huì)切斷元素。
import html2canvas from 'html2canvas-pro' // 推薦使用 pro 版本無(wú)縫替代
import jsPDF from 'jspdf'
/**
* 將指定 DOM 導(dǎo)出為 PDF
* @param domId 目標(biāo) DOM 元素的 ID
* @param title 導(dǎo)出的文件名
*/
export const exportPdf = async (domId: string, title?: string): Promise<void> => {
const ele = document.getElementById(domId)
if (!ele) throw new Error('未找到目標(biāo)元素')
const scale = window.devicePixelRatio > 1 ? window.devicePixelRatio : 2
// 獲取所有防截?cái)嘣兀ǚ乐乖乇环猪?yè)切開(kāi),如表格行、標(biāo)題、段落等)
const nodes = ele.querySelectorAll('tr, h2, h3, h4, h5, p, img')
const containerRect = ele.getBoundingClientRect()
// 同時(shí)收集元素的 top 和 bottom 坐標(biāo)
const breakPointsPx = Array.from(nodes).map((node) => {
const rect = node.getBoundingClientRect()
return {
top: rect.top - containerRect.top,
bottom: rect.bottom - containerRect.top,
}
})
// 生成畫(huà)布
const canvas = await html2canvas(ele, {
scale,
useCORS: true, // 允許圖片跨域
backgroundColor: '#ffffff',
})
const imgDataUrl = canvas.toDataURL('image/jpeg', 1.0)
// 初始化 PDF 對(duì)象:p-豎向,pt-點(diǎn)(單位),a4-紙張規(guī)格
const pdf = new jsPDF('p', 'pt', 'a4')
const a4Width = pdf.internal.pageSize.getWidth()
const a4Height = pdf.internal.pageSize.getHeight()
// 計(jì)算圖片縮放比例:根據(jù)寬度適配 A4
const ratio = a4Width / canvas.width
const imgWidth = a4Width
const imgHeight = canvas.height * ratio
// 將坐標(biāo)單位從 px 轉(zhuǎn)換為 pt (符合 PDF 內(nèi)部計(jì)算)
const breakPointsPt = breakPointsPx.map((bp) => ({
top: bp.top * ratio,
bottom: bp.bottom * ratio,
}))
const topMargin = 30 // 頁(yè)眉預(yù)留
const bottomMargin = 30 // 頁(yè)腳預(yù)留
const pageContentHeight = a4Height - topMargin - bottomMargin
let currentRenderY = 0 // 已完成渲染的 Y 軸偏移
while (currentRenderY < imgHeight) {
let expectedPageBottom = currentRenderY + pageContentHeight
let actualPageBottom = expectedPageBottom
// 判斷是不是最后一頁(yè)
if (expectedPageBottom >= imgHeight) {
actualPageBottom = imgHeight
} else {
// 只有不是最后一頁(yè),才去遍歷判斷是否被截?cái)?
for (let i = 0; i < breakPointsPt.length; i++) {
const { top, bottom } = breakPointsPt[i]
// 核心判斷:元素的頭在當(dāng)前頁(yè),但尾巴超出了當(dāng)前頁(yè)的底部,說(shuō)明被“腰斬”了
if (top > currentRenderY && top < expectedPageBottom && bottom > expectedPageBottom) {
actualPageBottom = top // 在被截?cái)嘣氐捻敳壳幸坏?,將其整體推到下一頁(yè)
break
}
}
}
if (actualPageBottom === currentRenderY) actualPageBottom = expectedPageBottom
// 1. 渲染當(dāng)前頁(yè)圖像(利用負(fù)偏移顯示指定區(qū)域)
pdf.addImage(imgDataUrl, 'JPEG', 0, topMargin - currentRenderY, imgWidth, imgHeight)
// 2. 頂部遮罩(覆蓋負(fù)偏移區(qū)域產(chǎn)生的重疊部分)
if (currentRenderY > 0) {
pdf.setFillColor(255, 255, 255)
pdf.rect(0, 0, a4Width, topMargin, 'F')
}
// 3. 底部遮罩(留白并遮擋截?cái)嗵幍臍堄埃?
const currentRenderBottomY = topMargin + (actualPageBottom - currentRenderY)
pdf.setFillColor(255, 255, 255)
pdf.rect(0, currentRenderBottomY, a4Width, a4Height - currentRenderBottomY, 'F')
currentRenderY = actualPageBottom
// 如果還沒(méi)畫(huà)完,添加新的一頁(yè)
if (currentRenderY + 5 < imgHeight) {
pdf.addPage()
}
}
const fileName = title ? `${title}_${Date.now()}` : Date.now().toString()
pdf.save(`${fileName}.pdf`)
}
用法案例
在 React 中使用該方案:
import { exportPdf } from './utils/pdf'
const ReportPage = () => {
const handleDownload = async () => {
try {
// 傳入容器 ID 和文件名
await exportPdf('pdf-content', '月度分析報(bào)告')
} catch (error) {
console.error('生成 PDF 失敗:', error)
}
}
return (
<div>
<button onClick={handleDownload}>下載報(bào)告</button>
{/* 這里的 ID 必須與 exportPdf 傳入的一致 */}
<div id="pdf-content" style={{ padding: '20px', background: '#fff' }}>
<h2>報(bào)表標(biāo)題</h2>
<p>這里是很長(zhǎng)很長(zhǎng)的內(nèi)容,可能會(huì)跨頁(yè)...</p>
<table>
<tbody>
<tr>
<td>數(shù)據(jù)行 1</td>
</tr>
{/* 這里的 tr 會(huì)被防截?cái)噙壿嬜詣?dòng)推送到下一頁(yè)容器中 */}
<tr>
<td>數(shù)據(jù)行 2</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
進(jìn)階:PDF 模板架構(gòu)設(shè)計(jì)
當(dāng)項(xiàng)目中需要管理多個(gè) PDF 模板時(shí),建議采用“容器與顯示分離”的架構(gòu),這樣可以保證模板的純凈度(只負(fù)責(zé) UI),同時(shí)方便在后臺(tái)靜默生成 PDF。
1. 目錄結(jié)構(gòu)建議
src/
├── components/
│ └── pdf-templates/ # 所有的 PDF UI 模板
│ ├── Contract.tsx # 合同模板
│ ├── Invoice.tsx # 發(fā)票模板
│ └── index.ts # 統(tǒng)一導(dǎo)出
└── utils/
└── pdf.ts # 核心 exportPdf 方法
2. 模板編寫(xiě)建議
模板組件應(yīng)該只接收 data Props,不處理任何業(yè)務(wù)邏輯。
// 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>
{/* 自由編寫(xiě)復(fù)雜的 PDF 樣式 */}
</div>
)
3. 數(shù)據(jù)獲取與導(dǎo)出架構(gòu)
推薦在需要導(dǎo)出 PDF 的頁(yè)面中,通過(guò)一個(gè)隱藏的“渲染容器”來(lái)實(shí)現(xiàn)。這樣可以在不影響主頁(yè)面 UI 的情況下,獲取最新的業(yè)務(wù)數(shù)據(jù)并生成 PDF。
// 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. 獲取業(yè)務(wù)數(shù)據(jù) (如從 API 獲取)
const res = await fetchOrderData()
setData(res)
// 2. 等待 React 渲染 DOM (利用 setTimeout 確保渲染完成)
setTimeout(async () => {
try {
await exportPdf('pdf-render-target', '業(yè)務(wù)合同')
} finally {
setIsExporting(false)
}
}, 100)
}
return (
<div>
<button onClick={startExport} disabled={isExporting}>
{isExporting ? '正在生成...' : '下載 PDF'}
</button>
{/* 通過(guò) Portal 將模板渲染在屏幕外,實(shí)現(xiàn)“無(wú)感”生成 */}
{isExporting &&
data &&
createPortal(
<div style={{ position: 'absolute', left: '-9999px', top: 0 }}>
<ContractTemplate data={data} />
</div>,
document.body
)}
</div>
)
}
4. 架構(gòu)優(yōu)勢(shì)
- 關(guān)注點(diǎn)分離:頁(yè)面只管觸發(fā),模板只管繪制,
utils只管轉(zhuǎn)換。 - 數(shù)據(jù)解耦:PDF 模板的數(shù)據(jù)可以由父頁(yè)面統(tǒng)一注入,也可以在
exportPdf調(diào)用前按需加載。 - 用戶(hù)無(wú)感:通過(guò)
createPortal將渲染目標(biāo)移出可視區(qū)域,用戶(hù)在頁(yè)面上感知不到“截圖”的過(guò)程。
到此這篇關(guān)于從文件流下載到自動(dòng)分頁(yè)解析前端PDF文件生成與導(dǎo)出功能的文章就介紹到這了,更多相關(guān)前端生成導(dǎo)出PDF內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
原生JavaScript實(shí)現(xiàn)無(wú)限滾動(dòng)加載效果
無(wú)限滾動(dòng)加載(Infinite?Scroll)是現(xiàn)代?Web?應(yīng)用最主流的列表加載方式,替代傳統(tǒng)分頁(yè),大幅提升用戶(hù)瀏覽體驗(yàn),下面小編就和大家詳細(xì)介紹一下如何使用原生JavaScript實(shí)現(xiàn)無(wú)限滾動(dòng)加載效果吧2026-04-04
悄悄用腳本檢查你訪問(wèn)過(guò)哪些網(wǎng)站的代碼
YouPorn是YouTube的成人自拍版,Alexa排名61。如果你登陸YouPorn主頁(yè),它會(huì)悄悄用腳本檢查你訪問(wèn)過(guò)哪些色情網(wǎng)站。2010-12-12
javascript中call,apply,bind的區(qū)別詳解
這篇文章主要介紹了javascript中call,apply,bind的區(qū)別詳解,幫助大家更好的理解和使用JavaScript,感興趣的朋友可以了解下2020-12-12
常用Javascript函數(shù)與原型功能收藏(必看篇)
下面小編就為大家?guī)?lái)一篇常用Javascript函數(shù)與原型功能收藏(必看篇)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2016-10-10

