特大pdf文件在线预览技术方案

特大pdf文件在线预览技术方案

  • [1 技术背景](#1 技术背景)
  • [2 解决方案](#2 解决方案)
  • [3 技术选型](#3 技术选型)
  • [4 实现](#4 实现)

1 技术背景

在一些信息化系统中,比如传统建筑工程领域的招投标,标书体积特别大,动则几百兆、几千页的pdf。这给在线预览评标等工作提出了技术挑战。解决这个问题的一般思路有两种,一是下载文件到本地,使用wps,office等办公软件打开来预览,二是直接在浏览器上打开预览。

但是,不论下载还是浏览器直接预览,都需要全量下载整个pdf文件,下载过程漫长,体验较差;如果浏览器直接预览,除了下载文件时间长问题,还需面临一次性加载pdf文件到浏览器导致浏览器内存吃紧而可能出现的卡顿和崩溃问题。

2 解决方案

解决问题的思路是按需请求 + 懒加载 + 内存换页 + 网络请求防抖。

  • 按需请求

    不要一次性下载整个大几百兆的pdf文件,而是根据需求下载预览目标页的"片",一次只下载小几百k的数据,预览哪里就下载哪里的片。

  • 懒加载

    在前端设计中,只有进入可视区的页内容才被渲染显示。

  • 内存换页

    被滑出可视区的页码内容将从内存中移除,减少内存使用。

  • 防抖

    当用户快速滚动鼠标进行翻页时,对于停留在可视区不超过200毫秒的页面的数据请求,需要取消http的请求。

3 技术选型

  • 前端技术选型

    我们无需手戳原始码,我们遇到的问题都是前人所遇到的,他们已经走完了我们现在面临的崎岖道路。pdf.js库是一个伟大的库,它可以做到我们上面讨论中所想要的效果。

  • 后端技术选型

    后端实现上无需编写任何源代码,只需在网关nginx上添加一些支持http range的配置。

4 实现

  • 前端实现
    示例代码以vue2.5.2和pdfjs-dist2.6.347为例
json 复制代码
"dependencies": {
    "pdfjs-dist": "^2.6.347",
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  }

组件代码如下:

js 复制代码
<template>
  <div class="pdf-viewer-container">
    <div class="pdf-sidebar" v-if="outline && outline.length > 0">
      <h3>文件目錄</h3>
      <div class="outline-tree">
        <div
          v-for="(item, index) in outline"
          :key="index"
          class="outline-item"
          @click="scrollToDest(item.dest)"
        >
          {{ item.title }}
        </div>
      </div>
    </div>

    <div class="pdf-main-content" ref="scrollContainer" @scroll="onScroll">
      <div class="pdf-pages-wrapper">
        <div
          v-for="pageNo in totalPages"
          :key="pageNo"
          :id="`pdf-page-${pageNo}`"
          :data-page-number="pageNo"
          class="page-placeholder"
          :style="pageStyles[pageNo] || defaultPageStyle"
        >
          <canvas v-if="renderedPages.includes(pageNo)" :id="`canvas-${pageNo}`"></canvas>
          <div v-else class="page-loading-status">加載中...</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 引入 pdf.js
import * as pdfjsLib from 'pdfjs-dist'
// 必須配置 worker,建議使用與 pdfjs-dist 版本一致的 cdn 或本地文件
// pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`

import PdfWorker from 'pdfjs-dist/build/pdf.worker.entry'
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker

export default {
  name: 'PdfViewer',
  props: {
    pdfUrl: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      pdfDoc: null,
      totalPages: 0,
      outline: [], // 目錄數據
      pageStyles: {}, // 存儲每頁的寬高樣式,用於佔位
      defaultPageStyle: { height: '800px', width: '100%' }, // 默認預估高度
      renderedPages: [], // 當前真正渲染了 Canvas 的頁碼數組
      observer: null, // IntersectionObserver 實例
      pageScale: 1.5 // 縮放比例
    }
  },
  watch: {
    pdfUrl: {
      handler: 'initPdf',
      immediate: true
    }
  },
  beforeDestroy () {
    // 組件銷毀時清理內存和監聽
    if (this.observer) {
      this.observer.disconnect()
    }
    if (this.pdfDoc) {
      this.pdfDoc.destroy()
    }
  },
  methods: {
    // 初始化 PDF
    async initPdf () {
      if (!this.pdfUrl) return

      try {
        const loadingTask = pdfjsLib.getDocument({
          url: this.pdfUrl,
          // ================= 🚀 超大文件優化配置項 =================
          disableRange: false, // 開啟分片請求
          disableAutoFetch: true, // 禁止自動預取未看頁面的數據
          disableStream: true, // 徹底關閉流式傳輸,強制走純分片,防止自動下載剩餘部分
          rangeChunkSize: 65536 * 4, // 分片塊大小設為 256KB,平衡請求頻率與單次開銷
          pdfBug: false, // 關閉調試
          verbosity: 0, // 關閉日誌,減少 CPU 字符串拼接與打印開銷
          // =======================================================
          cMapUrl: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/cmaps/`,
          cMapPacked: true
        })

        this.pdfDoc = await loadingTask.promise
        this.totalPages = this.pdfDoc.numPages

        // 1. 優先獲取第一頁尺寸並撐開滾動條(幾毫秒內完成,防止滾動條塌陷)
        await this.initPageSizes()

        // 2. 啟動交叉監聽,讓第一頁及可視區頁面立刻渲染出來,用戶能先看到畫面
        this.$nextTick(() => {
          this.initIntersectionObserver()
        })

        // 3. 🚀 解決初始化卡頓核心:將繁重的「目錄解析」延遲 500ms 執行
        // 避開首屏 Canvas 渲染的 CPU 峰值,等瀏覽器緩過神來再去加載目錄
        setTimeout(() => {
          this.loadOutlineAsync()
        }, 500)
      } catch (error) {
        console.error('PDF 初始化失敗:', error)
      }
    },

    // 獲取第一頁的尺寸,粗略預估所有頁面高度,避免滾動條塌陷
    async initPageSizes () {
      try {
        const firstPage = await this.pdfDoc.getPage(1)
        const viewport = firstPage.getViewport({ scale: this.pageScale })
        const style = {
          width: `${viewport.width}px`,
          height: `${viewport.height}px`
        }

        // 為了性能優化,此處數據結構較大,但由於只是扁平對象,性能損耗可控
        const styles = {}
        for (let i = 1; i <= this.totalPages; i++) {
          styles[i] = style
        }
        this.pageStyles = styles
      } catch (e) {
        console.error('獲取頁面尺寸失敗:', e)
      }
    },

    // 🚀 異步加載目錄並凍結數據,防止 Vue 2 深度劫持導致卡死
    async loadOutlineAsync () {
      try {
        const rawOutline = await this.pdfDoc.getOutline()
        if (!rawOutline || rawOutline.length === 0) return

        // 🚀 核心優化:使用 Object.freeze 凍結目錄數據!
        // 幾千頁的目錄包含巨量節點,不凍結的話 Vue 2 會遍歷每個節點做響應式監聽,直接卡死瀏覽器。
        this.outline = Object.freeze(rawOutline)
      } catch (e) {
        console.error('讀取 PDF 目錄失敗:', e)
      }
    },

    // 初始化交叉監聽
    initIntersectionObserver () {
      if (this.observer) {
        this.observer.disconnect()
      }

      const options = {
        root: this.$refs.scrollContainer, // 監聽滾動容器
        rootMargin: '300px 0px 300px 0px', // 上下預留 300px 緩衝區,提前加載即將滑入的頁面
        threshold: 0.01 // 只要有 1% 進入可視區就觸發
      }

      this.observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          const pageNo = parseInt(entry.target.dataset.pageNumber)

          if (entry.isIntersecting) {
            // 進入可視區:加入渲染隊列
            if (!this.renderedPages.includes(pageNo)) {
              this.renderedPages.push(pageNo)
              this.$nextTick(() => {
                this.renderPageCanvas(pageNo)
              })
            }
          } else {
            // 移出可視區:從渲染隊列移除(Vue 的 v-if 會徹底銷毀 Canvas DOM,釋放記憶體)
            const index = this.renderedPages.indexOf(pageNo)
            if (index > -1) {
              this.renderedPages.splice(index, 1)
            }
          }
        })
      }, options)

      // 監聽所有分頁佔位符
      const placeholders = this.$refs.scrollContainer.querySelectorAll('.page-placeholder')
      placeholders.forEach(el => this.observer.observe(el))
    },

    // 真正渲染 Canvas 的方法
    async renderPageCanvas (pageNo) {
      // 雙重檢查:如果在異步獲取頁面時用戶已經快速滑出去了,則直接中斷
      if (!this.renderedPages.includes(pageNo)) return

      try {
        const page = await this.pdfDoc.getPage(pageNo)
        const canvas = document.getElementById(`canvas-${pageNo}`)
        if (!canvas) return

        const ctx = canvas.getContext('2d')
        const viewport = page.getViewport({ scale: this.pageScale })

        canvas.width = viewport.width
        canvas.height = viewport.height

        const renderContext = {
          canvasContext: ctx,
          viewport: viewport
        }

        const renderTask = page.render(renderContext)
        await renderTask.promise
      } catch (error) {
        console.error(`渲染第 ${pageNo} 頁出錯:`, error)
      }
    },

    // 目錄跳轉
    async scrollToDest (dest) {
      if (!dest) return
      try {
        let pageIdx
        if (typeof dest === 'string') {
          pageIdx = await this.pdfDoc.getPageIndex(JSON.parse(dest))
        } else if (Array.isArray(dest)) {
          pageIdx = await this.pdfDoc.getPageIndex(dest[0])
        }

        const pageNo = pageIdx + 1
        const targetEl = document.getElementById(`pdf-page-${pageNo}`)
        if (targetEl) {
          targetEl.scrollIntoView({ behavior: 'smooth' })
        }
      } catch (e) {
        console.error('跳轉失敗:', e)
      }
    },

    onScroll () {
      // 預留滾動監聽接口,可用於做防抖更新當前閱讀頁碼等擴展功能
    }
  }
}
</script>

<style scoped>
.pdf-viewer-container {
  display: flex;
  width: 100%;
  height: 100vh; /* 撐滿螢幕高度 */
  overflow: hidden;
  background-color: #525659;
}

/* 左側側邊欄目錄 */
.pdf-sidebar {
  width: 300px;
  height: 100%;
  background-color: #ffffff;
  border-right: 1px solid #e0e0e0;
  display: flex;
  flex-direction: column;
}
.pdf-sidebar h3 {
  padding: 15px;
  margin: 0;
  border-bottom: 1px solid #eee;
  font-size: 16px;
  color: #333;
}
.outline-tree {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}
.outline-item {
  padding: 8px 12px;
  cursor: pointer;
  font-size: 14px;
  color: #444;
  border-radius: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: background-color 0.2s;
}
.outline-item:hover {
  background-color: #f5f5f5;
  color: #409eff;
}

/* 右側 PDF 滾動主區域 */
.pdf-main-content {
  flex: 1;
  height: 100%;
  overflow-y: auto;
  position: relative;
  scroll-behavior: smooth;
}

.pdf-pages-wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px 0;
}

/* 分頁佔位器(核心:負責撐開高度,維持滾動條比例) */
.page-placeholder {
  margin-bottom: 20px;
  background-color: #ffffff;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
}

canvas {
  width: 100%;
  height: 100%;
  display: block;
}

.page-loading-status {
  color: #999;
  font-size: 14px;
}
</style>

父组件调用方式:

js 复制代码
<template>
  <div class="hello">
  <PdfViewer pdfUrl="https://minio.abcd.com/bucketname/file1.pdf" />
  </div>
</template>

<script>
import PdfViewer from '@/components/PdfViewer.vue'
export default {
  name: 'HelloWorld',
  components: {
    PdfViewer
  },
  data () {
    return {
      msg: 'Welcome to Your Vue.js App'
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.hello {
  margin: 20px;
  border: 1px solid rgb(139, 139, 139);
}
</style>
  • 后端

一般情况下,设计良好的系统都是文件系统与应用系统解耦的。本文以minio文件系统为例,在后端解析一个域名,反向代理到minio端口,在反向代理的配置里,添加对Http Range的支持,完整的反向代理nginx配置如下:

json 复制代码
#PROXY-START/

location ^~ /
{
    proxy_pass http://xxx.xxx.xxx.xxx:9000/; #minio服务
    proxy_set_header Host xxx.xxx.xxx.xxx;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_http_version 1.1;
    # proxy_hide_header Upgrade;
    
    proxy_set_header Range $http_range;
    proxy_set_header If-Range $http_if_range;
    
    proxy_buffering off;
    proxy_cache off;
    
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Range,Accept-Ranges,Content-Type,Authorization' always;
    
    # 核心:暴露分片響應頭,否則本地調試(localhost)瀏覽器會阻斷分片
    add_header 'Access-Control-Expose-Headers' 'Accept-Ranges,Content-Length,Content-Range' always;

    # 處理瀏覽器的 OPTIONS 預檢請求
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Range,Accept-Ranges,Content-Type,Authorization' always;
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
    }

    add_header X-Cache $upstream_cache_status;
    #Set Nginx Cache

    set $static_fileHPrgWjK2 0;
    if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
    {
        set $static_fileHPrgWjK2 1;
        expires 1m;
    }
    if ( $static_fileHPrgWjK2 = 0 )
    {
        add_header Cache-Control no-cache;
    }
}
#PROXY-END/

其中以下的配置是与Http range协议有关的:

json 复制代码
proxy_set_header Range $http_range;
    proxy_set_header If-Range $http_if_range;
    
    proxy_buffering off;
    proxy_cache off;
    
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Range,Accept-Ranges,Content-Type,Authorization' always;
    
    # 核心:暴露分片響應頭,否則本地調試(localhost)瀏覽器會阻斷分片
    add_header 'Access-Control-Expose-Headers' 'Accept-Ranges,Content-Length,Content-Range' always;

    # 處理瀏覽器的 OPTIONS 預檢請求
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Range,Accept-Ranges,Content-Type,Authorization' always;
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
    }
相关推荐
GuWen_yue1 小时前
吃透二叉树与递归!60分钟掌握树结构核心+解题思路
javascript·算法
去码头整点薯条ing1 小时前
某红书笔记接口逆向【x-s参数】
javascript·爬虫·python
難釋懷1 小时前
Nginx测试工具charles
运维·nginx·php
weixin_li152********1 小时前
《Angular 中优雅地处理枚举值:Map + *ngIf as 替代多次 *ngIf》
javascript·vue.js·angular.js
放下华子我只抽RuiKe51 小时前
FastAPI 全栈后端(五):后台任务与消息队列
前端·javascript·react.js·ai·前端框架·fastapi·ai编程
丷丩1 小时前
MapLibre GL JS第44课:生成并添加缺失图标
前端·javascript·gis·mapblibre gl js
风曦Kisaki2 小时前
#Linux监控与安全Day02:Zabbix 自动发现,Zabbix 报警机制,Zabbix 主动监控,监控 Nginx 服务
linux·运维·nginx·安全·自动化·云计算·zabbix
zyplayer-doc2 小时前
知识库官方CLI工具已发布并开源,以及重写思维导图编辑器,提供更完整的编辑能力,zyplayer-doc 2.6.6 发布啦!
人工智能·安全·pdf·编辑器·创业创新
庖丁AI2 小时前
PDF转Markdown工具怎么选?AI知识库和RAG场景要注意什么
人工智能·pdf·格式转换