Vue3 + pdfjs-dist 实现 PDF 预览完整指南

发布时间:2026/10/2 19:35:04
Vue3 + pdfjs-dist 实现 PDF 预览完整指南 做前端时间久了你会发现凡是和“预览”两个字沾边的事多少都带点坑。PDF 预览在 vue3 项目里就是个典型场景后台管理系统要预览合同、商城要预览电子发票、可视化大屏要嵌入报表网上搜索 vue3 pdfjs教程其实不少但要么是旧版本写法要么只贴代码不解释原因照着抄完一脸懵。这篇文章把我自己在 vue3 项目里用 pdfjs-dist 做 PDF 预览的完整过程、版本选型、代码实现、以及踩过的坑全部整理出来从零开始手把手跑通一个能翻页、能缩放、能旋转、能下载的 PDF 预览功能。不管你用的是 vite 创建 vue3 项目还是公司的 vue3 后台管理系统这套思路和代码都能直接抄作业。先说明一个容易踩坑的点pdfjs 的官方包名是 pdfjs-dist不是 pdfjs。网上很多老教程让你 npm install pdfjs 或者 vue-pdf那些要么停更好几年要么依赖的还是旧版 pdf.js在 vue3 里用起来一堆兼容问题。所以下面的内容统一使用pdfjs-dist这也是目前官方维护的主包。1. 项目准备为什么选 pdfjs-dist1.1 先聊聊几种 PDF 预览方案的取舍我见过不少朋友在群里问“vue3 怎么预览 PDF”底下的回答五花八门有人说直接用 iframe 套一下不就行了有人说用 window.open 打开也有人说用 vue-pdf还有人推荐 pdfobject。这些方案我都试过各有各的局限性简单梳理一下iframe 预览是最“省事”的方案浏览器自带 PDF 插件就能渲染。但问题也很明显不同浏览器的 PDF 插件界面不统一Edge、Chrome、Firefox 展示出来完全不是一套 UI移动端 Safari 甚至直接下载而不是预览而且 iframe 里你没法拿到 PDF 的页码、缩放比例这些信息想做个自定义工具栏基本不可能。如果你只是临时看个文件iframe 够用但要做成产品功能它撑不住。vue-pdf 这个插件在 vue2 时代用得很多它是基于 pdfjs-dist 二次封装的组件。但它的问题是更新太慢很多版本还停留在 pdf.js 2.x 的 API 上在 vue3 里需要装 vue-pdfnext 才能用而且依赖的 pdfjs-dist 版本老像文字复制、特殊字体的支持都跟不上。我自己试过一次遇到一个带复杂注释的 PDF渲染出来直接乱码。pdfjs-dist 是 Mozilla 官方的 pdf.js 项目发布的 npm 包pdf.js 本身就是 Firefox 内置的 PDF 渲染引擎可以说是目前浏览器端渲染 PDF 最成熟、最活跃的方案。它不依赖任何框架在 vue3、react、原生 JS 里都能用灵活性最高。虽然上手要比 iframe 多写几行代码但换来的是完全可控的渲染结果以及翻页、缩放、旋转、打印这些都能自己定制。下面用一个表格把几个方案的差异列出来方便你按需选择方案自定义能力vue3 兼容性移动端表现适合场景iframe / window.open几乎为零无依赖部分浏览器直接下载临时查看、后台管理简单预览pdfobject低仅嵌入展示一般依赖浏览器插件不推荐插件已较少维护vue-pdf中基于旧版 pdf.js需用 next 分支一般老项目迁移不建议新项目使用pdfjs-dist高完全可控优秀可在 canvas 上渲染需要自定义工具栏、性能可控的正式功能从右上角这张表的对比可以看出来如果你只是给后台管理系统的附件列表加一个“点击查看”的功能iframe 的确省事。但只要涉及到“预览体验”四个字比如你要自定义上一页/下一页按钮、要显示“当前页 / 总页数”、要放大缩小、要旋转矫正扫描件方向那就绕不开 pdfjs-dist。1.2 创建 Vue3 项目和安装依赖这一节我们先把环境拉起来。如果你已经有现成的 vue3 项目直接跳到安装依赖的部分就行。我习惯用 vite 创建项目比起 webpack 那套配置vite 启动速度快对 pdfjs-dist 这种有 worker 资源的库也更好处理。npm create vitelatest my-pdf-demo -- --template vue cd my-pdf-demo npm install项目创建好之后安装 pdfjs-distnpm install pdfjs-dist这里要特别提醒版本问题。pdfjs-dist 的大版本迭代很快3.x 和 4.x 之间 API 有一些变化。我写这篇文章时用的版本是 4.x具体到安装的时候你可以用npm install pdfjs-dist4固定大版本避免哪天更新到 5.x 之后出现兼容性问题。安装完后可以在 package.json 里确认一下版本号确保后续代码能跑通。如果你用的是 TypeScript 项目还需要注意类型声明。4.x 版本自带类型定义不需要额外安装 types/pdfjs-dist这点比老版本友好很多。同样是基于 vite 的 vue3 ts 项目直接 import 就能有代码提示。1.3 为什么必须先配置 Workerpdfjs-dist 刚上手时最容易摔倒的地方就是 worker 配置。我先把结论放在这里必须给GlobalWorkerOptions.workerSrc赋值否则运行时会报错。Worker 的作用得从 pdf.js 的渲染机制说起。PDF 文件的解析是 CPU 密集型操作如果放在浏览器主线程里做用户滚动页面、点击按钮都会卡顿体验非常差。pdf.js 的设计是把解析工作放到 Web Worker 中执行解析完成后再把结果传回主线程渲染。这样可以保证界面流畅。Worker 本身也是一个 JS 文件浏览器需要知道这个文件从哪里加载所以我们要显式地告诉它 workerSrc 的路径。在 vite 项目中最稳妥的方式是用?url后缀把 worker 文件作为静态资源导入import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min.mjs?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;?url是 vite 提供的一个导入方式它会把文件复制到构建输出目录然后返回这个文件在运行时的 URL。这样开发环境、生产环境都能正确加载 worker 文件不需要手工拷贝到 public 目录。老一些的做法是去 node_modules/pdfjs-dist/build/ 下面手动把 pdf.worker.min.mjs 复制到 public 目录再写死路径。这样做的问题在于项目部署到子路径时容易 404版本升级时容易漏掉文件。通过?url导入的方式就没有这些顾虑构建工具会自动处理。2. 基础流程让第一页 PDF 渲染到页面上2.1 初始化一个最小的 PDF 预览组件这一节我们实现一个最精简的版本指定一个 PDF 的 URL把它渲染到 canvas 上。目标是把核心流程跑通后面再逐步加入交互功能。新建一个组件命名为 PdfPreview.vuetemplate div classpdf-preview canvas refcanvasRef/canvas /div /template script setup import { ref, onMounted } from vue; import * as pdfjsLib from pdfjs-dist; import workerUrl from pdfjs-dist/build/pdf.worker.min.mjs?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl; const canvasRef ref(null); let pdfDoc null; async function loadPdf(url) { const loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; const page await pdfDoc.getPage(1); const viewport page.getViewport({ scale: 1.5 }); const canvas canvasRef.value; canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; } onMounted(() { loadPdf(https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf); }); /script这段代码做了四件事我按顺序拆开解释。第一调用getDocument(url)创建一个加载任务。这里传入的是 PDF 文件的地址可以是线上 URL也可以是一个Uint8Array类型的数据后面讲本地文件的时候会用到。getDocument返回的是一个PDFDocumentLoadingTask对象调用它的promise属性等待加载完成。第二拿到pdfDoc之后用getPage(1)获取第一页的内容。注意页码从 1 开始不是从 0 开始这是 pdf.js 里面容易忽略的小细节。第三getViewport({ scale: 1.5 })计算当前页面要渲染到多宽的画布上。PDF 文件的单位是 pt1 英寸等于 72pt而屏幕上的 1px 等于 1/96 英寸所以要放大 96/72 倍才能让 PDF 的实际大小和屏幕尺寸 1:1 对应。scale: 1.5表示再放大 1.5 倍。实际项目中我们一般是根据容器宽度动态计算 scale让 PDF 自适应显示这个后面详细讲。第四在 canvas 上执行渲染。page.render接收两个核心参数canvasContext是 canvas 的 2d 上下文viewport是之前计算好的视图区域。这个方法的返回值也是一个 Promise等到 resolve 就说明这一页画完了。这里有个常见的坑canvas 的宽高必须设置到画布属性上也就是canvas.width和canvas.height而不是通过 CSS 设置。如果只写 CSS 的 width、height要么画面模糊要么只显示左上角一部分。因为 canvas 的绘图缓冲区大小是由 width、height 属性决定的CSS 只是拉伸显示。2.2 根据容器宽度动态计算缩放比例上一节里面我写死了scale: 1.5实际项目中肯定不能这么写。不同屏幕分辨率下同一个 PDF 可能有的是“大字报”有的是“蚂蚁文”。更好的做法是让 PDF 自适应容器宽度。思路是这样的先不缩放拿到页面的原始 viewport然后用容器的可用宽度除以原始宽度得到合适的缩放比例。代码如下async function renderPage(pdfDoc, pageNum, containerWidth) { const page await pdfDoc.getPage(pageNum); const baseViewport page.getViewport({ scale: 1 }); const scale containerWidth / baseViewport.width; const viewport page.getViewport({ scale }); const canvas canvasRef.value; canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; }这个计算方式不是百分之百严谨比如高 DPI 屏幕下要做像素比校正不然文字边缘容易发虚。但对大多数业务场景来说这个简单的自适应已经够用。如果你在意高清屏的显示效果可以在 scale 基础上再乘以window.devicePixelRatio同时用 CSS 把 canvas 显示尺寸固定为未放大的尺寸const dpr window.devicePixelRatio || 1; canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width viewport.width px; canvas.style.height viewport.height px;我这里提一下这个思路但不在这篇文章里展开因为涉及设备像素比的细节比较多后面有机会单独写一篇讲高清 PDF 渲染。2.3 渲染任务的对象和 Promise 陷阱render 方法返回的不是普通的 Promise而是RenderTask对象。它除了promise属性之外还有一个cancel()方法可以用来取消正在进行的渲染。这个 API 在翻页、缩放时需要重点使用。啥时候会用到 cancel想象一下用户快速点“下一页”按钮第一页还没渲染完第二页的渲染任务已经启动了。这时候如果不处理两个 render 会抢同一个 canvas表现为页面闪烁、内容错乱。解决办法是每次重新渲染之前把上一次的渲染任务取消掉let renderTask null; async function renderPage(pdfDoc, pageNum, scale) { if (renderTask) { renderTask.cancel(); } const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale }); const canvas canvasRef.value; canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); renderTask page.render({ canvasContext: ctx, viewport }); await renderTask.promise; }3. 交互进阶翻页、缩放、旋转、下载全套实现3.1 状态管理和完整组件代码有了基础渲染能力下面把它扩充成一个可用的预览组件。这个组件会包含状态管理、翻页、缩放、旋转、页码跳转等功能。先用 ref 管理几个关键状态const currentPage ref(1); const totalPages ref(0); const scale ref(1); const rotation ref(0); const isLoading ref(false); const errorMsg ref();currentPage是当前页totalPages是 PDF 总页数scale是缩放比例rotation是旋转角度isLoading用于展示加载状态errorMsg用来提示错误信息。加载 PDF 的时候把总页数存下来async function loadPdf(url) { if (loadingTask) { await loadingTask.destroy(); } isLoading.value true; errorMsg.value ; currentPage.value 1; rotation.value 0; try { loadingTask pdfjsLib.getDocument(url); pdfDoc await loadingTask.promise; totalPages.value pdfDoc.numPages; await renderCurrentPage(); } catch (err) { errorMsg.value PDF 加载失败 err.message; console.error(err); } finally { isLoading.value false; } }这里有一个细节每次 loadPdf 之前调用loadingTask.destroy()作用是销毁上一个 PDF 文档实例释放 worker 占用、解除内存引用。如果不销毁加载多个 PDF 后页面会越来越卡因为老的文档对象还占着内存。然后封装一个统一的翻页方法async function renderCurrentPage() { if (!pdfDoc) return; const page await pdfDoc.getPage(currentPage.value); const baseViewport page.getViewport({ scale: 1, rotation: rotation.value }); const fitScale containerWidth.value / baseViewport.width; const finalScale fitScale * scale.value; const viewport page.getViewport({ scale: finalScale, rotation: rotation.value }); const canvas canvasRef.value; canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); renderTask page.render({ canvasContext: ctx, viewport }); await renderTask.promise; }这个里面关键的是finalScale的计算fitScale让 PDF 适配容器宽度再乘以用户手动设置的scale通过放大/缩小按钮控制这样既保证默认情况下 PDF 完整显示又允许用户手动缩放。模板部分可以加上一个简单的工具栏template div classpdf-preview div classpdf-toolbar button clickprevPage :disabledcurrentPage 1上一页/button input v-model.numbercurrentPage typenumber min1 :maxtotalPages / span/ {{ totalPages }}/span button clicknextPage :disabledcurrentPage totalPages下一页/button button clickzoomOut缩小/button span{{ Math.round(scale.value * 100) }}%/span button clickzoomIn放大/button button clickrotate旋转/button button clickdownload下载/button /div canvas refcanvasRef/canvas div v-ifisLoading classpdf-loading加载中.../div div v-iferrorMsg classpdf-error{{ errorMsg }}/div /div /template上面代码里的currentPage通过v-model.number绑定了输入框用户可以直接输入页码。这里我加了一个注意事项如果你把currentPage直接绑定到翻页逻辑上就要处理输入框变化和翻页的关系不然用户还没输完数字就触发多次翻页。下一小节我会讲讲我在项目中实际采用的 watch 处理方式。3.2 翻页、缩放、旋转的边界处理翻页的逻辑很好写function prevPage() { if (currentPage.value 1) { currentPage.value--; } } function nextPage() { if (currentPage.value totalPages.value) { currentPage.value; } }页码变化之后需要重新渲染所以用 watch 监听 currentPage 变化触发 renderCurrentPagewatch(currentPage, async () { if (pdfDoc) { await renderCurrentPage(); } });也有人直接在 prevPage、nextPage 里面手动调用 renderCurrentPage不写 watch。两种方式都可以但用 watch 的好处是用户直接在输入框里输入页码时也能自动重新渲染不会漏掉入口。这里有一个体验上的细节要注意用户可能在输入框里输入一个中间值比如从第 5 页想跳到第 20 页他可能会先清空输入框这时 currentPage 变成了 null然后输入 20。如果 watch 捕获到 null 就触发渲染页面会报错。我的处理办法是判断一下输入值是否在有效范围内watch(currentPage, async (val) { if (!val || val 1 || val totalPages.value) return; await renderCurrentPage(); });缩放比例的边界也要限制。我一般把最小缩放设为 0.5最大设为 4超过边界就不处理function zoomIn() { if (scale.value 4) return; scale.value 0.25; } function zoomOut() { if (scale.value 0.5) return; scale.value - 0.25; }然后 watch scale 变化重新渲染watch(scale, async () { if (pdfDoc) { await renderCurrentPage(); } });旋转是一个独立的状态我用rotate按钮让它每次增加 90 度function rotate() { rotation.value (rotation.value 90) % 360; }因为旋转角度变了页面的宽高会互换viewPort 会根据 rotation 自动调整所以 renderCurrentPage 里每次都会重新计算 canvas 宽高天然支持旋转后的正确显示。需要提醒一下如果你的 PDF 包含大量页面每次缩放、翻页都重新走完整渲染流程性能上还是会有点压力。我见过一个 200 页的 PDF每翻一页要等 1 秒多。这种情况下可以考虑预渲染下一页、或者用 pdf.js 的低分辨率占位方案但这已经是另外一个话题了。基础功能跑通以后性能优化瓶颈另开一篇再说。3.3 下载和打印一行代码之外的事下载功能最简单就是利用 HTML 的 a 标签加 download 属性function download() { const link document.createElement(a); link.href pdfUrl.value; link.download fileName.value || document.pdf; link.click(); }需要注意的是如果 PDF 是从接口获取的并且是通过blob:或base64生成的那下载时不能直接用原始 URL需要确保href指向能够被浏览器访问到的数据源。对blob:URL 来说直接赋给 a 标签就行。打印功能比下载稍微麻烦一点。iframe 是最简单的打印方式把 PDF URL 塞进一个隐藏的 iframe然后调用 iframe 的 contentWindow.print()function printPdf() { const iframe document.createElement(iframe); iframe.style.display none; iframe.src pdfUrl.value; document.body.appendChild(iframe); iframe.onload () { iframe.contentWindow.print(); }; }这个方案在 Chrome 和 Edge 上都能正常调起 PDF 自带的打印预览。Safari 需要再验证但我们在实际项目中用 Chrome 内核比较多够用了。3.4 三种数据源URL、本地文件、Base64前面例子一直用 URL 加载 PDF但实际项目中还有两种常见情况用户上传本地 PDF 后用 FileReader 读取后台接口返回 Base64 字符串。处理本地文件时可以这样读取function handleFileChange(event) { const file event.target.files[0]; if (!file) return; const reader new FileReader(); reader.onload (e) { const typedArray new Uint8Array(e.target.result); loadPdf(typedArray); }; reader.readAsArrayBuffer(file); }readAsArrayBuffer拿到的结果是一个 ArrayBuffer需要转成Uint8Array再传给getDocument。pdf.js 的getDocument支持多种类型的数据输入URL 字符串、Uint8Array、ArrayBuffer等但直接传 ArrayBuffer 时它会修改原对象所以建议转成Uint8Array再传。Base64 字符串的转换稍微绕一点。Base64 本质上是一个文本编码要把每个字符转成对应的字节才能得到有效的二进制数据function base64ToUint8Array(base64) { const binaryString window.atob(base64); const len binaryString.length; const bytes new Uint8Array(len); for (let i 0; i len; i) { bytes[i] binaryString.charCodeAt(i); } return bytes; }这里有个点容易坑到人接口返回的 Base64 字符串可能带有前缀data:application/pdf;base64,转码之前一定要先把前缀去掉否则 atob 会直接抛异常。写成代码就是function cleanBase64(base64) { const commaIndex base64.indexOf(,); return commaIndex ! -1 ? base64.substring(commaIndex 1) : base64; }三种数据源的加载路径最后都汇聚到 loadPdf 方法里入口不同底层逻辑一致。我在项目里通常会把 loadPdf 拆分成 loadPdfByUrl 和 loadPdfByData 两个方法职责分开更好维护。4. 常见问题与排查技巧实录4.1 Worker 报错和打包后路径 404这是 pdfjs-dist 使用率最高的问题基本每个新手都会遇到。报错信息通常是 “Failed to fetch dynamically imported module” 或者 “The API version does not match the Worker version”。如果确认本地开发没问题、打包部署后才有问题直接去看构建产物里的 worker 文件是不是被正确输出。用?url导入时vite 会在产物目录生成pdf.worker.min-xxxx.mjs这样的文件文件名带哈希。如果找不到说明导入方式有问题检查一下是不是写成了pdf.worker.min.js而实际文件名后缀是.mjs。另外如果项目部署在子路径下需要确认base配置是否正确。vite 默认 base 是/部署在https://example.com/admin/这种子路径时静态资源路径会全部 404。解决方式是修改 vite.config 里的 base或者用import.meta.env.BASE_URL拼接 worker 路径。这个坑其实和 pdfjs 关系不大但很多同学部署后才暴露出来。4.2 本地文件协议下打不开 PDF如果你直接双击 index.html 用 file:// 协议打开打包后的页面pdfjs 大概率加载失败。原因有两点一是 file:// 协议下浏览器禁止了部分 JavaScript 行为二是 PDF 文件本身在 file:// 下读取时会有 CORS 限制。所以本地调试尽量使用 vite dev server部署时用 http/https 访问。如果你只是临时做一个静态演示可以用npx serve起一个静态服务器或者用 Python 的python -m http.server都能避免 file:// 的坑。4.3 接口返回的 PDF 文件被 CORS 拦截加载跨域 PDF 时报错为 “Access to fetch at ... has been blocked by CORS policy” 或者类似内容。这不是 pdf.js 写错而是浏览器的安全策略。前端无法绕过 CORS只能在服务端处理。开发环境可以在 vite.config 里配 proxy把 /pdf-api 开头的请求代理到目标服务器// vite.config.js export default defineConfig({ server: { proxy: { /pdf-api: { target: https://your-pdf-server.com, changeOrigin: true, rewrite: (path) path.replace(/^\/pdf-api/, ), }, }, }, });生产环境就交给 nginx 或者网关配置跨域响应头。这个属于服务端问题前端能做的有限。4.4 翻页/切换 PDF 时出现白屏或渲染错乱白屏的常见原因是渲染任务重叠。翻页的时候上一页的 render 还没结束下一页的 render 又开始了canvas 被两个任务交替写入最终结果可能是空白或者错乱画面。处理方式在 2.3 里已经讲过保存 renderTask 对象新任务开始前调用renderTask.cancel()。另外如果加载的是不同的 PDF 文件还应该在 getDocument 前销毁之前的 loadingTask因为两个文档共用同一个 worker 时会有资源竞争。我测试过最稳妥的切换流程是destroy 旧 loadingTask → 置空 pdfDoc 引用 → 创建新 loadingTask → await promise → 渲染第一页。4.5 中文 PDF 文字显示异常或复制出来乱码这个问题要分两种情况。第一种情况是 PDF 本身正常浏览器预览也正常但是把 canvas 转成图片或者打印时中文变方框。这类问题通常是字体子集化或嵌入方式导致的和 pdf.js 关系不大。解决方法是从 PDF 生成端排查要求上游系统在导出 PDF 时把字体嵌入完整。第二种情况是 copy 出来的文字是乱码这涉及到 PDF 字体编码映射。pdf.js 对大部分标准字体都能正确处理但如果 PDF 用了非标准的字体编码复制功能就会出现错乱。这个问题我目前没有特别完美的前端方案只能提示用户直接用原文件内容。对于纯展示需求来说canvas 渲染出来的画面是正常的不影响使用。4.6 常见问题排查速查表为了方便随时查阅我把上面几个问题整理成一个表格建议收藏问题现象常见原因解决办法报错 worker is not defined没有配置 GlobalWorkerOptions.workerSrc用 ?url 导入 worker 文件并赋值报错 API version mismatchpdfjs 核心与 worker 版本不一致统一版本不要混装不同版本打包后 worker 404手动复制 worker 文件遗漏改用 vite ?url 自动导入加载跨域 PDF 失败后端未开启 CORS开发环境配 proxy生产环境配 nginxfile:// 下打不开浏览器 file 协议限制使用本地服务器调试翻页白屏/错乱渲染任务未取消保存 renderTask 并调用 cancel()多次加载 PDF 后卡顿未销毁旧文档记录 loadingTask 并调用 destroy()中文变方框PDF 字体未嵌入从生成端修复前端较难解决4.7 我在真实项目中踩过的坑这里分享两个我在真实项目里遇到、并且花了比较长时间才排查出来的问题希望能帮你避开。第一个是 CDN 引入方式的问题。有一次我为了省事没有用 npm 包而是直接在 index.html 里用 script 标签引入了 pdf.js 的 CDN 版本。本地开发没问题打包上线后生产环境偶发白屏最后定位到是 CDN 资源加载不稳定而且 worker 文件的路径在 CDN 环境下不好控制。后来我全部改成 npm 包 本地构建再也没出过这类问题。非必要不要用 CDN 方式跑 pdfjs尤其是企业后台这类对稳定性要求较高的场景。第二个是 Electron 环境下的坑。vue3 electron 项目里用 pdfjs 时如果把 PDF 文件放在本地磁盘file:// 或者自定义协议加载会遇到 worker 无法跨协议访问的问题。解决方法是把 PDF 文件读成Uint8Array通过 data 方式传给getDocument绕开资源路径问题。所以如果你的项目是 electron 套壳可以直接走本地文件读取的路线。5. 封装一个通用 PDF 预览组件5.1 组件 Props 和事件设计到了这一步基础功能都跑通了。但如果在项目中只有一两个地方用 PDF 预览直接复制组件没问题要是后台管理系统里好几个模块都要预览 PDF那就值得把 PDF 预览封装成通用组件方便复用。我设计的组件的 Props 大概长这样const props defineProps({ src: { type: String, required: true, }, fileName: { type: String, default: document.pdf, }, showToolbar: { type: Boolean, default: true, }, initialScale: { type: Number, default: 1, }, });然后对外抛出几个事件const emit defineEmits([load-success, load-error, page-change]);组件内部加载成功、失败、翻页时分别触发这些事件父组件就能根据状态做额外处理比如加载完成后隐藏 loading 遮罩。父组件这样调用template PdfPreview srchttps://example.com/report.pdf fileName季度报告.pdf load-successhandleLoadSuccess load-errorhandleLoadError / /template一个通用组件的价值在于使用者不必关心 pdfjs 的细节传入一个 URL 就完事。工具栏、页码显示、加载状态都封装在内部多个模块共用一套交互。5.2 在 vue3 后台管理系统中集成预览弹窗后台管理系统最常见的使用方式就是弹窗预览。客户在列表里看到一份合同点“预览”按钮右侧滑出一个抽屉里面展示 PDF。这种场景配合 el-drawer 或者 ant-design-vue 的 Drawer 组件非常方便template el-button clickopenPreview预览合同/el-button el-drawer v-modelpreviewVisible size70% title合同预览 PdfPreview :srcpreviewUrl fileName合同文件.pdf / /el-drawer /template script setup import { ref } from vue; import PdfPreview from /components/PdfPreview.vue; const previewVisible ref(false); const previewUrl ref(); function openPreview() { const id currentRow.value.id; previewUrl.value /api/contract/pdf/${id}; previewVisible.value true; } /script注意这里有个细节不要把 PDF 的加载放在 draw 打开之前。我最初是先把 previewUrl 赋值再打开 drawer发现第一次打开时组件内 PDF 加载正常但后续切换不同的合同文件时组件复用导致文件内容不刷新。原因是 drawer 关闭后组件并没有销毁props.src 变化后没有重新加载。解决办法是在 PdfPreview 组件里监听 src 变化watch(() props.src, (newSrc) { if (newSrc) { loadPdf(newSrc); } }, { immediate: true });这样不管 src 是初始化还是后续变化都能触发加载。类似的思路也适用于其他组件复用的场景。5.3 在 vue3 ts 项目中的类型支持现在很多新项目用 vue3 ts 开发有 ts 支持的类型定义会舒服很多。pdfjs-dist 的 4.x 自带类型不需要额外安装声明文件。在 ts 项目中我们主要需要声明几个类型import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from pdfjs-dist; let pdfDoc: PDFDocumentProxy | null null; let renderTask: RenderTask | null null; let page: PDFPageProxy;有了类型之后代码提示非常友好参数传错会直接标红。这也是我建议大家尽量用 4.x 版本的原因之一。另外一个 ts 下需要注意的点pdfjs-dist/build/pdf.worker.min.mjs?url这个导入语句 vite 本身能识别但 ts 编译器不认识?url后缀。你需要在vite-env.d.ts或全局类型声明中加入/// reference typesvite/client /vite/client 类型声明里面已经包含了*.mjs?url等资源模块的声明引用了之后 ts 就不会报错。5.4 性能优化和后续扩展封装完通用组件之后我们来聊聊性能优化这可能是整个 PDF 预览方案里最能体现工程深度的地方。先说一个我实测有效的方案预渲染相邻页。用户浏览 PDF 时通常会一页一页往下翻提前把下一页渲染好能大幅提升翻页流畅度。实现思路是维护一个缓存 Mapkey 是页码value 是渲染好的 canvas 数据。渲染完当前页之后主动调用pdfDoc.getPage(currentPage 1)并渲染到一个离屏 canvas 上等用户真正翻过去时直接替换。缓存需要控制大小不然一个 200 页的 PDF 会把内存撑爆。我一般只缓存 3 页上一页、当前页、下一页。第二个优化是 Canvas 复用。每次翻页都重新设置 canvas width、height 会导致浏览器重新分配绘图缓冲区有额外的性能开销。如果 PDF 页面尺寸差不多可以固定 canvas 尺寸只在渲染前清空画布减少重复分配。第三个优化是懒加载。用户打开 PDF 时只加载第一页滚动或者翻页时再按需加载后续页面配合预渲染体验和性能都能兼顾。组件还能扩展的方向很多比如文本选择、文字高亮、区域标注、电子签章等这些都是在 pdf.js 的渲染基础上做的。建议先把基础组件跑通再根据业务需求逐一扩展。最后的补充写到这我把 vue3 集成的 pdfjs-dist 从环境搭建到封装通用组件的完整流程都过了。个人建议新项目直接按这个思路做不要为了省事用 iframe 凑合一旦业务要求自定义工具栏或深度交互重写成本会高很多。实际部署时记得把 pdfjs-dist 的版本锁定在 4.x同时处理好 worker 文件的构建路径这两点做到位基本能一直用下去。