☰
Vue集成pdf.js实现高效PDF预览与发票处理
2026/11/3 21:38:28 网站建设 项目流程

1. 项目概述

在Web开发中,PDF文件预览是一个常见的需求场景,特别是在企业级应用中,发票、合同等文档的在线预览功能尤为重要。传统的解决方案往往需要后端服务器进行PDF渲染,再将图片返回前端,这种方式不仅增加了服务器负担,还影响了用户体验。而pdf.js作为Mozilla开源的纯前端PDF渲染引擎,配合Vue框架使用,能够实现高性能的PDF直接渲染,特别适合发票预览这类对实时性要求较高的场景。

2. 技术选型分析

2.1 为什么选择pdf.js

pdf.js具有以下核心优势:

  • 纯前端实现:无需后端参与,减轻服务器压力
  • 开源免费:Apache 2.0协议,可自由使用
  • 跨平台兼容:基于HTML5 Canvas实现,支持现代浏览器
  • 功能丰富:支持文本选择、缩放、搜索等交互功能

2.2 Vue集成方案对比

在Vue项目中集成pdf.js主要有两种方式:

  1. 直接引入CDN版本
  2. 通过npm安装pdfjs-dist包

对于发票预览这种相对简单的场景,推荐使用npm安装方式,便于版本管理和打包优化。

3. 实现步骤详解

3.1 环境准备

首先安装必要的依赖:

npm install pdfjs-dist@2.16.105 vue@3.2.47

注意:pdfjs-dist版本需要与项目环境匹配,最新版可能不兼容旧项目

3.2 基础组件实现

创建PdfViewer.vue组件:

<template> <div class="pdf-container"> <canvas ref="pdfCanvas"></canvas> <div class="page-controls"> <button @click="prevPage">上一页</button> <span>第{{ currentPage }}页/共{{ pageCount }}页</span> <button @click="nextPage">下一页</button> </div> </div> </template> <script> import * as pdfjsLib from 'pdfjs-dist' export default { props: { pdfUrl: { type: String, required: true } }, data() { return { pdfDoc: null, currentPage: 1, pageCount: 0, renderTask: null } }, mounted() { this.loadPdf() }, methods: { async loadPdf() { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js' try { const loadingTask = pdfjsLib.getDocument(this.pdfUrl) this.pdfDoc = await loadingTask.promise this.pageCount = this.pdfDoc.numPages this.renderPage(this.currentPage) } catch (err) { console.error('PDF加载失败:', err) } }, async renderPage(num) { if (this.renderTask) { await this.renderTask } const page = await this.pdfDoc.getPage(num) const viewport = page.getViewport({ scale: 1.5 }) const canvas = this.$refs.pdfCanvas const context = canvas.getContext('2d') canvas.height = viewport.height canvas.width = viewport.width this.renderTask = page.render({ canvasContext: context, viewport: viewport }) await this.renderTask }, prevPage() { if (this.currentPage <= 1) return this.currentPage-- this.renderPage(this.currentPage) }, nextPage() { if (this.currentPage >= this.pageCount) return this.currentPage++ this.renderPage(this.currentPage) } } } </script> <style scoped> .pdf-container { margin: 20px auto; max-width: 800px; } canvas { border: 1px solid #ddd; box-shadow: 0 0 10px rgba(0,0,0,0.1); margin-bottom: 10px; } .page-controls { display: flex; justify-content: center; align-items: center; gap: 20px; } button { padding: 5px 15px; background: #42b983; color: white; border: none; border-radius: 4px; cursor: pointer; } </style>

3.3 处理Base64数据

发票系统常以Base64格式传输PDF数据,需要特殊处理:

async loadPdf() { // ...其他代码 // 如果是base64数据 if (this.pdfUrl.startsWith('data:application/pdf;base64,')) { const byteString = atob(this.pdfUrl.split(',')[1]) const byteArray = new Uint8Array(byteString.length) for (let i = 0; i < byteString.length; i++) { byteArray[i] = byteString.charCodeAt(i) } const loadingTask = pdfjsLib.getDocument(byteArray) this.pdfDoc = await loadingTask.promise } else { // 普通URL处理 const loadingTask = pdfjsLib.getDocument(this.pdfUrl) this.pdfDoc = await loadingTask.promise } // ...后续代码 }

4. 性能优化方案

4.1 按需加载

对于多页PDF,实现懒加载:

async renderPage(num) { // 取消正在进行的渲染 if (this.renderTask) { await this.renderTask.cancel() } // ...原有渲染逻辑 }

4.2 缓存策略

实现页面缓存提升性能:

data() { return { // ...其他data pageCache: new Map() } }, methods: { async renderPage(num) { if (this.pageCache.has(num)) { const cached = this.pageCache.get(num) this.$refs.pdfCanvas.width = cached.width this.$refs.pdfCanvas.height = cached.height this.$refs.pdfCanvas.getContext('2d').drawImage(cached.canvas, 0, 0) return } // ...原有渲染逻辑 // 缓存渲染结果 const canvasCopy = document.createElement('canvas') canvasCopy.width = canvas.width canvasCopy.height = canvas.height canvasCopy.getContext('2d').drawImage(canvas, 0, 0) this.pageCache.set(num, { canvas: canvasCopy, width: canvas.width, height: canvas.height }) } }

4.3 Worker优化

自定义worker提升稳定性:

// 在public目录下创建pdf.worker.js importScripts('https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js') // 在组件中指定 pdfjsLib.GlobalWorkerOptions.workerSrc = process.env.BASE_URL + 'pdf.worker.js'

5. 常见问题解决

5.1 跨域问题

解决方案:

  1. 确保服务器配置CORS头
  2. 或通过代理服务器请求PDF
  3. 使用Base64方式传输

5.2 中文显示异常

添加CMAP资源:

const loadingTask = pdfjsLib.getDocument({ url: this.pdfUrl, cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/cmaps/', cMapPacked: true })

5.3 内存泄漏

正确销毁资源:

beforeUnmount() { if (this.pdfDoc) { this.pdfDoc.destroy() } this.pageCache.clear() }

6. 发票预览专用优化

6.1 发票常见需求实现

  1. 发票验真功能:
// 在渲染完成后提取发票代码 async extractInvoiceCode(pageNum = 1) { const page = await this.pdfDoc.getPage(pageNum) const textContent = await page.getTextContent() const invoiceCode = textContent.items .find(item => item.str.match(/发票代码[::]\s*(\d+)/)) ?.str.match(/\d+/)[0] return invoiceCode }
  1. 发票信息高亮:
async renderPage(num) { // ...原有渲染逻辑 // 添加文本层 const textLayer = document.createElement('div') textLayer.className = 'text-layer' this.$refs.pdfCanvas.parentNode.appendChild(textLayer) // 渲染文本 const textContent = await page.getTextContent() pdfjsLib.renderTextLayer({ textContent: textContent, container: textLayer, viewport: viewport, textDivs: [] }) // 高亮关键信息 const invoiceNo = textContent.items.find(item => item.str.includes('发票号码') ) if (invoiceNo) { const div = document.createElement('div') div.style.left = `${invoiceNo.transform[4]}px` div.style.top = `${invoiceNo.transform[5]}px` div.style.width = `${invoiceNo.width}px` div.style.height = `${invoiceNo.height}px` div.style.backgroundColor = 'rgba(255,255,0,0.3)' div.style.position = 'absolute' textLayer.appendChild(div) } }

6.2 移动端适配

针对移动设备优化:

@media (max-width: 768px) { .pdf-container { width: 100%; padding: 0 10px; } canvas { width: 100% !important; height: auto !important; } }

7. 完整示例代码

以下是整合所有优化的完整组件代码:

<template> <div class="pdf-viewer"> <div class="toolbar"> <button @click="zoomOut">-</button> <span>{{ Math.round(scale * 100) }}%</span> <button @click="zoomIn">+</button> <input type="number" v-model.number="jumpPage" min="1" :max="pageCount" @change="goToPage" > <span>/ {{ pageCount }}</span> </div> <div class="pdf-container" ref="container"> <canvas ref="pdfCanvas"></canvas> <div class="text-layer" ref="textLayer"></div> </div> <div class="status" v-if="loading"> 加载中... {{ Math.round(progress * 100) }}% </div> <div class="error" v-if="error"> 加载失败: {{ error }} </div> </div> </template> <script> import * as pdfjsLib from 'pdfjs-dist' export default { props: { src: { type: [String, Uint8Array], required: true }, defaultScale: { type: Number, default: 1.0 } }, data() { return { pdfDoc: null, currentPage: 1, pageCount: 0, scale: this.defaultScale, jumpPage: 1, loading: false, progress: 0, error: null, renderTask: null, pageCache: new Map() } }, watch: { src: { immediate: true, handler() { this.initPDF() } } }, methods: { async initPDF() { try { this.loading = true this.error = null pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js' const loadingTask = pdfjsLib.getDocument({ url: typeof this.src === 'string' ? this.src : undefined, data: typeof this.src !== 'string' ? this.src : undefined, cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/cmaps/', cMapPacked: true }) loadingTask.onProgress = ({ loaded, total }) => { this.progress = loaded / total } this.pdfDoc = await loadingTask.promise this.pageCount = this.pdfDoc.numPages this.jumpPage = this.currentPage = 1 await this.renderPage(this.currentPage) } catch (err) { this.error = err.message console.error('PDF加载失败:', err) } finally { this.loading = false } }, async renderPage(num) { if (num < 1 || num > this.pageCount) return try { this.currentPage = num this.jumpPage = num // 使用缓存 if (this.pageCache.has(num)) { const cached = this.pageCache.get(num) const canvas = this.$refs.pdfCanvas canvas.width = cached.width canvas.height = cached.height canvas.getContext('2d').drawImage(cached.canvas, 0, 0) return } // 取消正在进行的渲染 if (this.renderTask) { await this.renderTask.cancel() } const page = await this.pdfDoc.getPage(num) const viewport = page.getViewport({ scale: this.scale }) const canvas = this.$refs.pdfCanvas const context = canvas.getContext('2d') // 调整canvas尺寸 canvas.height = viewport.height canvas.width = viewport.width // 清空文本层 this.$refs.textLayer.innerHTML = '' // 渲染PDF页面 this.renderTask = page.render({ canvasContext: context, viewport: viewport }) await this.renderTask // 渲染文本层 const textContent = await page.getTextContent() await pdfjsLib.renderTextLayer({ textContent: textContent, container: this.$refs.textLayer, viewport: viewport, textDivs: [] }) // 缓存结果 const canvasCopy = document.createElement('canvas') canvasCopy.width = canvas.width canvasCopy.height = canvas.height canvasCopy.getContext('2d').drawImage(canvas, 0, 0) this.pageCache.set(num, { canvas: canvasCopy, width: canvas.width, height: canvas.height }) } catch (err) { if (err.name !== 'RenderingCancelledException') { console.error('页面渲染失败:', err) } } }, zoomIn() { this.scale = Math.min(this.scale + 0.1, 3.0) this.refreshCurrentPage() }, zoomOut() { this.scale = Math.max(this.scale - 0.1, 0.5) this.refreshCurrentPage() }, refreshCurrentPage() { this.pageCache.delete(this.currentPage) this.renderPage(this.currentPage) }, prevPage() { this.renderPage(this.currentPage - 1) }, nextPage() { this.renderPage(this.currentPage + 1) }, goToPage() { const page = Math.max(1, Math.min(this.pageCount, parseInt(this.jumpPage) || 1)) this.renderPage(page) } }, beforeUnmount() { if (this.renderTask) { this.renderTask.cancel() } if (this.pdfDoc) { this.pdfDoc.destroy() } this.pageCache.clear() } } </script> <style scoped> .pdf-viewer { max-width: 100%; margin: 0 auto; position: relative; } .toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 5px; background: #f5f5f5; border-radius: 4px; } .toolbar button { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border: 1px solid #ddd; background: white; cursor: pointer; } .toolbar input { width: 50px; padding: 5px; border: 1px solid #ddd; text-align: center; } .pdf-container { position: relative; margin: 0 auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); } canvas { display: block; max-width: 100%; height: auto; margin: 0 auto; border: 1px solid #eee; } .text-layer { position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden; opacity: 0.2; line-height: 1; } .text-layer ::selection { background: rgba(0,0,255,0.3); } .status, .error { padding: 10px; margin-top: 10px; border-radius: 4px; } .status { background: #e6f7ff; color: #1890ff; } .error { background: #fff2f0; color: #ff4d4f; } </style>

8. 进阶功能扩展

8.1 缩略图导航

添加缩略图功能:

<template> <div class="thumbnail-container" v-if="showThumbnails"> <div v-for="n in pageCount" :key="n" :class="{ active: n === currentPage }" @click="renderPage(n)" > <canvas :ref="`thumb${n}`"></canvas> </div> </div> </template> <script> // 在methods中添加 async renderThumbnails() { for (let i = 1; i <= this.pageCount; i++) { const page = await this.pdfDoc.getPage(i) const viewport = page.getViewport({ scale: 0.2 }) const canvas = this.$refs[`thumb${i}`][0] const context = canvas.getContext('2d') canvas.height = viewport.height canvas.width = viewport.width await page.render({ canvasContext: context, viewport: viewport }).promise } } </script> <style scoped> .thumbnail-container { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; } .thumbnail-container > div { border: 2px solid transparent; cursor: pointer; } .thumbnail-container > div.active { border-color: #42b983; } .thumbnail-container canvas { max-width: 100px; height: auto; } </style>

8.2 搜索功能

实现PDF内容搜索:

async searchText(query) { if (!query || !this.pdfDoc) return [] const results = [] for (let i = 1; i <= this.pageCount; i++) { const page = await this.pdfDoc.getPage(i) const textContent = await page.getTextContent() textContent.items.forEach(item => { if (item.str.includes(query)) { results.push({ page: i, text: item.str, transform: item.transform }) } }) } return results }

8.3 打印支持

添加打印功能:

printPDF() { const printWindow = window.open('', '_blank') printWindow.document.write(` <!DOCTYPE html> <html> <head> <title>打印PDF</title> <style> @page { size: auto; margin: 0mm; } body { margin: 0; padding: 0; } img { max-width: 100%; } </style> </head> <body> ${Array.from({ length: this.pageCount }) .map((_, i) => `<img src="${this.$refs.pdfCanvas.toDataURL('image/png')}">`) .join('')} </body> </html> `) printWindow.document.close() printWindow.focus() setTimeout(() => { printWindow.print() printWindow.close() }, 500) }

9. 项目集成建议

9.1 与Vuex/Pinia集成

管理PDF状态:

// store/pdf.js export const usePdfStore = defineStore('pdf', { state: () => ({ currentPage: 1, scale: 1.0, rotation: 0, bookmarks: [] }), actions: { addBookmark(page, note) { this.bookmarks.push({ page, note, createdAt: new Date() }) } } }) // 在组件中使用 import { usePdfStore } from '@/stores/pdf' export default { setup() { const pdfStore = usePdfStore() return { currentPage: computed({ get: () => pdfStore.currentPage, set: (val) => pdfStore.currentPage = val }), // ...其他属性 } } }

9.2 动态主题支持

添加主题切换功能:

<template> <div :class="[theme, 'pdf-viewer']"> <!-- ...原有内容 --> </div> </template> <script> export default { props: { theme: { type: String, default: 'light', validator: value => ['light', 'dark'].includes(value) } } } </script> <style scoped> .pdf-viewer.light { --bg-color: #ffffff; --text-color: #333333; --border-color: #dddddd; } .pdf-viewer.dark { --bg-color: #1e1e1e; --text-color: #f0f0f0; --border-color: #444444; } .pdf-viewer { background-color: var(--bg-color); color: var(--text-color); } canvas { border-color: var(--border-color); } /* ...其他样式 */ </style>

10. 测试与调试

10.1 单元测试示例

使用Jest测试核心功能:

import { shallowMount } from '@vue/test-utils' import PdfViewer from '@/components/PdfViewer.vue' describe('PdfViewer.vue', () => { it('正确初始化', () => { const wrapper = shallowMount(PdfViewer, { props: { src: 'test.pdf' } }) expect(wrapper.vm.currentPage).toBe(1) }) it('处理页码跳转', async () => { const wrapper = shallowMount(PdfViewer, { props: { src: 'test.pdf' }, data() { return { pageCount: 5 } } }) await wrapper.vm.goToPage(3) expect(wrapper.vm.currentPage).toBe(3) await wrapper.vm.goToPage(0) expect(wrapper.vm.currentPage).toBe(1) await wrapper.vm.goToPage(10) expect(wrapper.vm.currentPage).toBe(5) }) })

10.2 性能测试建议

使用Chrome DevTools进行性能分析:

  1. 记录PDF加载和渲染过程的性能时间线
  2. 检查内存使用情况,确保没有泄漏
  3. 测试不同大小PDF文件的渲染时间
  4. 模拟弱网环境测试加载表现

11. 部署注意事项

11.1 生产环境配置

推荐配置:

// vite.config.js export default defineConfig({ build: { rollupOptions: { external: ['pdfjs-dist'] } } }) // 在组件中动态加载 const pdfjsLib = await import('pdfjs-dist/build/pdf') pdfjsLib.GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker?url')

11.2 CDN优化

使用CDN加速资源加载:

<script> // 在index.html中预加载 const link = document.createElement('link') link.rel = 'preload' link.href = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.min.js' link.as = 'script' document.head.appendChild(link) </script>

12. 替代方案对比

12.1 其他PDF库比较

库名称优点缺点适用场景
pdf.js开源免费、功能全面、社区活跃大文件性能一般通用PDF预览
PSPDFKit商业级质量、功能强大收费昂贵企业级应用
PDFTron编辑功能强大、格式支持广价格高、学习曲线陡专业PDF编辑
PDFObject简单易用、轻量级功能有限简单嵌入

12.2 服务端渲染方案

对于超大PDF文件,可考虑服务端预渲染:

  1. 使用pdf-lib等库在服务端生成缩略图
  2. 将PDF转换为图片序列
  3. 使用WebSocket分片传输

13. 安全最佳实践

  1. 内容安全:
// 限制危险操作 if (this.pdfUrl.startsWith('javascript:')) { throw new Error('不安全的PDF URL') }
  1. 沙箱隔离:
<iframe sandbox="allow-scripts allow-same-origin" :src="safePdfUrl" ></iframe>
  1. 内容校验:
async validatePdf(buffer) { const header = new Uint8Array(buffer.slice(0, 4)) const pdfHeader = [0x25, 0x50, 0x44, 0x46] // %PDF return header.every((val, i) => val === pdfHeader[i]) }

14. 未来演进方向

  1. WebAssembly加速:利用pdf.js的WASM版本提升性能
  2. 增量加载:实现PDF文件的流式加载
  3. Web组件化:将PDF查看器封装为Web Component
  4. 3D注释支持:增强交互式文档体验

15. 实际案例分享

某电商平台发票系统优化:

  • 问题:原有方案使用后端渲染,高峰时段服务器负载高
  • 解决方案:改用pdf.js前端渲染
  • 效果:
    • 服务器负载降低70%
    • 用户等待时间缩短50%
    • 移动端体验显著改善

关键实现点:

  1. 使用Service Worker缓存常用发票模板
  2. 实现Base64直传避免二次请求
  3. 添加发票验真快捷入口

16. 资源推荐

  1. 官方文档:

    • pdf.js官方示例
    • API文档
  2. 学习资源:

    • 《PDF解析与渲染优化》技术手册
    • Vue3+pdf.js实战视频教程
  3. 工具推荐:

    • PDF调试工具:pdf-lib
    • 性能分析工具:PDF.js Viewer

17. 版本升级指南

从2.x升级到3.x注意事项:

  1. Worker加载方式变化:
// 旧版 pdfjsLib.GlobalWorkerOptions.workerSrc = 'path/to/pdf.worker.js' // 新版 const pdfjs = await import('pdfjs-dist/build/pdf') const worker = await import('pdfjs-dist/build/pdf.worker?url') pdfjs.GlobalWorkerOptions.workerSrc = worker
  1. API变更:
  • getDocument()返回类型变化
  • 文本提取方式优化
  • 注释处理API重构
  1. 性能改进:
  • WASM版本成为默认
  • 内存管理优化
  • 渲染管道重构

18. 移动端专项优化

  1. 触摸事件支持:
// 添加手势支持 this.$refs.pdfCanvas.addEventListener('touchstart', this.handleTouchStart) this.$refs.pdfCanvas.addEventListener('touchmove', this.handleTouchMove) methods: { handleTouchStart(e) { this.touchStartX = e.touches[0].clientX }, handleTouchMove(e) { const deltaX = e.touches[0].clientX - this.touchStartX if (Math.abs(deltaX) > 50) { if (deltaX > 0) this.prevPage() else this.nextPage() this.touchStartX = e.touches[0].clientX } } }
  1. 内存优化:
// 限制缓存大小 const MAX_CACHE_SIZE = 3 watch: { currentPage(newVal) { if (this.pageCache.size > MAX_CACHE_SIZE) { // 保留当前页和相邻页 const toDelete = [] this.pageCache.forEach((_, key) => { if (Math.abs(key - newVal) > 1) { toDelete.push(key) } }) toDelete.forEach(key => this.pageCache.delete(key)) } } }

19. 无障碍访问

  1. 屏幕阅读器支持:
<template> <div role="document" aria-label="PDF文档" aria-describedby="pdfStatus" > <!-- ...其他内容 --> <div id="pdfStatus" aria-live="polite"> 当前查看第{{ currentPage }}页,共{{ pageCount }}页 </div> </div> </template>
  1. 键盘导航:
mounted() { window.addEventListener('keydown', this.handleKeyDown) }, beforeUnmount() { window.removeEventListener('keydown', this.handleKeyDown) }, methods: { handleKeyDown(e) { switch(e.key) { case 'ArrowLeft': this.prevPage() break case 'ArrowRight': this.nextPage() break case '+': this.zoomIn() break case '-': this.zoomOut() break } } }

20. 项目脚手架

推荐项目结构:

src/ ├── components/ │ ├── PdfViewer/ │ │ ├── PdfViewer.vue # 主组件 │ │ ├── Thumbnails.vue # 缩略图组件 │ │ ├── Toolbar.vue # 工具栏组件 │ │ └── TextLayer.vue # 文本层组件 │ └── PdfControls/ │ ├── ZoomControls.vue │ └── PageControls.vue ├── composables/ │ └── usePdf.js # 组合式API ├── stores/ │ └── pdf.js # 状态管理 └── utils/ ├── pdf.js # PDF工具函数 └── worker.js # Worker管理

初始化命令:

npm create vite@latest pdf-viewer --template vue cd pdf-viewer npm install pdfjs-dist@2.16.105 pinia vue-router

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询