特大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;
}