整体功能概述
这个 PDF 查看器是一个使用 Vue2 框架开发的单页应用,它允许用户上传 PDF 文件并在浏览器中查看。应用提供了基本的 PDF 浏览功能,包括文件选择、页面导航、目录浏览以及根据窗口大小自动调整显示比例。
主要组件结构
-
文件选择器:顶部区域的文件输入控件,用于选择本地 PDF 文件
-
主内容区:
- 左侧目录面板:显示 PDF 文档的目录结构 (如果有)
- 右侧内容区:显示当前 PDF 页面的渲染结果
-
页脚导航区:显示当前页码并提供上一页 / 下一页按钮
核心功能模块
1. PDF 加载与解析
- 用户通过文件选择器选择 PDF 文件后,触发
loadPdf
方法 - 该方法使用 FileReader 读取文件内容为 ArrayBuffer
- 调用 pdf.js 库的
getDocument
方法解析 PDF 内容 - 解析成功后获取总页数并尝试提取文档目录结构 (TOC)
- 最后渲染第一页内容
2. 目录处理与渲染
processOutline
方法递归处理 PDF 大纲结构- 为每个目录项生成唯一 ID 和层级关系
- 异步获取每个目录项对应的页码信息
- 在左侧面板渲染目录列表,点击时跳转到对应页面
3. 页面渲染机制
renderPage
方法负责渲染指定页码的 PDF 内容- 创建 canvas 元素并设置尺寸与 PDF 页面匹配
- 使用 pdf.js 的渲染 API 将页面内容绘制到 canvas 上
- 每次渲染前清空现有内容,确保只显示当前页
4. 页面导航控制
prevPage
和nextPage
方法分别处理前后翻页goToPage
方法根据目录项或页码导航到指定页面- 按钮状态根据当前页码和总页数动态禁用 / 启用
- 页脚显示当前页码和总页数信息
5. 响应式布局处理
- 监听窗口大小变化事件
- 根据容器宽度动态计算最佳缩放比例
- 窗口大小改变时重新渲染当前页面以适应新尺寸
- 使用平滑滚动确保用户体验一致性
6. 状态管理
- 使用 Vue2 的响应式数据管理 PDF 加载状态
- 包括 loading 状态、当前页码、总页数等信息
- 数据变化自动触发 DOM 更新
关键技术实现细节
-
PDF.js 集成:
- 正确配置 worker 源以处理 PDF 解析任务
- 使用 Promise 方式处理异步解析过程
- 处理可能出现的解析错误
-
动态 DOM 操作:
- 使用 ref 属性访问 DOM 元素
- 在 mounted 生命周期钩子中添加事件监听
- 在组件销毁前正确移除事件监听
-
异步操作处理:
- 使用 async/await 处理 PDF 解析的多个异步步骤
- 适当的错误处理机制确保应用稳定性
- 加载状态管理提供良好用户体验
css
npm i pdfjs-dist
"pdfjs-dist": "^2.16.105"
Vue3风格
ini
<template>
<div class="flex flex-col h-screen bg-gray-50">
<!-- 文件选择器 -->
<div class="p-4 bg-white shadow-sm">
<input type="file" @change="loadPdf" accept=".pdf" class="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- PDF查看器主体 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧目录 -->
<div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<div class="p-4 font-medium text-gray-700 border-b border-gray-200">目录</div>
<ul class="p-2">
<li v-for="item in toc" :key="item.id" class="mb-1">
<a
href="#"
@click.prevent="goToPage(item.pageNum)"
class="block p-2 rounded hover:bg-gray-100 text-sm"
:class="{ 'font-medium': currentPage === item.pageNum }"
>
{{ item.title }}
</a>
</li>
</ul>
</div>
<!-- 右侧内容 -->
<div class="flex-1 overflow-auto bg-gray-100 p-4" ref="pdfContainer">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<div v-else ref="pagesContainer">
<!-- PDF页面将在这里动态渲染 -->
</div>
</div>
</div>
<!-- 页脚 -->
<div class="p-4 bg-white border-t border-gray-200 flex justify-between items-center">
<div class="text-sm text-gray-600">
当前页: {{ currentPage || 0 }} / {{ totalPages || 0 }}
</div>
<div class="flex space-x-2">
<button
@click="prevPage"
:disabled="!currentPage || currentPage <= 1"
class="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<button
@click="nextPage"
:disabled="!currentPage || currentPage >= totalPages"
class="px-4 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/web/pdf_viewer.css';
// 修复:从项目中导入Worker而不是使用CDN
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
// 引用DOM元素
const pdfContainer = ref(null);
const pagesContainer = ref(null);
// 状态变量
const loading = ref(true);
const pdfDoc = ref(null);
const toc = ref([]);
const currentPage = ref(1);
const totalPages = ref(0);
const scale = ref(1.0);
// 加载PDF文件
const loadPdf = async (event) => {
const file = event.target.files[0];
if (!file) return;
loading.value = true;
toc.value = [];
currentPage.value = 1;
try {
const fileReader = new FileReader();
fileReader.onload = async (e) => {
const typedArray = new Uint8Array(e.target.result);
// 加载PDF文档
pdfDoc.value = await pdfjsLib.getDocument(typedArray).promise;
totalPages.value = pdfDoc.value.numPages;
// 尝试获取目录
try {
const outline = await pdfDoc.value.getOutline();
if (outline && outline.length) {
processOutline(outline);
}
} catch (err) {
console.warn('获取PDF目录失败:', err);
}
// 渲染第一页
await renderPage(currentPage.value);
loading.value = false;
};
fileReader.readAsArrayBuffer(file);
} catch (err) {
console.error('加载PDF失败:', err);
loading.value = false;
}
};
// 处理PDF目录结构
const processOutline = (outlineItems, parentId = null, level = 1) => {
outlineItems.forEach((item, index) => {
const id = parentId ? `${parentId}-${index}` : `item-${index}`;
const pageRef = item.dest ? item.dest[0] : (item.pageRef || null);
let pageNum = null;
if (pageRef) {
pdfDoc.value.getPageIndex(pageRef).then(num => {
const pageItem = toc.value.find(i => i.id === id);
if (pageItem) {
pageItem.pageNum = num + 1; // PDF.js索引从0开始,页面从1开始
}
}).catch(err => {
console.warn('获取页面索引失败:', err);
});
}
toc.value.push({
id,
title: item.title,
pageNum: pageNum || null,
level
});
if (item.items && item.items.length) {
processOutline(item.items, id, level + 1);
}
});
};
// 渲染指定页面
const renderPage = async (num) => {
if (!pdfDoc.value) return;
try {
// 清除现有页面
if (pagesContainer.value) {
pagesContainer.value.innerHTML = '';
}
// 获取并渲染指定页面
const page = await pdfDoc.value.getPage(num);
currentPage.value = num;
const viewport = page.getViewport({ scale: scale.value });
// 创建canvas元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.className = 'mb-6 shadow-md bg-white';
// 添加到容器
if (pagesContainer.value) {
pagesContainer.value.appendChild(canvas);
}
// 渲染页面到canvas
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
// 平滑滚动到顶部
if (pdfContainer.value) {
pdfContainer.value.scrollTop = 0;
}
} catch (err) {
console.error('渲染页面失败:', err);
}
};
// 导航到指定页面
const goToPage = (pageNum) => {
if (pageNum && pageNum !== currentPage.value) {
renderPage(pageNum);
}
};
// 上一页
const prevPage = () => {
if (currentPage.value > 1) {
renderPage(currentPage.value - 1);
}
};
// 下一页
const nextPage = () => {
if (currentPage.value < totalPages.value) {
renderPage(currentPage.value + 1);
}
};
// 页面加载完成后初始化
onMounted(() => {
// 监听窗口大小变化,调整PDF缩放比例
const handleResize = () => {
if (pdfContainer.value && pdfDoc.value) {
// 计算适合容器的缩放比例
const containerWidth = pdfContainer.value.clientWidth - 40; // 减去内边距
if (containerWidth > 0) {
pdfDoc.value.getPage(1).then(page => {
const viewport = page.getViewport({ scale: 1.0 });
scale.value = containerWidth / viewport.width;
if (currentPage.value) {
renderPage(currentPage.value);
}
});
}
}
};
window.addEventListener('resize', handleResize);
// 初始调整
nextTick(() => {
handleResize();
});
// 清理函数
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
pdfDoc.value = null;
});
});
</script>
<style scoped>
/* 自定义样式 */
.pdf-page {
margin-bottom: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
</style>
Vue2风格
xml
<template>
<div class="flex flex-col h-screen bg-gray-50">
<!-- 文件选择器 -->
<div class="p-4 bg-white shadow-sm">
<input type="file" @change="loadPdf" accept=".pdf" class="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- PDF查看器主体 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧目录 -->
<div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<div class="p-4 font-medium text-gray-700 border-b border-gray-200">目录</div>
<ul class="p-2">
<li v-for="item in toc" :key="item.id" class="mb-1">
<a
href="#"
@click.prevent="goToPage(item.pageNum)"
class="block p-2 rounded hover:bg-gray-100 text-sm"
:class="{ 'font-medium': currentPage === item.pageNum }"
>
{{ item.title }}
</a>
</li>
</ul>
</div>
<!-- 右侧内容 -->
<div class="flex-1 overflow-auto bg-gray-100 p-4" ref="pdfContainer">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<div v-else ref="pagesContainer">
<!-- PDF页面将在这里动态渲染 -->
</div>
</div>
</div>
<!-- 页脚 -->
<div class="p-4 bg-white border-t border-gray-200 flex justify-between items-center">
<div class="text-sm text-gray-600">
当前页: {{ currentPage || 0 }} / {{ totalPages || 0 }}
</div>
<div class="flex space-x-2">
<button
@click="prevPage"
:disabled="!currentPage || currentPage <= 1"
class="px-4 py-1.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<button
@click="nextPage"
:disabled="!currentPage || currentPage >= totalPages"
class="px-4 py-1.5 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
</template>
<script>
import * as pdfjsLib from 'pdfjs-dist';
import 'pdfjs-dist/web/pdf_viewer.css';
// 修复:从项目中导入Worker而不是使用CDN
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export default {
data() {
return {
loading: true,
pdfDoc: null,
toc: [],
currentPage: 1,
totalPages: 0,
scale: 1.0
}
},
mounted() {
// 监听窗口大小变化,调整PDF缩放比例
const handleResize = () => {
if (this.$refs.pdfContainer && this.pdfDoc) {
// 计算适合容器的缩放比例
const containerWidth = this.$refs.pdfContainer.clientWidth - 40; // 减去内边距
if (containerWidth > 0) {
this.pdfDoc.getPage(1).then(page => {
const viewport = page.getViewport({ scale: 1.0 });
this.scale = containerWidth / viewport.width;
if (this.currentPage) {
this.renderPage(this.currentPage);
}
});
}
}
};
window.addEventListener('resize', handleResize);
// 初始调整
this.$nextTick(() => {
handleResize();
});
// 保存resize处理函数引用,以便在销毁时移除监听
this.handleResize = handleResize;
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
this.pdfDoc = null;
},
methods: {
// 加载PDF文件
async loadPdf(event) {
const file = event.target.files[0];
if (!file) return;
this.loading = true;
this.toc = [];
this.currentPage = 1;
try {
const fileReader = new FileReader();
fileReader.onload = async (e) => {
const typedArray = new Uint8Array(e.target.result);
// 加载PDF文档
this.pdfDoc = await pdfjsLib.getDocument(typedArray).promise;
this.totalPages = this.pdfDoc.numPages;
// 尝试获取目录
try {
const outline = await this.pdfDoc.getOutline();
if (outline && outline.length) {
this.processOutline(outline);
}
} catch (err) {
console.warn('获取PDF目录失败:', err);
}
// 渲染第一页
await this.renderPage(this.currentPage);
this.loading = false;
};
fileReader.readAsArrayBuffer(file);
} catch (err) {
console.error('加载PDF失败:', err);
this.loading = false;
}
},
// 处理PDF目录结构
processOutline(outlineItems, parentId = null, level = 1) {
outlineItems.forEach((item, index) => {
const id = parentId ? `${parentId}-${index}` : `item-${index}`;
const pageRef = item.dest ? item.dest[0] : (item.pageRef || null);
let pageNum = null;
if (pageRef) {
this.pdfDoc.getPageIndex(pageRef).then(num => {
const pageItem = this.toc.find(i => i.id === id);
if (pageItem) {
pageItem.pageNum = num + 1; // PDF.js索引从0开始,页面从1开始
}
}).catch(err => {
console.warn('获取页面索引失败:', err);
});
}
this.toc.push({
id,
title: item.title,
pageNum: pageNum || null,
level
});
if (item.items && item.items.length) {
this.processOutline(item.items, id, level + 1);
}
});
},
// 渲染指定页面
async renderPage(num) {
if (!this.pdfDoc) return;
try {
// 清除现有页面
if (this.$refs.pagesContainer) {
this.$refs.pagesContainer.innerHTML = '';
}
// 获取并渲染指定页面
const page = await this.pdfDoc.getPage(num);
this.currentPage = num;
const viewport = page.getViewport({ scale: this.scale });
// 创建canvas元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.className = 'mb-6 shadow-md bg-white';
// 添加到容器
if (this.$refs.pagesContainer) {
this.$refs.pagesContainer.appendChild(canvas);
}
// 渲染页面到canvas
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
// 平滑滚动到顶部
if (this.$refs.pdfContainer) {
this.$refs.pdfContainer.scrollTop = 0;
}
} catch (err) {
console.error('渲染页面失败:', err);
}
},
// 导航到指定页面
goToPage(pageNum) {
if (pageNum && pageNum !== this.currentPage) {
this.renderPage(pageNum);
}
},
// 上一页
prevPage() {
if (this.currentPage > 1) {
this.renderPage(this.currentPage - 1);
}
},
// 下一页
nextPage() {
if (this.currentPage < this.totalPages) {
this.renderPage(this.currentPage + 1);
}
}
}
}
</script>
<style scoped>
/* 自定义样式 */
.pdf-page {
margin-bottom: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
</style>
实现效果
