Vue3 PDF 预览组件设计与实现分析
引言
PDF 预览是现代 Web 应用中常见的功能需求,尤其是在文档管理、在线阅读等场景下。本文将深入分析一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨其设计思路、核心功能和优化策略,为开发者提供参考。
组件整体架构
该组件采用 Vue3 的 Composition API 开发,结合 PDF.js 库实现 PDF 文档的加载、渲染和交互。整体架构分为以下几个主要部分:
1. 组件结构与布局
组件采用了清晰的三层布局结构:
- 头部区域:包含标题和关闭按钮,用于展示文档标题和控制组件显示/隐藏
- 内容区域:分为 PDF 页面容器和加载指示器
- PDF 渲染区域:负责 PDF 页面的渲染和滚动显示
vue
<template>
<div class="pdf-viewer-container" ref="container">
<div class="pdf-header">
<!-- 头部标题和关闭按钮 -->
</div>
<div class="pdf-content">
<div class="pdf-pages-wrapper">
<div class="pdf-pages-container" ref="pagesContainer" @scroll.passive="handleScroll">
<div class="pdf-canvas-container" ref="canvasContainer"></div>
</div>
<!-- PDF加载中指示器 -->
<div class="spin-container" v-if="loading">
<n-spin size="large" />
</div>
</div>
</div>
</div>
</template>
2. 核心状态管理
组件通过 Vue3 的响应式 API 管理关键状态:
| 状态变量 | 类型 | 作用 |
|---|---|---|
loading |
boolean | 控制加载指示器显示 |
pdfDoc |
object | PDF 文档实例 |
totalPages |
number | 文档总页数 |
scale |
number | 页面缩放比例 |
currentPageNumber |
number | 当前页码 |
visiblePages |
number | 可见区域前后渲染页数 |
renderedPages |
array | 已渲染页面索引 |
pageHeight |
number | 单页高度 |
renderingPages |
Set | 正在渲染的页面集合 |
renderQueue |
array | 页面渲染队列 |
核心功能实现
1. PDF 文档加载
组件在 onMounted 钩子中调用 initPDF 方法初始化 PDF 文档:
javascript
const initPDF = async () => {
try {
loading.value = true;
await nextTick();
const loadingTask = pdfjsLib.getDocument({
url: props.src,
cMapUrl: "/cmaps/",
cMapPacked: true,
});
const doc = await loadingTask.promise;
pdfDoc.value = doc;
totalPages.value = doc.numPages;
// 设置默认缩放比例和页面高度
const defaultScale = 1.0;
scale.value = defaultScale;
if (totalPages.value > 0) {
const firstPage = await doc.getPage(1);
const viewport = firstPage.getViewport({ scale: defaultScale });
pageHeight.value = viewport.height;
}
// 渲染可见区域页面
await renderVisiblePages();
loading.value = false;
} catch (error) {
loading.value = false;
console.error("PDF加载失败:", error);
}
};
2. 虚拟滚动机制
为了优化大型 PDF 文档的性能,组件实现了虚拟滚动机制,只渲染可见区域附近的页面:
- 可见区域计算 :通过
getVisiblePageRange方法计算当前可见区域需要渲染的页面范围 - 渲染队列管理 :使用
renderQueue和isRenderingQueue控制页面渲染顺序 - 异步渲染:采用异步方式渲染页面,避免阻塞主线程
- 页面清理:自动清理不可见区域的页面,释放资源
javascript
const getVisiblePageRange = () => {
if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
const currentPage = currentPageNumber.value;
let start = Math.max(1, currentPage - 5);
let end = Math.min(totalPages.value, currentPage + 5);
start = Math.max(1, start - 2);
end = Math.min(totalPages.value, end + 2);
return { start, end };
};
3. 页面渲染逻辑
页面渲染是组件的核心功能,通过 renderPage 方法实现:
- Canvas 创建与管理:为每个页面创建独立的 Canvas 元素,并按顺序插入到容器中
- 页面渲染 :使用 PDF.js 的
page.render()方法将页面内容渲染到 Canvas 上 - 缩放处理:根据当前缩放比例调整 Canvas 显示大小
- 错误处理:完善的错误捕获和日志记录机制
javascript
const renderPage = async (pageNum) => {
if (!pdfDoc.value || !canvasContainer.value) return;
if (pageNum < 1 || pageNum > totalPages.value) return;
// 检查页面是否正在渲染中,如果是则跳过
if (renderingPages.value.has(pageNum)) {
return;
}
// 标记页面正在渲染
renderingPages.value.add(pageNum);
try {
const page = await pdfDoc.value.getPage(pageNum);
const viewport = page.getViewport({
scale: 2.0,
rotation: 0
});
// 创建或获取 Canvas 元素
let canvas = pageCanvasRefs.value[pageNum];
if (!canvas) {
// Canvas 创建逻辑...
}
// 设置 Canvas 尺寸
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
// 获取绘图上下文并渲染页面
const context = canvas.getContext('2d');
if (!context) return;
await page.render({
canvasContext: context,
viewport,
}).promise;
// 应用当前缩放比例
canvas.style.transform = `scale(${scale.value})`;
} catch (error) {
console.error(`渲染页面 ${pageNum} 失败:`, error);
} finally {
// 标记页面渲染完成
renderingPages.value.delete(pageNum);
}
};
4. 滚动事件处理
组件通过监听滚动事件实现页面的动态加载和清理:
- 防抖处理 :使用
lodash-es的debounce函数优化滚动事件,避免频繁触发 - 当前页码计算:根据滚动位置计算当前浏览的页码
- 动态渲染 :调用
renderVisiblePages方法渲染可见区域页面
javascript
const handleScroll = debounce(() => {
if (!pagesContainer.value) return;
const scrollContainer = pagesContainer.value;
const scrollTop = scrollContainer.scrollTop;
const gap = props.pageGap || 20;
const unit = pageHeight.value + gap;
let page = 1;
if (unit > 0) {
page = Math.floor(scrollTop / unit) + 1;
}
if (page < 1) page = 1;
if (page > totalPages.value) page = totalPages.value;
currentPageNumber.value = page;
renderVisiblePages();
}, 500);
性能优化策略
1. 虚拟滚动优化
- 渲染范围控制:只渲染可见区域前后各 7 页(总共 15 页左右)
- 动态清理:自动清理距离可见区域较远的页面,释放内存和 DOM 节点
- 渲染队列:采用队列机制管理页面渲染,避免同时渲染过多页面导致性能问题
2. 渲染性能优化
- 高分辨率渲染:使用 2.0 倍缩放渲染 Canvas,提高页面清晰度
- 按需渲染:仅在页面进入可见区域时渲染,避免不必要的计算和绘制
- 异步渲染:页面渲染采用异步方式,不阻塞主线程
3. 内存管理
- Canvas 复用:对于已经渲染过的页面,保存 Canvas 引用,避免重复创建
- 及时清理:组件卸载时释放 PDF 文档实例和 Canvas 资源
- 渲染状态管理:使用 Set 数据结构跟踪正在渲染的页面,避免重复渲染
代码亮点与最佳实践
1. 事件处理优化
- 使用 passive 滚动事件 :
@scroll.passive="handleScroll"提高滚动性能 - 防抖处理:减少滚动事件触发频率,优化性能
2. 组件生命周期管理
在组件卸载时,释放所有资源,避免内存泄漏:
javascript
onUnmounted(() => {
if (pdfDoc.value) {
pdfDoc.value.destroy();
}
for (const key in pageCanvasRefs.value) {
const c = pageCanvasRefs.value[Number(key)];
if (c && c.parentNode) c.parentNode.removeChild(c);
}
pageCanvasRefs.value = {};
renderedPages.value = [];
});
应用场景与扩展方向
该组件适用于以下场景:
- 文档管理系统:用于在线预览上传的 PDF 文档
- 在线阅读平台:提供流畅的 PDF 阅读体验
- 报表系统:用于预览和导出报表文档
- 教育平台:在线教材、课件预览
扩展方向
- 添加页码导航:允许用户直接跳转到指定页码
- 实现缩放控制:提供缩放按钮,允许用户调整页面大小
- 添加文本搜索功能:支持在 PDF 文档中搜索文本
- 实现页面旋转:允许用户旋转页面方向
- 添加书签功能:支持添加和管理文档书签
总结
本文深入分析了一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨了其设计思路、核心功能和优化策略。该组件通过虚拟滚动、按需渲染、异步处理等技术,实现了高效的 PDF 文档预览功能,具有良好的性能表现和用户体验。
对于开发者来说,该组件提供了一个优秀的参考案例,展示了如何在 Vue3 项目中实现复杂的第三方库集成和高性能交互功能。通过学习其设计思想和实现细节,开发者可以更好地理解和应用 Vue3 的 Composition API,以及如何进行前端性能优化。
随着 Web 技术的不断发展,PDF 预览功能的需求将越来越多样化和复杂化。开发者可以在此基础上,结合实际业务需求,进一步扩展和优化该组件,提供更加丰富和高效的 PDF 预览体验。
全部代码
vue
<template>
<div class="pdf-viewer-container" ref="container">
<div class="pdf-header">
<div class="pdf-header-title">
<h2>{{ props.title }}</h2>
</div>
<div class="pdf-header-actions">
<n-button quaternary circle @click="close">
<template #icon>
<n-icon>
<Close />
</n-icon>
</template>
</n-button>
</div>
</div>
<div class="pdf-content">
<!-- 右侧主内容 - 多页容器 -->
<div class="pdf-pages-wrapper">
<div class="pdf-pages-container" ref="pagesContainer" :style="{
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
'--page-gap': pageGap + 'px',
}" @scroll.passive="handleScroll">
<div class="pdf-canvas-container" ref="canvasContainer"></div>
</div>
<!-- PDF加载中指示器 -->
<div class="spin-container" v-if="loading">
<n-spin size="large" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
onMounted,
onUnmounted,
shallowRef,
nextTick,
} from "vue";
import * as pdfjsLib from "pdfjs-dist/build/pdf";
import { NIcon, NSpin } from "naive-ui";
import {debounce} from 'lodash-es'
import {
Close,
} from "@vicons/carbon";
const loading = ref(false);
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url).href;
const props = defineProps({
src: {
type: String,
required: true,
},
title: {
type: String,
default: 'pdf预览',
},
pageGap: {
type: Number,
default: 20,
},
});
const pagesContainer = ref<HTMLDivElement | null>(null);
const canvasContainer = ref<HTMLDivElement | null>(null);
const pageCanvasRefs = ref<Record<number, HTMLCanvasElement>>({});
const pdfDoc = shallowRef<any>(null);
const totalPages = ref(0);
const scale = ref(2.0);
const currentPageNumber = ref(1);
// 渲染单页PDF到canvas
const renderPage = async (pageNum) => {
if (!pdfDoc.value || !canvasContainer.value) return;
if (pageNum < 1 || pageNum > totalPages.value) return;
// 检查页面是否正在渲染中,如果是则跳过
if (renderingPages.value.has(pageNum)) {
return;
}
// 标记页面正在渲染
renderingPages.value.add(pageNum);
try {
const page = await pdfDoc.value.getPage(pageNum);
const viewport = page.getViewport({
scale: 2.0,
rotation: 0
});
let canvas = pageCanvasRefs.value[pageNum];
if (!canvas) {
// 创建新canvas
canvas = document.createElement('canvas');
canvas.className = `pdf-page-canvas page-${pageNum}`;
canvas.style.display = 'block';
canvas.style.margin = '0 auto var(--page-gap, 20px) auto';
canvas.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
canvas.style.backgroundColor = 'white';
canvas.style.transition = 'box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease';
canvas.style.transformOrigin = 'center center';
// 确保页面按顺序添加到DOM中
// 查找当前页码应该插入的位置
const existingPages = Array.from(canvasContainer.value.children);
let insertIndex = existingPages.length;
for (let i = 0; i < existingPages.length; i++) {
const child = existingPages[i];
if (child.className.includes('pdf-page-canvas')) {
const childPageNum = parseInt(child.className.match(/page-(\d+)/)[1]);
if (childPageNum > pageNum) {
insertIndex = i;
break;
}
}
}
// 按顺序插入canvas
canvasContainer.value.insertBefore(canvas, existingPages[insertIndex] || null);
pageCanvasRefs.value[pageNum] = canvas;
}
// 设置canvas尺寸
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
// 获取绘图上下文
const context = canvas.getContext('2d');
if (!context) return;
// 渲染页面
await page.render({
canvasContext: context,
viewport,
}).promise;
// 应用当前的缩放transform
canvas.style.transform = `scale(${scale.value})`;
} catch (error) {
console.error(`渲染页面 ${pageNum} 失败:`, error);
} finally {
// 标记页面渲染完成
renderingPages.value.delete(pageNum);
}
};
// 虚拟滚动相关变量
const visiblePages = ref(10); // 可见区域前后各渲染5页,总共10页
const renderedPages = ref([]); // 当前已渲染的页面索引
const pageHeight = ref(0); // 单页高度
const renderingPages = ref(new Set()); // 正在渲染的页面集合
// 页面渲染队列,确保按顺序渲染
const renderQueue = ref([]);
let isRenderingQueue = ref(false);
// 计算可见区域的页面范围
const getVisiblePageRange = () => {
if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
const currentPage = currentPageNumber.value;
let start = Math.max(1, currentPage - 5);
let end = Math.min(totalPages.value, currentPage + 5);
start = Math.max(1, start - 2);
end = Math.min(totalPages.value, end + 2);
return { start, end };
};
// 处理渲染队列
const processRenderQueue = async () => {
if (isRenderingQueue.value) return;
isRenderingQueue.value = true;
try {
while (renderQueue.value.length > 0) {
const pageNum= renderQueue.value.shift();
// 跳过已经渲染或正在渲染的页面
if (renderedPages.value.includes(pageNum) || renderingPages.value.has(pageNum)) {
continue;
}
try {
// 渲染页面
await renderPage(pageNum);
// 页面渲染完成后添加到已渲染列表
if (!renderedPages.value.includes(pageNum)) {
renderedPages.value.push(pageNum);
renderedPages.value.sort((a, b) => a - b);
}
} catch (innerError) {
console.error(`渲染页面 ${pageNum} 失败:`, innerError);
}
}
} catch (error) {
console.error('渲染队列处理异常:', error);
} finally {
isRenderingQueue.value = false;
}
};
// 渲染可见区域页面
const renderVisiblePages = async () => {
if (!pdfDoc.value || !canvasContainer.value) return;
const { start, end } = getVisiblePageRange();
// 收集需要渲染的页面
const pagesToRender = [];
const currentQueueSet = new Set(renderQueue.value);
for (let i = start; i <= end; i++) {
if (!renderedPages.value.includes(i) &&
!renderingPages.value.has(i) &&
!currentQueueSet.has(i)) {
pagesToRender.push(i);
}
}
if (pagesToRender.length > 0) {
// 添加到渲染队列,按顺序渲染
renderQueue.value.push(...pagesToRender.sort((a, b) => a - b));
// 处理渲染队列
processRenderQueue();
}
// 清理不可见的页面(确保不在渲染中)
const pagesToRemove = renderedPages.value.filter(
pageNum => pageNum < start - 5 || pageNum > end + 5
);
for (const pageNum of pagesToRemove) {
// 检查页面是否正在渲染中,如果是则跳过清理
if (renderingPages.value.has(pageNum)) {
continue;
}
const canvas = pageCanvasRefs.value[pageNum];
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
delete pageCanvasRefs.value[pageNum];
}
renderedPages.value = renderedPages.value.filter(p => p !== pageNum);
}
};
// 初始化 PDF
const initPDF = async () => {
try {
loading.value = true;
await nextTick();
// 确保容器存在且样式满足要求
if (!pagesContainer.value || !canvasContainer.value) {
await nextTick();
}
const loadingTask = pdfjsLib.getDocument({
url: props.src,
cMapUrl: "/cmaps/",
cMapPacked: true,
});
const doc = await loadingTask.promise;
pdfDoc.value = doc;
totalPages.value = doc.numPages;
// 设置默认缩放比例
const defaultScale = 1.0;
scale.value = defaultScale;
// 计算页面高度和位置
if (totalPages.value > 0) {
const firstPage = await doc.getPage(1);
const viewport = firstPage.getViewport({ scale: defaultScale });
pageHeight.value = viewport.height;
}
// 渲染可见区域页面
await renderVisiblePages();
loading.value = false;
} catch (error) {
loading.value = false;
console.error("PDF加载失败:", error);
}
};
const handleScroll = debounce(() => {
if (!pagesContainer.value) return;
const scrollContainer = pagesContainer.value;
const scrollTop = scrollContainer.scrollTop;
const gap = props.pageGap || 20;
const unit = pageHeight.value + gap;
let page = 1;
if (unit > 0) {
page = Math.floor(scrollTop / unit) + 1;
}
if (page < 1) page = 1;
if (page > totalPages.value) page = totalPages.value;
currentPageNumber.value = page;
renderVisiblePages();
}, 500);
onMounted(() => {
initPDF();
});
onUnmounted(() => {
if (pdfDoc.value) {
pdfDoc.value.destroy();
}
for (const key in pageCanvasRefs.value) {
const c = pageCanvasRefs.value[Number(key)];
if (c && c.parentNode) c.parentNode.removeChild(c);
}
pageCanvasRefs.value = {};
renderedPages.value = [];
});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
};
</script>
<style lang="less" scoped>
/* 主容器样式 */
.pdf-viewer-container {
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
width: 100%;
}
/* 图标激活状态 */
.n-icon.active {
color: #1890ff;
}
.pdf-header {
align-items: center;
background-color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-sizing: border-box;
display: flex;
gap: 50px;
height: 56px;
justify-content: space-between;
padding: 0 15px;
width: 100%;
&-title {
h2 {
color: rgb(51, 54, 57);
font-size: 18px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.pdf-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
width: 100%;
}
.spin-container {
position: absolute;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
z-index: 10;
}
.pdf-pages-wrapper {
background-color: #f4f5f7;
box-sizing: border-box;
flex: 1;
padding: 20px;
position: relative;
}
// PDF页面容器样式
.pdf-pages-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow-y: auto;
overflow-x: auto;
padding: 0 40px;
background-color: var(--bg-color);
// 响应式调整
@media (max-width: 768px) {
padding: 0 20px;
}
@media (max-width: 480px) {
padding: 0 10px;
}
}
/* PDF Canvas Container */
.pdf-canvas-container {
display: block;
margin: 0 auto;
padding: 20px 0;
width: 100%;
}
// PDF页面Canvas样式优化
.pdf-page-canvas {
display: block;
margin: 0 auto var(--page-gap, 20px) auto;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: white;
transition: box-shadow var(--transition-speed) ease;
// 页面悬停效果
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
// 响应式页面间距
@media (max-width: 768px) {
--page-gap: 15px;
}
@media (max-width: 480px) {
--page-gap: 10px;
}
}
</style>