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为例
"dependencies": {
"pdfjs-dist": "^2.6.347",
"vue": "^2.5.2",
"vue-router": "^3.0.1"
}
组件代码如下:
- 目录组件
<template>
<div class="pdf-outline-wrapper">
<div v-if="!outline || outline.length === 0" class="empty-tip">無目錄信息</div>
<div class="outline-tree-root" v-else>
<div v-for="(item, index) in outline" :key="index" class="outline-node">
<div class="outline-item-row" @click="handleRowClick(item)">
<span
v-if="item.items && item.items.length > 0"
class="toggle-arrow"
:class="{ 'is-open': openStates[index] }"
@click.stop="toggleNode(index)"
>▶</span>
<span v-else class="toggle-placeholder"></span>
<span class="outline-title" :title="item.title">{
{
item.title }}</span>
</div>
<div v-if="item.items && item.items.length > 0" v-show="openStates[index]" class="outline-children">
<pdf-outline :outline="item.items" @on-click="forwardClick" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PdfOutline',
props: {
// 接收來自父組件的 PDF 原生目錄樹數據
outline: {
type: Array,
default: () => []
}
},
data () {
return {
// 記錄當前層級下各個節點的展開/折疊狀態
openStates: {
}
}
},
methods: {
// 切換展開狀態
toggleNode (index) {
this.$set(this.openStates, index, !this.openStates[index])
},
// 點擊當前行的跳轉邏輯
handleRowClick (item) {
if (item.dest) {
this.$emit('on-click', item.dest)
}
},
// 🚀 關鍵:將底層遞歸組件冒泡上來的點擊事件,繼續向上拋給最外層的主組件
forwardClick (dest) {
this.$emit('on-click', dest)
}
}
}
</script>
<style scoped>
.pdf-outline-wrapper {
width: 100%;
box-sizing: border-box;
padding: 4px;
}
.empty-tip {
color: #909399;
text-align: center;
margin-top: 30px;
font-size: 13px;
}
.outline-node {
display: flex;
flex-direction: column;
width: 100%;
}
/* 🚀 核心改造:整行改為 Flex 佈局,強制靠左對齊,保證視覺起點統一 */
.outline-item-row {
display: flex;
align-items: center;
justify-content: flex-start; /* 確保所有內容從最左側開始排 */
padding: 8px 6px;
cursor: pointer;
font-size: 13px;
color: #303133;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
/* 鼠標懸停效果:背景微灰,文字變藍 */
.outline-item-row:hover {
background-color: #f2f6fc;
color: #409eff;
}
/* 展開/折疊小箭頭:縮小尺寸,改用精緻的幾何箭頭,並固定寬度 */
.toggle-arrow {
font-size: 10px;
color: #a8abb2;
margin-right: 6px;
cursor: pointer;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0; /* 防止被擠壓 */
}
/* 展開時旋轉 90 度 */
.toggle-arrow.is-open {
transform: rotate(90deg);
color: #606266;
}
/* 沒有子節點時的佔位符:寬度必須與小箭頭完全一致(14px + 6px 邊距 = 20px) */
.toggle-placeholder {
width: 20px;
flex-shrink: 0;
}
/* 🚀 文字部分優化:強制左對齊、禁止換行、超出長度自動省略號 */
.outline-title {
flex: 1;
text-align: left; /* 強制文字左對齊 */
white-space: nowrap; /* 絕對不換行 */
overflow: hidden; /* 超出部分隱藏 */
text-overflow: ellipsis; /* 超出時顯示 ... */
padding-right: 4px;
}
/* 🚀 核心改造:去掉原本雜亂的虛線,改用乾淨的空白縮進 */
/* 子級目錄向右精準縮進 16px */
.outline-children {
padding-left: 16px;
margin-left: 6px;
/* 💡 去掉了 border-left 的 dashed 虛線,因為虛線在多層級時會顯得很碎很亂。
改用純粹的留白(Whitespace),大廠主流(如 Adobe, Notion, VSCode)的樹狀目錄都是靠純留白來展現高級感的。 */
}
</style>
- pdf预览主组件
<template>
<div class="pdf-viewer-container" ref="pdfViewerContainer" v-loading="sLoading">
<div class="pdf-sidebar">
<div class="sidebar-tabs">
<button :class="{ active: activeTab === 'outline' }" @click="activeTab = 'outline'">目錄</button>
<button :class="{ active: activeTab === 'search' }" @click="activeTab = 'search'">搜索</button>
</div>
<div v-show="activeTab === 'outline'" class="sidebar-content outline-tree">
<pdf-outline :outline="outline" @on-click="scrollToDest" />
</div>
<div v-show="activeTab === 'search'" class="sidebar-content search-panel">
<div class="search-box">
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="text"
placeholder="輸入關鍵字,回車搜索..."
@keyup.enter="handleSearch"
/>
<span
v-if="searchQuery"
class="clear-icon"
@click="clearSearch"
title="清除搜索"
></span>
</div>
<button @click="handleSearch">搜索</button>
</div>
<div class="search-status" v-if="searchStatusText">
{
{
searchStatusText }}
</div>
<div class="search-results">
<div
v-for="(res, index) in searchResults"
:key="index"
class="result-item"
@click="highlightAndScrollToPage(res.pageNo)"
>
<div class="result-page-badge">大約 第 {
{
res.pageNo }} 頁</div>
<div class="result-context" v-html="res.highLightText"></div>
</div>
</div>
</div>
</div>
<div class="pdf-main-content" ref="scrollContainer" @scroll


502

被折叠的 条评论
为什么被折叠?



