Code前端首页关于Code前端联系我们

Vue3里怎么实现不依赖第三方付费插件的流畅PDF预览?

terry 15小时前 阅读数 197 #Vue

最近做Vue3的项目时,碰到了客户要求的PDF嵌入需求——不能插iframe跳外链(怕跳丢流量),不能用带限制的付费预览服务,手机端、PC端都要适配,还得有基础的翻页、缩放、打印功能,一开始以为很难,搜了一堆零散教程踩了不少坑,折腾了两天终于摸出了一套稳定、免费的方案,今天就用大家日常能听懂的话,把从选型到踩坑再到优化的全流程理清楚。

为什么不直接用HTML5原生的或者

很多人第一反应会想到这俩原生标签,我一开始也试了,确实快——直接把PDF文件的路径塞进去就行,浏览器自带的PDF阅读器会接管,但踩过的三个大坑让我放弃了这个方案:

第一个坑是兼容性参差不齐,你永远不知道用户用的是什么浏览器内核:IE就不说了,早就淘汰了,但国内的360极速、QQ浏览器这类双核浏览器,如果用户默认切到了兼容模式(很多政企老系统或者家庭电脑还会这么设置),原生标签要么白屏要么直接提示下载,完全没法用。

第二个坑是功能完全不可控,浏览器自带的阅读器UI长什么样、有什么按钮,我们开发者说了不算——比如有些浏览器没有“跳转到指定页码”的按钮,有些手机端阅读器会直接全屏隐藏原页面的导航栏,这对电商详情页或者企业文档系统这种需要保持页面结构的场景来说,简直是致命伤。

第三个坑是文件体积大的时候加载体验差,原生标签没有懒加载单页PDF的能力,整个文件一次性全部下载下来,要是碰到几百兆的技术手册或者设计稿,用户可能要等几十秒甚至更久,耐心早就磨没了。

选什么免费开源库最稳妥?

既然原生标签不行,那肯定得找开源库,我对比了Vue3生态里主流的三个:pdfjs-dist、vue-pdf-app-next、vue-pdf-embed。

vue-pdf-app-next其实是基于pdf.js改的Vue3组件,一开始我觉得这个会最顺手,毕竟封装好了很多功能,但实际用的时候发现,最近一年作者好像没怎么更新了,文档也写得比较简略,碰到手机端双指缩放卡顿的问题,翻遍了GitHub的issue区也没找到官方解决方案,只能自己硬啃底层代码改,效率太低。

vue-pdf-embed也是封装好的,而且更新频率比vue-pdf-app-next高一点,但它只保留了最基础的渲染、翻页功能,要是想加打印、搜索、缩略图侧边栏,还得自己手动集成pdf.js的其他模块,相当于既要用封装层,又要懂底层,有点“画蛇添足”的意思。

最后选的是直接用原生的pdfjs-dist,别被“原生”两个字吓到,其实pdf.js官方早就提供了适配各种框架的思路,而且它的兼容性是所有方案里最好的——覆盖了Chrome、Firefox、Safari(包括iOS14之前的老版本)、Edge,国内的主流双核浏览器在极速模式下也完全没问题,功能最全,文档也非常详细,遇到问题查起来方便。

从零开始搭建Vue3+pdfjs-dist的PDF预览功能

废话不多说,直接上干货,分PC端基础版和手机端优化版两部分讲。

第一步:安装依赖和引入资源

pdfjs-dist虽然是原生库,但有两个版本:一个是普通的CommonJS版本,另一个是ES模块版本,推荐用ES模块版本,配合Vite或者Webpack的tree-shaking功能,可以减小打包体积。

安装依赖很简单,直接在终端敲:

npm install pdfjs-dist

安装好之后,关键一步来了——别直接在代码里import完整的pdf.js,因为里面的worker文件太大了,直接打包会导致首屏加载慢,pdf.js官方推荐的做法是把worker文件和字体文件(用来渲染中文、日文、韩文这些非拉丁文字)单独放到项目的静态资源目录里,然后通过CDN或者本地路径引入。

如果是用Vite的项目,静态资源目录默认是public,我们可以把node_modules/pdfjs-dist/build/pdf.worker.min.js和node_modules/pdfjs-dist/cmaps整个文件夹(注意是整个文件夹!因为cmaps里有很多支持不同语言的映射文件)复制到public目录下,如果是用Webpack的项目,静态资源目录一般是src/assets或者public,具体看你的配置,复制的步骤是一样的。

第二步:写基础的渲染组件

我们先写一个单文件组件PDFViewer.vue,实现最基础的功能:加载PDF文件、渲染第一页、上一页/下一页翻页、跳转到指定页码、缩放、显示总页数和当前页码。

首先在script setup里引入pdf.js的核心模块,并设置worker和cmaps的路径:

import { onMounted, ref, watch } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
// 设置worker文件的路径
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js'
// 组件的props,用来接收PDF文件的路径或者Blob对象
const props = defineProps({
  pdfUrl: {
    type: [String, Blob],
    required: true
  }
})
// 定义响应式变量
const pdfDoc = ref(null) // 整个PDF文档的对象
const currentPage = ref(1) // 当前页码
const totalPages = ref(0) // 总页数
const scale = ref(1) // 缩放比例,1代表原始大小
const canvasRef = ref(null) // 用来渲染PDF的canvas元素
const pdfContainerRef = ref(null) // canvas的父容器,用来适配宽度

然后写一个加载PDF文档的函数loadPdf:

const loadPdf = async () => {
  try {
    // 如果props.pdfUrl是Blob对象,需要先转换成URL
    let url = props.pdfUrl
    if (url instanceof Blob) {
      url = URL.createObjectURL(url)
    }
    // 加载PDF文档
    const loadingTask = pdfjsLib.getDocument({
      url: url,
      cMapUrl: '/cmaps/', // 设置cmaps的路径
      cMapPacked: true // 表示cmaps是压缩过的
    })
    pdfDoc.value = await loadingTask.promise
    totalPages.value = pdfDoc.value.numPages
    // 加载完文档后渲染第一页
    renderPage(currentPage.value)
  } catch (error) {
    console.error('PDF加载失败:', error)
    alert('PDF文件加载失败,请检查文件路径是否正确或者文件是否损坏')
  }
}

接下来写渲染单页的函数renderPage:

const renderPage = async (pageNum) => {
  try {
    // 获取指定页码的页面对象
    const page = await pdfDoc.value.getPage(pageNum)
    // 获取父容器的宽度,用来计算缩放比例,实现自适应宽度
    const containerWidth = pdfContainerRef.value.clientWidth
    const viewport = page.getViewport({ scale: 1 })
    const calculatedScale = containerWidth / viewport.width
    // 如果用户手动设置了缩放比例,就用用户设置的,否则用自适应的
    const finalScale = scale.value === 1 ? calculatedScale : scale.value
    const finalViewport = page.getViewport({ scale: finalScale })
    // 获取canvas的2D上下文
    const canvas = canvasRef.value
    const context = canvas.getContext('2d')
    // 设置canvas的宽高
    canvas.width = finalViewport.width
    canvas.height = finalViewport.height
    // 渲染PDF页面到canvas上
    const renderContext = {
      canvasContext: context,
      viewport: finalViewport
    }
    await page.render(renderContext).promise
    // 更新当前页码
    currentPage.value = pageNum
  } catch (error) {
    console.error('PDF页面渲染失败:', error)
  }
}

然后写一些交互函数,比如上一页、下一页、跳转到指定页码、放大、缩小、恢复原始大小:

// 上一页
const prevPage = () => {
  if (currentPage.value > 1) {
    renderPage(currentPage.value - 1)
  }
}
// 下一页
const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    renderPage(currentPage.value + 1)
  }
}
// 跳转到指定页码
const jumpToPage = (e) => {
  const pageNum = parseInt(e.target.value)
  if (pageNum >= 1 && pageNum <= totalPages.value) {
    renderPage(pageNum)
  } else {
    alert(`请输入1到${totalPages.value}之间的页码`)
  }
}
// 放大
const zoomIn = () => {
  if (scale.value < 3) { // 限制最大缩放比例为3倍,避免canvas太大导致浏览器卡顿
    scale.value += 0.25
    renderPage(currentPage.value)
  }
}
// 缩小
const zoomOut = () => {
  if (scale.value > 0.5) { // 限制最小缩放比例为0.5倍,避免看不清
    scale.value -= 0.25
    renderPage(currentPage.value)
  }
}
// 恢复原始大小
const resetScale = () => {
  scale.value = 1
  renderPage(currentPage.value)
}

然后在onMounted钩子函数里调用loadPdf,监听pdfUrl的变化,重新加载PDF:

onMounted(() => {
  loadPdf()
})
watch(() => props.pdfUrl, () => {
  if (props.pdfUrl) {
    loadPdf()
  }
})

最后写template部分,布局要简洁,PC端和手机端都能适配:

<template>
  <div class="pdf-viewer-wrapper">
    <!-- 工具栏 -->
    <div class="pdf-toolbar">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
      <span>{{ currentPage }} / {{ totalPages }}</span>
      <input type="number" :min="1" :max="totalPages" @change="jumpToPage" :value="currentPage" />
      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
      <div class="pdf-zoom-buttons">
        <button @click="zoomOut">-</button>
        <button @click="resetScale">原始大小</button>
        <button @click="zoomIn">+</button>
      </div>
    </div>
    <!-- PDF容器 -->
    <div class="pdf-container" ref="pdfContainerRef">
      <canvas ref="canvasRef"></canvas>
    </div>
  </div>
</template>

再加点简单的CSS样式,让它看起来更美观:

.pdf-viewer-wrapper {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  box-sizing: border-box;
}
.pdf-toolbar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 15px;
  margin-bottom: 20px;
  padding: 10px 20px;
  background-color: #f5f5f5;
  border-radius: 8px;
  flex-wrap: wrap;
}
.pdf-toolbar button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: #409eff;
  color: white;
  cursor: pointer;
  transition: all 0.3s;
}
.pdf-toolbar button:hover {
  background-color: #66b1ff;
}
.pdf-toolbar button:disabled {
  background-color: #c0c4cc;
  cursor: not-allowed;
}
.pdf-toolbar input {
  width: 60px;
  padding: 6px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  text-align: center;
}
.pdf-container {
  width: 100%;
  overflow-x: auto;
  text-align: center;
}
.pdf-container canvas {
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

第三步:加打印功能

打印功能其实也不难,pdf.js官方提供了PrintManager模块,但直接用的话有点复杂,我给大家简化了一个方案——利用浏览器的打印API,把当前渲染的canvas或者所有页面的canvas打印出来。

如果只想打印当前页面,那很简单,直接在组件里加一个打印按钮,然后写一个printCurrentPage函数:

const printCurrentPage = () => {
  const canvas = canvasRef.value
  const dataUrl = canvas.toDataURL('image/png')
  const printWindow = window.open('', '_blank')
  printWindow.document.write(`
    <html>
      <head>
        <title>打印当前页</title>
        <style>
          body {
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
          }
          img {
            max-width: 100%;
            max-height: 100vh;
          }
        </style>
      </head>
      <body>
        <img src="${dataUrl}" />
      </body>
    </html>
  `)
  printWindow.document.close()
  printWindow.onload = () => {
    printWindow.print()
    printWindow.close()
  }
}

如果想打印整个PDF文档,那就要先把所有页面都渲染成canvas,然后再生成一个包含所有图片的临时页面打印,但要注意,如果PDF文档页数很多,渲染所有页面会消耗大量的内存和时间,所以一般建议只提供打印当前页或者打印指定范围页的功能。

第四步:手机端优化

手机端的主要优化点有两个:一是适配屏幕宽度,二是支持双指缩放和双击缩放。

适配屏幕宽度其实我们在基础版里已经实现了,就是通过父容器的宽度计算缩放比例,这个在手机端也能用。

支持双指缩放和双击缩放可以用原生的touch事件,也可以用第三方库比如hammer.js,我推荐用hammer.js,因为它封装好了各种手势事件,用起来非常简单。

首先安装hammer.js:

npm install hammer.js

然后在PDFViewer.vue里引入,并绑定到pdfContainerRef上:

import Hammer from 'hammer.js'
// 在onMounted钩子函数里添加
onMounted(() => {
  loadPdf()
  // 绑定hammer.js的手势事件
  const hammer = new Hammer(pdfContainerRef.value)
  // 启用双击和双指缩放手势
  hammer.get('doubletap').set({ enable: true })
  hammer.get('pinch').set({ enable: true })
  // 双击缩放
  hammer.on('doubletap', (e) => {
    if (scale.value === 1) {
      // 双击放大到2倍
      scale.value = 2
    } else {
      // 双击恢复原始大小
      resetScale()
    }
    renderPage(currentPage.value)
  })
  // 双指缩放
  let initialScale = 1
  hammer.on('pinchstart', (e) => {
    initialScale = scale.value
  })
  hammer.on('pinchmove', (e) => {
    let newScale = initialScale * e.scale
    // 限制缩放比例在0.5到3倍之间
    newScale = Math.max(0.5, Math.min(3, newScale))
    scale.value = newScale
    renderPage(currentPage.value)
  })
})

踩过的其他坑和优化建议

除了前面提到的worker文件和cmaps的配置,我还踩了几个其他的坑,给大家提个醒:

第一个坑是中文乱码,这个就是因为没有配置cmaps导致的,只要把node_modules/pdfjs-dist/cmaps整个文件夹复制到静态资源目录,并在getDocument的参数里设置cMapUrl和cMapPacked就可以解决。

第二个坑是大文件加载慢,这个可以通过pdf.js的懒加载功能来优化,只加载当前页和相邻的几页,不过实现起来比较复杂,适合页数特别多的项目,如果项目页数不是特别多(比如少于50页),可以用loadingTask的onProgress回调函数来显示加载进度,提升用户体验。

第三个坑是canvas渲染模糊,这个是因为现在的手机和电脑屏幕大多是高DPI的,canvas的默认分辨率是1:1的,所以渲染出来会模糊,解决方法是把canvas的宽高乘以设备的像素比(devicePixelRatio),然后再用CSS把canvas的宽高设置成原来的大小,我们可以修改renderPage函数里的canvas宽高设置部分:

// 获取设备的像素比
const dpr = window.devicePixelRatio || 1
// 设置canvas的物理宽高(乘以像素比)
canvas.width = finalViewport.width * dpr
canvas.height = finalViewport.height * dpr
// 设置canvas的CSS宽高(保持原来的大小)
canvas.style.width = `${finalViewport.width}px`
canvas.style.height = `${finalViewport.height}px`
// 缩放2D上下文,适配高DPI
context.scale(dpr, dpr)

最后给大家几个优化建议:

  1. 如果PDF文件很大,可以考虑先压缩一下,比如用SmallPDF或者Adobe Acrobat Pro,压缩率能达到50%以上,而且不会太影响画质。
  2. 如果项目是部署在CDN上的,可以把pdf.worker.min.js和cmaps文件夹也上传到CDN,这样加载速度会更快。
  3. 可以添加一个加载动画,在PDF加载和页面渲染的时候显示,提升用户体验。
  4. 如果需要搜索功能,可以自己集成pdf.js的TextLayerBuilder模块,实现起来也不难。

好了,以上就是我从零开始搭建Vue3+pdfjs-dist的PDF预览功能的全流程,希望能帮到大家,如果有什么问题,欢迎在评论区留言交流。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门