最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

從文件流下載到自動(dòng)分頁(yè)解析前端PDF文件生成與導(dǎo)出功能

 更新時(shí)間:2026年03月18日 08:57:07   作者:許留山  
在工作中,我們經(jīng)常會(huì)遇到需要生成 PDF 的業(yè)務(wù),這篇文章將從文件流下載到自動(dòng)分頁(yè),為大家詳細(xì)解析前端PDF文件生成與導(dǎo)出功能的實(shí)現(xiàn)方法,希望對(duì)大家有所幫助

在工作中,我們經(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-prohtml2canvas 的加強(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)加載效果

    原生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
  • 繼續(xù)學(xué)習(xí)javascript閉包

    繼續(xù)學(xué)習(xí)javascript閉包

    這篇文章主要還是繼續(xù)學(xué)習(xí)javascript閉包,理解JavaScript的閉包是邁向高級(jí)JS程序員的必經(jīng)之路,需要的朋友可以參考下
    2015-12-12
  • 悄悄用腳本檢查你訪問(wèn)過(guò)哪些網(wǎng)站的代碼

    悄悄用腳本檢查你訪問(wèn)過(guò)哪些網(wǎng)站的代碼

    YouPorn是YouTube的成人自拍版,Alexa排名61。如果你登陸YouPorn主頁(yè),它會(huì)悄悄用腳本檢查你訪問(wèn)過(guò)哪些色情網(wǎng)站。
    2010-12-12
  • Element-plus安裝及基礎(chǔ)組件使用詳解

    Element-plus安裝及基礎(chǔ)組件使用詳解

    ElementPlus是一個(gè)基于Vue3的UI組件庫(kù),旨在提供豐富的HTML元素封裝,以簡(jiǎn)化前端開(kāi)發(fā),主要特點(diǎn)包括預(yù)定義樣式、事件處理、易用性等,為開(kāi)發(fā)者提供了一致且美觀的用戶(hù)界面,同時(shí)支持按需導(dǎo)入,提高項(xiàng)目效率,感興趣的朋友一起看看吧
    2024-09-09
  • javascript中call,apply,bind的區(qū)別詳解

    javascript中call,apply,bind的區(qū)別詳解

    這篇文章主要介紹了javascript中call,apply,bind的區(qū)別詳解,幫助大家更好的理解和使用JavaScript,感興趣的朋友可以了解下
    2020-12-12
  • 常用Javascript函數(shù)與原型功能收藏(必看篇)

    常用Javascript函數(shù)與原型功能收藏(必看篇)

    下面小編就為大家?guī)?lái)一篇常用Javascript函數(shù)與原型功能收藏(必看篇)。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2016-10-10
  • javascript限制文本框輸入值類(lèi)型的方法

    javascript限制文本框輸入值類(lèi)型的方法

    這篇文章主要介紹了javascript限制文本框輸入值類(lèi)型的方法,涉及javascript鼠標(biāo)事件、鍵盤(pán)按鍵及字符串的相關(guān)操作技巧,非常具有實(shí)用價(jià)值,需要的朋友可以參考下
    2015-05-05
  • 一篇文章讓你輕松記住js的隱式轉(zhuǎn)化

    一篇文章讓你輕松記住js的隱式轉(zhuǎn)化

    這篇文章主要給大家介紹了如何通過(guò)一篇文章讓你輕松記住js的隱式轉(zhuǎn)化的相關(guān)資料,通過(guò)在各種情況下發(fā)生的隱式類(lèi)型的例子,來(lái)詳細(xì)的了解隱式類(lèi)型轉(zhuǎn)換的過(guò)程,需要的朋友可以參考下
    2022-01-01
  • JS數(shù)組降維的幾種方法詳解

    JS數(shù)組降維的幾種方法詳解

    這篇文章主要介紹了JS數(shù)組降維的幾種方法,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2021-04-04
  • JSONP跨域請(qǐng)求

    JSONP跨域請(qǐng)求

    本文主要介紹了jsonp跨域請(qǐng)求的相關(guān)知識(shí)。具有很好的參考價(jià)值,下面跟著小編一起來(lái)看下吧
    2017-03-03

最新評(píng)論

尚志市| 泽库县| 同心县| 绥芬河市| 隆昌县| 呼伦贝尔市| 南开区| 宁波市| 抚宁县| 峨山| 温宿县| 鹰潭市| 枣强县| 长寿区| 许昌县| 来凤县| 驻马店市| 巴南区| 文成县| 泰州市| 洛川县| 运城市| 缙云县| 金秀| 鸡东县| 万盛区| 两当县| 溆浦县| 桐柏县| 育儿| 饶平县| 惠水县| 金昌市| 乡宁县| 昔阳县| 海原县| 沂南县| 自治县| 灵台县| 宜兰市| 临高县|