🌈 前言
最近做项目,产品丢过来一个需求: “我们要一个像掘金文章那样的 PDF 连续预览功能,要自适应屏幕,还要支持放大缩小!”
我心想,这需求简单啊,直接搜一波现成的库,结果一看,要么太重,要么不支持连续预览,要么缩放体验差,要么样式丑。
于是,干脆自己撸一个!用 Vue2 + PDF.js,花了一个下午,效果还不错,直接分享给大家,代码已经整理好了,复制粘贴就能用!
🚀 效果展示
- ✅ 支持 PDF 文件上传预览
- ✅ 连续滚动 显示所有页面,像浏览网页一样
- ✅ 自适应屏幕宽度,窗口大小变化自动调整
- ✅ 支持缩放(放大、缩小、重置)
- ✅ 加载进度提示,体验更友好
- ✅ 样式美观,代码简洁易读
📦 技术栈
- Vue2:熟悉的响应式框架
- PDF.js:Mozilla 出品的 PDF 渲染库,功能强大
- FontAwesome:图标库,让按钮更美观
🛠️ 核心实现思路
1. 引入依赖
<!-- Vue2 -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<!-- PDF.js -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js"></script>
<!-- FontAwesome -->
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
2. 文件上传
使用 <input type="file" accept=".pdf"> 实现文件选择,通过 FileReader 读取文件内容:
const file = e.target.files[0];
if (!file || file.type !== 'application/pdf') {
this.errorMessage = '请上传PDF格式的文件';
return;
}
const fileReader = new FileReader();
fileReader.onload = (event) => {
const typedArray = new Uint8Array(event.target.result);
this.loadPdf(typedArray);
};
fileReader.readAsArrayBuffer(file);
}
3. 渲染 PDF 页面
使用 PDF.js 的 getDocument 方法加载 PDF,然后逐页渲染:
renderPage(pageNum, containerWidth) {
this.pdfDoc.getPage(pageNum).then(page => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 计算缩放比例,自适应容器宽度
const viewport = page.getViewport({ scale: 1 });
const baseScale = Math.min(containerWidth / viewport.width, 2);
const scale = baseScale * this.zoom;
const scaledViewport = page.getViewport({ scale });
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
const renderContext = {
canvasContext: context,
viewport: scaledViewport
};
page.render(renderContext).promise.then(() => {
this.loadedPages++;
});
});
}
4. 缩放功能
通过调整 zoom 值,重新渲染所有页面:
zoomIn() {
if (this.zoom < 3) {
this.zoom += 0.1;
this.reRenderAllPages();
}
},
zoomOut() {
if (this.zoom > 0.3) {
this.zoom -= 0.1;
this.reRenderAllPages();
}
},
resetZoom() {
this.zoom = 1.0;
this.reRenderAllPages();
}
5. 自适应屏幕
监听窗口大小变化,自动重新渲染:
mounted() {
window.addEventListener('resize', () => {
if (this.pdfDoc && this.showViewer) {
this.reRenderAllPages();
}
});
}
📝 使用方式
- 复制文章最后提供的完整 HTML 代码
- 保存为
index.html - 直接双击打开即可使用
🏁 结语
一个简单的 PDF 连续预览器就完成了,代码不到 300 行,功能却很实用。如果你有更好的想法或者遇到问题,欢迎在评论区留言交流!
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 直接预览器</title>
<!-- 引入Vue 2 -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<!-- 引入PDF.js -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js"></script>
<!-- 引入Font Awesome图标 -->
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.pdf-viewer {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
margin-top: 20px;
}
.url-input-area {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
#pdf-url {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.btn {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
margin: 0 3px;
}
.btn:hover {
background-color: #359e75;
}
.btn i {
margin-right: 5px;
}
.zoom-controls {
display: flex;
align-items: center;
}
.pdf-container {
width: 100%;
overflow-x: hidden;
overflow-y: auto;
padding: 10px 0;
max-height: calc(100vh - 200px);
}
.pdf-page {
box-shadow: 0 0 8px rgba(0,0,0,0.1);
margin: 0 auto 15px;
transition: all 0.3s ease;
background-color: white;
}
.loading {
text-align: center;
padding: 50px 0;
color: #666;
}
.page-count {
color: #666;
}
</style>
</head>
<body>
<div class="container" id="app">
<h1>PDF 直接预览器</h1>
<!-- PDF链接输入区域 -->
<div class="url-input-area">
<input type="text" id="pdf-url" v-model="pdfUrl"
placeholder="请输入PDF文件的URL地址"
@keyup.enter="loadPdfFromUrl">
<button class="btn" @click="loadPdfFromUrl">
<i class="fa fa-eye"></i> 预览PDF
</button>
</div>
<!-- PDF 预览区域 -->
<div class="pdf-viewer" v-if="showViewer || loading">
<!-- 工具栏 -->
<div class="toolbar" v-if="showToolbar">
<div>
<span class="page-count">共 {{ totalPages }} 页</span>
</div>
<div class="zoom-controls">
<button class="btn" @click="zoomOut">
<i class="fa fa-search-minus"></i> 缩小
</button>
<button class="btn" @click="resetZoom">
<i class="fa fa-home"></i> 重置
</button>
<button class="btn" @click="zoomIn">
<i class="fa fa-search-plus"></i> 放大
</button>
<span class="page-info">{{ Math.round(zoom * 100) }}%</span>
</div>
</div>
<!-- PDF 内容区域 - 连续显示所有页面 -->
<div class="pdf-container">
<div v-if="loading" class="loading">
<i class="fa fa-spinner fa-spin text-2xl"></i>
<p>加载中... 共 {{ totalPages }} 页,正在加载第 {{ loadedPages }} 页</p>
</div>
<!-- 所有PDF页面将被渲染到这里 -->
<div ref="pdfContent" class="pdf-pages-container"></div>
</div>
</div>
<div v-if="!showViewer && !loading && !errorMessage" class="loading">
<i class="fa fa-file-pdf-o text-4xl text-gray-300"></i>
<p>请输入PDF文件的URL地址并点击预览按钮</p>
</div>
<div v-if="errorMessage" style="color: red; margin-top: 20px; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<i class="fa fa-exclamation-circle"></i> {{ errorMessage }}
</div>
</div>
<script>
// 配置PDF.js工作器
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js';
new Vue({
el: '#app',
data() {
return {
pdfUrl: 'https://501351981.github.io/vue-office/examples/dist/static/test-files/test.pdf', // 默认示例PDF
pdfDoc: null, // PDF文档实例
totalPages: 0, // 总页数
loadedPages: 0, // 已加载页数
zoom: 1.0, // 缩放比例
loading: false, // 加载状态
showViewer: false, // 是否显示预览器
showToolbar: false, // 是否显示工具栏
errorMessage: '' // 错误信息
}
},
methods: {
// 从URL加载PDF
loadPdfFromUrl() {
if (!this.pdfUrl.trim()) {
this.errorMessage = '请输入有效的PDF URL地址';
return;
}
this.errorMessage = '';
this.loading = true;
this.showViewer = true;
this.loadedPages = 0;
this.$nextTick(() => {
// 清空之前的内容
this.$refs.pdfContent.innerHTML = '';
// 检查URL是否以.pdf结尾
if (!this.pdfUrl.toLowerCase().endsWith('.pdf')) {
this.errorMessage = '请输入以.pdf结尾的有效URL';
this.loading = false;
return;
}
// 加载PDF文档
pdfjsLib.getDocument({
url: this.pdfUrl,
cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/cmaps/',
cMapPacked: true
}).promise
.then(pdfDoc => {
this.pdfDoc = pdfDoc;
this.totalPages = pdfDoc.numPages;
this.showToolbar = true;
// 渲染所有页面
this.renderAllPages();
})
.catch(error => {
console.error('PDF加载错误:', error);
this.errorMessage = '加载PDF失败: ' + (error.message || '可能是跨域问题或URL无效');
this.loading = false;
this.showViewer = false;
});
});
},
// 渲染所有页面
renderAllPages() {
// 计算适应容器的基础缩放比例
const containerWidth = this.$refs.pdfContent.clientWidth - 40; // 减去边距
// 逐个渲染页面
for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {
this.renderPage(pageNum, containerWidth);
}
},
// 渲染单页
renderPage(pageNum, containerWidth) {
this.pdfDoc.getPage(pageNum)
.then(page => {
// 创建页面容器和canvas
const pageContainer = document.createElement('div');
pageContainer.className = 'pdf-page-container';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.dataset.pageNum = pageNum;
pageContainer.appendChild(canvas);
this.$refs.pdfContent.appendChild(pageContainer);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法初始化画布上下文');
}
// 计算缩放比例
const viewport = page.getViewport({ scale: 1 });
const baseScale = Math.min(containerWidth / viewport.width, 2); // 最大2倍缩放
const scale = baseScale * this.zoom;
const scaledViewport = page.getViewport({ scale });
// 设置canvas尺寸
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
// 渲染页面
const renderContext = {
canvasContext: context,
viewport: scaledViewport
};
return page.render(renderContext).promise;
})
.then(() => {
this.loadedPages++;
// 所有页面加载完成后隐藏加载状态
if (this.loadedPages === this.totalPages) {
this.loading = false;
}
})
.catch(error => {
console.error(`页面${pageNum}渲染错误:`, error);
this.errorMessage = `渲染第 ${pageNum} 页失败: ${error.message}`;
this.loadedPages++;
if (this.loadedPages === this.totalPages) {
this.loading = false;
}
});
},
// 重新渲染所有页面(用于缩放)
reRenderAllPages() {
this.loading = true;
this.loadedPages = 0;
this.$refs.pdfContent.innerHTML = '';
this.renderAllPages();
},
// 放大
zoomIn() {
if (this.zoom < 3) { // 限制最大缩放
this.zoom += 0.1;
this.reRenderAllPages();
}
},
// 缩小
zoomOut() {
if (this.zoom > 0.3) { // 限制最小缩放
this.zoom -= 0.1;
this.reRenderAllPages();
}
},
// 重置缩放
resetZoom() {
this.zoom = 1.0;
this.reRenderAllPages();
}
},
mounted() {
// 监听窗口大小变化,重新渲染以适应新尺寸
window.addEventListener('resize', () => {
if (this.pdfDoc && this.showViewer) {
this.reRenderAllPages();
}
});
// 自动加载默认PDF
this.loadPdfFromUrl();
}
});
</script>
</body>
</html>