PDF.js 在 Vue 中的使用指南

环境准备

1. 安装依赖

bash 复制代码
npm install pdfjs-dist

2. 配置 Worker

PDF.js 需要 Worker 来处理 PDF 解析。推荐使用本地 Worker 文件:

步骤 1:复制 Worker 文件到 public 目录

bash 复制代码
# 从 node_modules 复制 worker 文件到 public 目录
copy node_modules\pdfjs-dist\build\pdf.worker.min.mjs public\pdf.worker.min.mjs

步骤 2:在代码中配置 Worker

typescript 复制代码
import * as pdfjsLib from 'pdfjs-dist'

// 方式1: 使用本地 worker 文件(推荐,稳定可靠)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// 方式2: 使用 CDN(备选方案,需要网络连接)
// pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`

3. Vite 配置(可选)

如果需要支持 ES2015 打包场景,在 vite.config.ts 中添加:

typescript 复制代码
export default defineConfig({
  // ... 其他配置
  build: {
    target: 'es2015',
    rollupOptions: {
      output: {
        format: 'es'
      }
    }
  },
  optimizeDeps: {
    include: ['pdfjs-dist', 'pdfjs-dist/web/pdf_viewer.mjs']
  }
})

方式一:单页查看器(基础版)

特点

  • ✅ 单页显示,分页导航
  • ✅ 支持缩放功能
  • ✅ 代码简单,易于理解
  • ❌ 不支持文本选择和复制
  • ❌ 一次只显示一页

核心代码

html 复制代码
<template>
  <div class="pdf-viewer-container">
    <!-- 文件上传 -->
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <!-- 控制按钮 -->
    <div v-if="pdfDoc">
      <el-button @click="previousPage">上一页</el-button>
      <el-button>{{ currentPage }} / {{ totalPages }}</el-button>
      <el-button @click="nextPage">下一页</el-button>
      <el-button @click="zoomIn">放大</el-button>
      <el-button @click="zoomOut">缩小</el-button>
    </div>
    <!-- Canvas 渲染 -->
    <div class="pdf-viewer">
      <canvas ref="pdfCanvas"></canvas>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 设置 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// 响应式数据
const pdfDoc = ref<any>(null)
const currentPage = ref(1)
const totalPages = ref(0)
const scale = ref(1.5)
const pdfCanvas = ref<HTMLCanvasElement | null>(null)

// 加载 PDF
const loadPdf = async (data: Uint8Array) => {
  const loadingTask = pdfjsLib.getDocument({ data })
  pdfDoc.value = await loadingTask.promise
  totalPages.value = pdfDoc.value.numPages
  await renderPage(1)
}

// 渲染页面
const renderPage = async (pageNum: number) => {
  const page = await pdfDoc.value.getPage(pageNum)
  const viewport = page.getViewport({ scale: scale.value })
  
  const canvas = pdfCanvas.value
  if (!canvas) return
  
  canvas.height = viewport.height
  canvas.width = viewport.width
  
  const context = canvas.getContext('2d')
  await page.render({
    canvasContext: context,
    viewport: viewport
  }).promise
}
</script>

演示

适用场景

  • 简单的 PDF 预览需求
  • 只需要单页查看
  • 不需要文本选择功能

方式二:多页查看器(性能优化版)

特点

  • ✅ 显示所有页面,垂直滚动
  • ✅ 懒加载优化(只渲染可见页面)
  • ✅ 支持文本选择和复制
  • ✅ 性能优化,适合大文档
  • ✅ 使用 Intersection Observer 实现懒加载

核心代码

html 复制代码
<template>
  <div class="pdf-viewer-container">
    <!-- 文件上传 -->
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <!-- 所有页面容器 -->
    <div class="pdf-viewer" ref="pdfViewer">
      <div v-for="pageNum in totalPages" :key="pageNum" :data-page="pageNum">
        <div class="page-header">第 {{ pageNum }} 页</div>
        <div class="page-content">
          <canvas class="page-canvas"></canvas>
          <div class="text-layer"></div>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 懒加载相关
const renderingPages = ref<Set<number>>(new Set())
const renderedPages = ref<Set<number>>(new Set())
let intersectionObserver: IntersectionObserver | null = null

// 初始化懒加载
const initLazyLoading = async () => {
  intersectionObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const pageNum = parseInt(entry.target.getAttribute('data-page') || '0')
          if (pageNum > 0 && !renderedPages.value.has(pageNum)) {
            renderPage(pageNum)
          }
        }
      })
    },
    {
      rootMargin: '200px 0px', // 提前 200px 开始加载
      threshold: 0.01
    }
  )

  // 观察所有页面容器
  for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
    const pageWrapper = pdfViewer.value?.querySelector(`[data-page="${pageNum}"]`)
    if (pageWrapper) {
      intersectionObserver.observe(pageWrapper)
    }
  }

  // 立即渲染前几页
  for (let pageNum = 1; pageNum <= Math.min(3, totalPages.value); pageNum++) {
    renderPage(pageNum)
  }
}

// 渲染单个页面(包含文本层)
const renderPage = async (pageNum: number) => {
  if (renderingPages.value.has(pageNum) || renderedPages.value.has(pageNum)) {
    return
  }

  renderingPages.value.add(pageNum)

  const pageWrapper = pdfViewer.value?.querySelector(`[data-page="${pageNum}"]`)
  const canvas = pageWrapper?.querySelector('canvas') as HTMLCanvasElement
  const textLayer = pageWrapper?.querySelector('.text-layer') as HTMLElement

  const page = await pdfDoc.value.getPage(pageNum)
  const viewport = page.getViewport({ scale: scale.value })

  // 渲染 Canvas
  canvas.height = viewport.height
  canvas.width = viewport.width
  const context = canvas.getContext('2d')
  await page.render({
    canvasContext: context,
    viewport: viewport
  }).promise

  // 渲染文本层(用于文字选择和复制)
  const textContent = await page.getTextContent()
  // ... 文本层渲染逻辑

  renderingPages.value.delete(pageNum)
  renderedPages.value.add(pageNum)
}
</script>

性能优化要点

  1. 懒加载:使用 Intersection Observer 只渲染可见页面
  2. 分批渲染:缩放时按批次重新渲染,避免阻塞 UI
  3. 状态管理:跟踪已渲染页面,避免重复渲染

演示

适用场景

  • 需要查看所有页面
  • 大文档(100+ 页)
  • 需要文本选择和复制
  • 需要性能优化

方式三:官方组件化查看器(推荐)

特点

  • ✅ 使用 PDF.js 官方组件(PDFViewer、PDFLinkService)
  • ✅ 自动文本层和注释层渲染
  • ✅ 完整的事件系统
  • ✅ 官方维护,稳定可靠
  • ✅ 可自定义样式和行为
  • 这是 PDF.js 官方推荐的生产环境使用方式

核心代码

html 复制代码
<template>
  <div class="pdf-viewer-container">
    <div class="pdf-controls">
      <el-upload :on-change="handleFileChange">
        <el-button>选择 PDF 文件</el-button>
      </el-upload>
    </div>
    <div class="pdf-viewer-wrapper">
      <div ref="pdfViewerContainer" class="pdf-viewer-container-inner"></div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { PDFViewer, EventBus, PDFLinkService } from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'

// 设置 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

let pdfViewer: PDFViewer | null = null
let eventBus: EventBus | null = null
const pdfViewerContainer = ref<HTMLElement | null>(null)

// 初始化 PDF 查看器
const initPDFViewer = async (pdfDocument: any) => {
  const container = pdfViewerContainer.value
  
  // 创建滚动容器(必须是绝对定位)
  const scrollContainer = document.createElement('div')
  scrollContainer.className = 'pdf-viewer-scroll'
  scrollContainer.style.position = 'absolute'
  scrollContainer.style.top = '0'
  scrollContainer.style.left = '0'
  scrollContainer.style.width = '100%'
  scrollContainer.style.height = '100%'
  scrollContainer.style.overflow = 'auto'
  
  // 创建查看器容器
  const viewer = document.createElement('div')
  viewer.className = 'pdfViewer'
  
  scrollContainer.appendChild(viewer)
  container.appendChild(scrollContainer)

  // 创建事件总线和链接服务
  eventBus = new EventBus()
  const linkService = new PDFLinkService({
    eventBus: eventBus,
    externalLinkTarget: 2
  })

  // 创建 PDFViewer 实例
  pdfViewer = new PDFViewer({
    container: scrollContainer,  // 外层滚动容器(必须绝对定位)
    viewer: viewer,              // 内层查看器容器
    eventBus: eventBus,
    linkService: linkService
  })

  // 设置 PDF 文档
  pdfViewer.setDocument(pdfDocument)

  // 监听事件
  eventBus.on('pagesinit', () => {
    totalPages.value = pdfViewer?.pagesCount || 0
  })

  eventBus.on('scalechanging', (evt: any) => {
    currentScale.value = evt.scale
  })
}

// 清理
onUnmounted(() => {
  if (pdfViewer) {
    pdfViewer.cleanup()
  }
})
</script>

重要配置

  1. Container 必须是绝对定位
typescript 复制代码
scrollContainer.style.position = 'absolute'
  1. 需要两个容器
    • container: 外层滚动容器(绝对定位)
    • viewer: 内层查看器容器(.pdfViewer
  1. 导入样式
typescript 复制代码
import 'pdfjs-dist/web/pdf_viewer.css'

演示

适用场景

  • 生产环境(最推荐)
  • 需要完整功能(文本选择、注释、链接)
  • 需要官方维护和更新
  • 需要自定义样式和行为

方式四:iframe 嵌入官方 HTML

特点

  • ✅ 直接使用官方 HTML 文件
  • ✅ 功能完整,无需额外开发
  • ❌ CDN 版本无法访问 Blob URL(跨域限制)
  • ❌ 样式隔离,难以自定义
  • ❌ 无法深度集成到 Vue 应用
  • ⚠️ 不推荐用于生产环境

核心代码

html 复制代码
<template>
  <div class="pdf-viewer-container">
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <div class="pdf-viewer-wrapper">
      <iframe
        v-if="viewerUrl"
        :src="viewerUrl"
        class="pdf-viewer-iframe"
        frameborder="0"
      ></iframe>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 设置 worker(注意:路径已更新)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-dist/build/pdf.worker.min.mjs'

const pdfUrl = ref<string>('')
const viewerUrl = ref<string>('')

// 检查本地查看器是否存在
const checkLocalViewer = async (): Promise<boolean> => {
  try {
    const response = await fetch('/pdfjs-dist/web/viewer.html', { method: 'HEAD' })
    return response.ok
  } catch {
    return false
  }
}

// 加载 PDF
const loadPdf = async (file: File) => {
  // 清理旧的 URL
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }

  // 创建 Blob URL
  const blob = new Blob([file], { type: 'application/pdf' })
  const url = URL.createObjectURL(blob)
  pdfUrl.value = url

  // 检查本地查看器是否存在
  const hasLocalViewer = await checkLocalViewer()
  
  if (!hasLocalViewer) {
    // 本地查看器不存在,提示用户配置
    console.error('本地查看器未找到,请按照以下步骤配置:')
    console.error('1. 访问 https://github.com/mozilla/pdf.js/releases 下载最新版本')
    console.error('2. 解压后将 web 目录复制到 public/pdfjs-dist/web/')
    console.error('3. 确保 viewer.html 的路径是 /pdfjs-dist/web/viewer.html')
    return
  }

  // ✅ 必须使用本地查看器(避免跨域问题)
  // ⚠️ CDN 版本无法访问 Blob URL,会导致跨域错误
  const viewerPath = '/pdfjs-dist/web/viewer.html'
  const encodedUrl = encodeURIComponent(url)
  viewerUrl.value = `${viewerPath}?file=${encodedUrl}`
}

// 清理资源
onUnmounted(() => {
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }
})
</script>

使用本地查看器

⚠️ 重要:必须使用本地查看器,CDN 版本无法访问 Blob URL(跨域限制)

  1. 下载 PDF.js 官方查看器:
    • 访问 github.com/mozilla/pdf...
    • 下载最新版本的预构建包(例如:pdfjs-5.4.449-dist.zip
    • 解压后找到 web 目录
  1. 复制到项目:
bash 复制代码
# 将 web 目录复制到 public/pdfjs-dist/web/
# 最终结构应该是:
public/
  └── pdfjs-dist/
      ├── build/
      │   └── pdf.worker.min.mjs
      └── web/
          ├── viewer.html
          ├── viewer.js
          ├── viewer.css
          └── ... (其他文件)
  1. 验证配置:
    • 启动开发服务器后,访问 http://localhost:xxxx/pdfjs-dist/web/viewer.html 应该能看到 PDF.js 官方查看器界面
    • 代码会自动检测本地查看器是否存在,如果不存在会提示配置步骤

演示

适用场景

  • 快速原型
  • 演示和测试
  • 不需要深度集成
  • ⚠️ 不推荐用于生产环境

最佳实践

1. Worker 配置

推荐:使用本地 Worker 文件

typescript 复制代码
// ✅ 推荐方式1: 放在 public 根目录
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// ✅ 推荐方式2: 放在 public/pdfjs-dist/build/ 目录(与官方查看器结构一致)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-dist/build/pdf.worker.min.mjs'

// ❌ 不推荐(可能有跨域问题)
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/...`

注意 :如果使用方式四(iframe),建议将 worker 文件放在 public/pdfjs-dist/build/ 目录,与官方查看器结构保持一致。

2. 文件验证

始终验证 PDF 文件:

typescript 复制代码
// 验证文件类型
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
  throw new Error('请选择 PDF 格式的文件')
}

// 验证 PDF 文件头
const header = String.fromCharCode(...uint8Array.slice(0, 4))
if (header !== '%PDF') {
  throw new Error('无效的 PDF 文件格式')
}

3. 错误处理

提供详细的错误信息:

typescript 复制代码
try {
  await loadPdf(data)
} catch (error: any) {
  let errorMessage = 'PDF 加载失败'
  if (error?.name === 'InvalidPDFException') {
    errorMessage = '无效的 PDF 文件结构,请确认文件是否损坏'
  } else if (error?.name === 'MissingPDFException') {
    errorMessage = 'PDF 文件缺失或无法访问'
  }
  $message?.error(errorMessage)
}

4. 性能优化

  • 懒加载:使用 Intersection Observer 只渲染可见页面
  • 分批渲染:缩放时按批次重新渲染
  • 内存管理:及时清理 Blob URL 和查看器实例
typescript 复制代码
// 清理 Blob URL
onUnmounted(() => {
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }
  if (pdfViewer) {
    pdfViewer.cleanup()
  }
})

5. 文本选择支持

如果需要文本选择,必须添加文本层:

typescript 复制代码
// 获取文本内容
const textContent = await page.getTextContent()

// 创建文本层元素
textContent.items.forEach((item: any) => {
  const span = document.createElement('span')
  span.textContent = item.str
  span.style.position = 'absolute'
  span.style.color = 'transparent' // 透明,不影响视觉效果
  span.style.cursor = 'text'
  textLayer.appendChild(span)
})

常见问题

Q1: Worker 加载失败

错误Setting up fake worker failed

解决方案

  1. 确保 worker 文件在 public 目录
  2. 使用本地 worker 文件而不是 CDN
  3. 检查路径是否正确

Q2: PDF 文件无法加载

错误Invalid PDF structure

解决方案

  1. 验证文件是否为有效的 PDF 格式

  2. 检查文件是否损坏

  3. 添加文件头验证

Q3: 打包后无法运行

错误:CORS 错误

解决方案

  1. 不要直接用 file:// 打开打包后的文件
  2. 使用 npm run preview 预览
  3. 或使用本地服务器(http-server、Python 等)

Q4: PDFViewer 初始化失败

错误Invalid container and/or viewer option

解决方案

  1. 确保 container 是绝对定位
  2. 提供 containerviewer 两个参数
  3. 等待 DOM 更新后再初始化

Q5: iframe 方式跨域错误

错误SecurityError: Failed to read a named property 'document' from 'Window': Blocked a frame with origin "https://mozilla.github.io" from accessing a cross-origin frame

原因

  • 使用 CDN 版本的官方查看器(https://mozilla.github.io
  • Blob URL 无法在跨域 iframe 中访问

解决方案

  1. 必须使用本地查看器(推荐)
    • 下载 PDF.js 官方查看器
    • web 目录复制到 public/pdfjs-dist/web/
    • 代码会自动检测本地查看器是否存在
  1. 路径配置
typescript 复制代码
// ✅ 正确:使用本地查看器
const viewerPath = '/pdfjs-dist/web/viewer.html'

// ❌ 错误:CDN 版本无法访问 Blob URL
const viewerPath = 'https://mozilla.github.io/pdf.js/web/viewer.html'
  1. 验证配置
    • 访问 http://localhost:3016/pdfjs-dist/web/viewer.html 应该能看到查看器界面

    • 如果不存在,代码会提示配置步骤


总结

选择建议

场景 推荐方式
生产环境 方式三(官方组件化)
需要查看所有页面 方式二(多页查看器)
简单预览 方式一(单页查看器)
快速原型 方式四(iframe)

核心要点

  1. Worker 配置:使用本地 worker 文件
  2. 文件验证:始终验证 PDF 文件格式
  3. 错误处理:提供详细的错误信息
  4. 性能优化:使用懒加载和分批渲染
  5. 内存管理:及时清理资源
  6. 生产环境:推荐使用官方组件化方式

参考资源

相关推荐
鹘一2 小时前
Prompts 组件实现
前端·javascript
大菜菜2 小时前
Molecule Framework - ExplorerService API 详细文档
前端
_一两风2 小时前
Vue-TodoList 项目详解
前端·javascript·vue.js
北辰alk2 小时前
Vue中mixin与mixins:全面解析与实战指南
前端·vue.js
脾气有点小暴2 小时前
UniApp实现刷新当前页面
开发语言·前端·javascript·vue.js·uni-app
YaeZed2 小时前
Vue3-全局组件 && 递归组件
前端·vue.js
一只Viki2 小时前
给 CS2 Major 竞猜做了个在线抄作业网站
前端
八点2 小时前
Electron 应用中 Sharp 模块跨架构兼容性问题解决方案
前端
黑臂麒麟2 小时前
DevUI modal 弹窗表单联动实战:表格编辑功能完整实现
前端·javascript·ui·angular.js